diff --git a/.github/workflows/check-schema-changes.yml b/.github/workflows/check-schema-changes.yml new file mode 100644 index 0000000..2a2a072 --- /dev/null +++ b/.github/workflows/check-schema-changes.yml @@ -0,0 +1,153 @@ +name: Check C2PA schema changes + +on: + schedule: + # Check for schema changes every 6 hours (offset from SDK update check) + - cron: "30 */6 * * *" + workflow_dispatch: + +jobs: + check-schema: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Get current and previous c2pa-rs tags + id: get_tags + run: | + TAGS=$(curl -s "https://api.github.com/repos/contentauth/c2pa-rs/releases" | \ + jq -r '.[].tag_name | select(test("^c2pa-v"))' | \ + head -2) + + CURRENT_TAG=$(echo "$TAGS" | head -1) + PREVIOUS_TAG=$(echo "$TAGS" | tail -1) + + if [ -z "$CURRENT_TAG" ] || [ -z "$PREVIOUS_TAG" ]; then + echo "Could not find two consecutive c2pa release tags" + exit 1 + fi + + if [ "$CURRENT_TAG" = "$PREVIOUS_TAG" ]; then + echo "Only one tag found, nothing to compare" + exit 1 + fi + + echo "current_tag=$CURRENT_TAG" >> $GITHUB_OUTPUT + echo "previous_tag=$PREVIOUS_TAG" >> $GITHUB_OUTPUT + echo "Current tag: $CURRENT_TAG" + echo "Previous tag: $PREVIOUS_TAG" + + - name: Clone c2pa-rs + run: | + git clone https://github.com/contentauth/c2pa-rs.git /tmp/c2pa-rs + cd /tmp/c2pa-rs + git fetch --tags + + - name: Generate schemas for both tags + run: | + for TAG_VAR in current previous; do + if [ "$TAG_VAR" = "current" ]; then + TAG="${{ steps.get_tags.outputs.current_tag }}" + else + TAG="${{ steps.get_tags.outputs.previous_tag }}" + fi + + cd /tmp/c2pa-rs + git checkout "$TAG" + cargo run -p export_schema --features c2pa/rust_native_crypto 2>/dev/null || \ + cargo run -p export_schema 2>/dev/null + mkdir -p "/tmp/schemas-$TAG_VAR" + cp target/schema/*.schema.json "/tmp/schemas-$TAG_VAR/" + done + + - name: Compare schemas + id: compare + run: | + CHANGES="" + HAS_CHANGES=false + + for schema in /tmp/schemas-current/*.schema.json; do + NAME=$(basename "$schema") + PREV="/tmp/schemas-previous/$NAME" + + if [ ! -f "$PREV" ]; then + CHANGES="${CHANGES}- **${NAME}**: New schema (not present in previous tag)\n" + HAS_CHANGES=true + continue + fi + + DIFF=$(diff \ + <(python3 -c "import json,sys; json.dump(json.load(open('$PREV')),sys.stdout,sort_keys=True,indent=2)") \ + <(python3 -c "import json,sys; json.dump(json.load(open('$schema')),sys.stdout,sort_keys=True,indent=2)") \ + || true) + + if [ -n "$DIFF" ]; then + ADDED=$(echo "$DIFF" | grep -c '^>' || true) + REMOVED=$(echo "$DIFF" | grep -c '^<' || true) + CHANGES="${CHANGES}- **${NAME}**: ${ADDED} additions, ${REMOVED} removals\n" + HAS_CHANGES=true + fi + done + + for schema in /tmp/schemas-previous/*.schema.json; do + NAME=$(basename "$schema") + if [ ! -f "/tmp/schemas-current/$NAME" ]; then + CHANGES="${CHANGES}- **${NAME}**: Schema removed\n" + HAS_CHANGES=true + fi + done + + echo "has_changes=$HAS_CHANGES" >> $GITHUB_OUTPUT + + if [ "$HAS_CHANGES" = true ]; then + echo "changes<> $GITHUB_OUTPUT + echo -e "$CHANGES" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + fi + + - name: Ensure schema-change label exists + if: steps.compare.outputs.has_changes == 'true' + run: | + gh label create "schema-change" \ + --description "Upstream c2pa-rs JSON schema has changed" \ + --color "D93F0B" \ + 2>/dev/null || true + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Check for existing open issue + if: steps.compare.outputs.has_changes == 'true' + id: existing_issue + run: | + EXISTING=$(gh issue list \ + --label "schema-change" \ + --state open \ + --json number \ + --jq '.[0].number // empty') + + echo "number=$EXISTING" >> $GITHUB_OUTPUT + + if [ -n "$EXISTING" ]; then + echo "Open schema change issue already exists: #$EXISTING" + fi + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: File issue for schema changes + if: steps.compare.outputs.has_changes == 'true' && steps.existing_issue.outputs.number == '' + run: | + gh issue create \ + --title "C2PA schema changes detected between ${{ steps.get_tags.outputs.previous_tag }} and ${{ steps.get_tags.outputs.current_tag }}" \ + --label "schema-change" \ + --body "Review the upstream schema changes and update \`C2PASettingsDefinition.kt\` and any other typed data classes to match. + + ${{ steps.compare.outputs.changes }}" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/library/build.gradle.kts b/library/build.gradle.kts index 0d246a3..46691df 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -36,10 +36,10 @@ android { // Specify ABIs to use prebuilt .so files ndk { - abiFilters.add("x86_64") abiFilters.add("arm64-v8a") abiFilters.add("armeabi-v7a") abiFilters.add("x86") + abiFilters.add("x86_64") } } diff --git a/library/gradle.properties b/library/gradle.properties index d2535b9..0fe5ca1 100644 --- a/library/gradle.properties +++ b/library/gradle.properties @@ -1,3 +1,3 @@ # C2PA Native Library Version # Update this to use a different release from https://github.com/contentauth/c2pa-rs/releases -c2paVersion=v0.75.8 +c2paVersion=v0.75.19 diff --git a/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidBuilderTests.kt b/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidBuilderTests.kt index bcf8564..2f15dd9 100644 --- a/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidBuilderTests.kt +++ b/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidBuilderTests.kt @@ -1,4 +1,4 @@ -/* +/* This file is licensed to you under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) or the MIT license (http://opensource.org/licenses/MIT), at your option. @@ -9,6 +9,7 @@ ANY KIND, either express or implied. See the LICENSE-MIT and LICENSE-APACHE files for the specific language governing permissions and limitations under each license. */ + package org.contentauth.c2pa import android.content.Context @@ -85,4 +86,52 @@ class AndroidBuilderTests : BuilderTests() { val result = testJsonRoundTrip() assertTrue(result.success, "JSON Round-trip test failed: ${result.message}") } + + @Test + fun runTestBuilderFromContextWithSettings() = runBlocking { + val result = testBuilderFromContextWithSettings() + assertTrue(result.success, "Builder from Context with Settings test failed: ${result.message}") + } + + @Test + fun runTestBuilderFromJsonWithSettings() = runBlocking { + val result = testBuilderFromJsonWithSettings() + assertTrue(result.success, "Builder fromJson with Settings test failed: ${result.message}") + } + + @Test + fun runTestBuilderWithArchive() = runBlocking { + val result = testBuilderWithArchive() + assertTrue(result.success, "Builder withArchive test failed: ${result.message}") + } + + @Test + fun runTestReaderFromContext() = runBlocking { + val result = testReaderFromContext() + assertTrue(result.success, "Reader fromContext test failed: ${result.message}") + } + + @Test + fun runTestBuilderSetIntent() = runBlocking { + val result = testBuilderSetIntent() + assertTrue(result.success, "Builder Set Intent test failed: ${result.message}") + } + + @Test + fun runTestBuilderAddAction() = runBlocking { + val result = testBuilderAddAction() + assertTrue(result.success, "Builder Add Action test failed: ${result.message}") + } + + @Test + fun runTestSettingsSetValue() = runBlocking { + val result = testSettingsSetValue() + assertTrue(result.success, "C2PASettings setValue test failed: ${result.message}") + } + + @Test + fun runTestBuilderIntentEditAndUpdate() = runBlocking { + val result = testBuilderIntentEditAndUpdate() + assertTrue(result.success, "Builder Intent Edit and Update test failed: ${result.message}") + } } diff --git a/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidSettingsDefinitionTests.kt b/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidSettingsDefinitionTests.kt new file mode 100644 index 0000000..22ae9f2 --- /dev/null +++ b/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidSettingsDefinitionTests.kt @@ -0,0 +1,118 @@ +/* +This file is licensed to you under the Apache License, Version 2.0 +(http://www.apache.org/licenses/LICENSE-2.0) or the MIT license +(http://opensource.org/licenses/MIT), at your option. + +Unless required by applicable law or agreed to in writing, this software is +distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS OF +ANY KIND, either express or implied. See the LICENSE-MIT and LICENSE-APACHE +files for the specific language governing permissions and limitations under +each license. +*/ +package org.contentauth.c2pa + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.runBlocking +import org.contentauth.c2pa.test.shared.SettingsDefinitionTests +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import kotlin.test.assertTrue + +/** Android instrumented tests for C2PASettingsDefinition. */ +@RunWith(AndroidJUnit4::class) +class AndroidSettingsDefinitionTests : SettingsDefinitionTests() { + + private val targetContext = InstrumentationRegistry.getInstrumentation().targetContext + + override fun getContext(): Context = targetContext + + override fun loadResourceAsBytes(resourceName: String): ByteArray = + ResourceTestHelper.loadResourceAsBytes(resourceName) + + override fun loadResourceAsString(resourceName: String): String = + ResourceTestHelper.loadResourceAsString(resourceName) + + override fun copyResourceToFile(resourceName: String, fileName: String): File = + ResourceTestHelper.copyResourceToFile(targetContext, resourceName, fileName) + + @Test + fun runTestRoundTrip() = runBlocking { + val result = testRoundTrip() + assertTrue(result.success, "Round Trip test failed: ${result.message}") + } + + @Test + fun runTestFromJson() = runBlocking { + val result = testFromJson() + assertTrue(result.success, "fromJson test failed: ${result.message}") + } + + @Test + fun runTestSettingsIntent() = runBlocking { + val result = testSettingsIntent() + assertTrue(result.success, "Settings Intent test failed: ${result.message}") + } + + @Test + fun runTestToJson() = runBlocking { + val result = testToJson() + assertTrue(result.success, "toJson test failed: ${result.message}") + } + + @Test + fun runTestSignerSettings() = runBlocking { + val result = testSignerSettings() + assertTrue(result.success, "Signer Settings test failed: ${result.message}") + } + + @Test + fun runTestCawgSigner() = runBlocking { + val result = testCawgSigner() + assertTrue(result.success, "CAWG Signer test failed: ${result.message}") + } + + @Test + fun runTestIgnoreUnknownKeys() = runBlocking { + val result = testIgnoreUnknownKeys() + assertTrue(result.success, "Ignore Unknown Keys test failed: ${result.message}") + } + + @Test + fun runTestBuilderSettings() = runBlocking { + val result = testBuilderSettings() + assertTrue(result.success, "Builder Settings test failed: ${result.message}") + } + + @Test + fun runTestFromDefinition() = runBlocking { + val result = testFromDefinition() + assertTrue(result.success, "fromDefinition test failed: ${result.message}") + } + + @Test + fun runTestUpdateFrom() = runBlocking { + val result = testUpdateFrom() + assertTrue(result.success, "updateFrom test failed: ${result.message}") + } + + @Test + fun runTestPrettyJson() = runBlocking { + val result = testPrettyJson() + assertTrue(result.success, "Pretty JSON test failed: ${result.message}") + } + + @Test + fun runTestEnumSerialization() = runBlocking { + val result = testEnumSerialization() + assertTrue(result.success, "Enum Serialization test failed: ${result.message}") + } + + @Test + fun runTestActionTemplates() = runBlocking { + val result = testActionTemplates() + assertTrue(result.success, "Action Templates test failed: ${result.message}") + } +} diff --git a/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidStreamTests.kt b/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidStreamTests.kt index e861636..001043e 100644 --- a/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidStreamTests.kt +++ b/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidStreamTests.kt @@ -62,6 +62,21 @@ class AndroidStreamTests : StreamTests() { assertTrue(result.success, "Custom Stream Callbacks test failed: ${result.message}") } + @Test + fun runTestCallbackStreamFactories() = runBlocking { + val result = testCallbackStreamFactories() + assertTrue(result.success, "Callback Stream Factories test failed: ${result.message}") + } + + @Test + fun runTestByteArrayStreamBufferGrowth() = runBlocking { + val result = testByteArrayStreamBufferGrowth() + assertTrue( + result.success, + "ByteArrayStream Buffer Growth test failed: ${result.message}", + ) + } + @Test fun runTestFileOperationsWithDataDirectory() = runBlocking { val result = testFileOperationsWithDataDirectory() diff --git a/library/src/main/jni/c2pa_jni.c b/library/src/main/jni/c2pa_jni.c index 1bb3252..fbe6a76 100644 --- a/library/src/main/jni/c2pa_jni.c +++ b/library/src/main/jni/c2pa_jni.c @@ -750,29 +750,6 @@ JNIEXPORT jlong JNICALL Java_org_contentauth_c2pa_Reader_resourceToStreamNative( } // Builder native methods -JNIEXPORT jlong JNICALL Java_org_contentauth_c2pa_Builder_nativeFromJson(JNIEnv *env, jclass clazz, jstring manifestJson) { - if (manifestJson == NULL) { - (*env)->ThrowNew(env, (*env)->FindClass(env, "java/lang/IllegalArgumentException"), - "Manifest JSON cannot be null"); - return 0; - } - - const char *cmanifestJson = jstring_to_cstring(env, manifestJson); - if (cmanifestJson == NULL) { - return 0; - } - - struct C2paBuilder *builder = c2pa_builder_from_json(cmanifestJson); - release_cstring(env, manifestJson, cmanifestJson); - - if (builder == NULL) { - throw_c2pa_exception(env, "Failed to create builder from JSON"); - return 0; - } - - return (jlong)(uintptr_t)builder; -} - JNIEXPORT jlong JNICALL Java_org_contentauth_c2pa_Builder_nativeFromArchive(JNIEnv *env, jclass clazz, jlong streamPtr) { if (streamPtr == 0) { (*env)->ThrowNew(env, (*env)->FindClass(env, "java/lang/IllegalArgumentException"), @@ -1216,6 +1193,247 @@ JNIEXPORT void JNICALL Java_org_contentauth_c2pa_Signer_free(JNIEnv *env, jobjec } } +// C2PASettings native methods +JNIEXPORT jlong JNICALL Java_org_contentauth_c2pa_C2PASettings_nativeNew(JNIEnv *env, jclass clazz) { + struct C2paSettings *settings = c2pa_settings_new(); + if (settings == NULL) { + return 0; + } + return (jlong)(uintptr_t)settings; +} + +JNIEXPORT jint JNICALL Java_org_contentauth_c2pa_C2PASettings_updateFromStringNative(JNIEnv *env, jobject obj, jlong settingsPtr, jstring settingsStr, jstring format) { + if (settingsPtr == 0 || settingsStr == NULL || format == NULL) { + return -1; + } + + struct C2paSettings *settings = (struct C2paSettings*)(uintptr_t)settingsPtr; + const char *csettingsStr = jstring_to_cstring(env, settingsStr); + const char *cformat = jstring_to_cstring(env, format); + + if (csettingsStr == NULL || cformat == NULL) { + release_cstring(env, settingsStr, csettingsStr); + release_cstring(env, format, cformat); + return -1; + } + + int result = c2pa_settings_update_from_string(settings, csettingsStr, cformat); + + release_cstring(env, settingsStr, csettingsStr); + release_cstring(env, format, cformat); + + return result; +} + +JNIEXPORT jint JNICALL Java_org_contentauth_c2pa_C2PASettings_setValueNative(JNIEnv *env, jobject obj, jlong settingsPtr, jstring path, jstring value) { + if (settingsPtr == 0 || path == NULL || value == NULL) { + return -1; + } + + struct C2paSettings *settings = (struct C2paSettings*)(uintptr_t)settingsPtr; + const char *cpath = jstring_to_cstring(env, path); + const char *cvalue = jstring_to_cstring(env, value); + + if (cpath == NULL || cvalue == NULL) { + release_cstring(env, path, cpath); + release_cstring(env, value, cvalue); + return -1; + } + + int result = c2pa_settings_set_value(settings, cpath, cvalue); + + release_cstring(env, path, cpath); + release_cstring(env, value, cvalue); + + return result; +} + +JNIEXPORT void JNICALL Java_org_contentauth_c2pa_C2PASettings_free(JNIEnv *env, jobject obj, jlong settingsPtr) { + if (settingsPtr != 0) { + c2pa_free((const void*)(uintptr_t)settingsPtr); + } +} + +// C2PAContext native methods +JNIEXPORT jlong JNICALL Java_org_contentauth_c2pa_C2PAContext_nativeNew(JNIEnv *env, jclass clazz) { + struct C2paContext *context = c2pa_context_new(); + if (context == NULL) { + return 0; + } + return (jlong)(uintptr_t)context; +} + +JNIEXPORT jlong JNICALL Java_org_contentauth_c2pa_C2PAContext_nativeNewWithSettings(JNIEnv *env, jclass clazz, jlong settingsPtr) { + if (settingsPtr == 0) { + return 0; + } + + struct C2paContextBuilder *builder = c2pa_context_builder_new(); + if (builder == NULL) { + return 0; + } + + struct C2paSettings *settings = (struct C2paSettings*)(uintptr_t)settingsPtr; + int result = c2pa_context_builder_set_settings(builder, settings); + if (result < 0) { + c2pa_free(builder); + return 0; + } + + // build consumes the builder + struct C2paContext *context = c2pa_context_builder_build(builder); + if (context == NULL) { + return 0; + } + + return (jlong)(uintptr_t)context; +} + +JNIEXPORT void JNICALL Java_org_contentauth_c2pa_C2PAContext_free(JNIEnv *env, jobject obj, jlong contextPtr) { + if (contextPtr != 0) { + c2pa_free((const void*)(uintptr_t)contextPtr); + } +} + +// Builder context-based methods +JNIEXPORT jlong JNICALL Java_org_contentauth_c2pa_Builder_nativeFromContext(JNIEnv *env, jclass clazz, jlong contextPtr) { + if (contextPtr == 0) { + (*env)->ThrowNew(env, (*env)->FindClass(env, "java/lang/IllegalArgumentException"), + "Context cannot be null"); + return 0; + } + + struct C2paContext *context = (struct C2paContext*)(uintptr_t)contextPtr; + struct C2paBuilder *builder = c2pa_builder_from_context(context); + + if (builder == NULL) { + throw_c2pa_exception(env, "Failed to create builder from context"); + return 0; + } + + return (jlong)(uintptr_t)builder; +} + +JNIEXPORT jlong JNICALL Java_org_contentauth_c2pa_Builder_withDefinitionNative(JNIEnv *env, jobject obj, jlong builderPtr, jstring manifestJson) { + if (builderPtr == 0 || manifestJson == NULL) { + (*env)->ThrowNew(env, (*env)->FindClass(env, "java/lang/IllegalArgumentException"), + "Builder and manifest JSON cannot be null"); + return 0; + } + + struct C2paBuilder *builder = (struct C2paBuilder*)(uintptr_t)builderPtr; + const char *cmanifestJson = jstring_to_cstring(env, manifestJson); + if (cmanifestJson == NULL) { + return 0; + } + + // This consumes the old builder pointer + struct C2paBuilder *newBuilder = c2pa_builder_with_definition(builder, cmanifestJson); + release_cstring(env, manifestJson, cmanifestJson); + + if (newBuilder == NULL) { + throw_c2pa_exception(env, "Failed to set builder definition"); + return 0; + } + + return (jlong)(uintptr_t)newBuilder; +} + +JNIEXPORT jlong JNICALL Java_org_contentauth_c2pa_Builder_withArchiveNative(JNIEnv *env, jobject obj, jlong builderPtr, jlong streamPtr) { + if (builderPtr == 0 || streamPtr == 0) { + (*env)->ThrowNew(env, (*env)->FindClass(env, "java/lang/IllegalArgumentException"), + "Builder and stream cannot be null"); + return 0; + } + + struct C2paBuilder *builder = (struct C2paBuilder*)(uintptr_t)builderPtr; + struct C2paStream *stream = (struct C2paStream*)(uintptr_t)streamPtr; + + // This consumes the old builder pointer + struct C2paBuilder *newBuilder = c2pa_builder_with_archive(builder, stream); + + if (newBuilder == NULL) { + throw_c2pa_exception(env, "Failed to set builder archive"); + return 0; + } + + return (jlong)(uintptr_t)newBuilder; +} + +// Reader context-based methods +JNIEXPORT jlong JNICALL Java_org_contentauth_c2pa_Reader_nativeFromContext(JNIEnv *env, jclass clazz, jlong contextPtr) { + if (contextPtr == 0) { + (*env)->ThrowNew(env, (*env)->FindClass(env, "java/lang/IllegalArgumentException"), + "Context cannot be null"); + return 0; + } + + struct C2paContext *context = (struct C2paContext*)(uintptr_t)contextPtr; + struct C2paReader *reader = c2pa_reader_from_context(context); + + if (reader == NULL) { + throw_c2pa_exception(env, "Failed to create reader from context"); + return 0; + } + + return (jlong)(uintptr_t)reader; +} + +JNIEXPORT jlong JNICALL Java_org_contentauth_c2pa_Reader_withStreamNative(JNIEnv *env, jobject obj, jlong readerPtr, jstring format, jlong streamPtr) { + if (readerPtr == 0 || format == NULL || streamPtr == 0) { + (*env)->ThrowNew(env, (*env)->FindClass(env, "java/lang/IllegalArgumentException"), + "Reader, format, and stream cannot be null"); + return 0; + } + + struct C2paReader *reader = (struct C2paReader*)(uintptr_t)readerPtr; + const char *cformat = jstring_to_cstring(env, format); + if (cformat == NULL) { + return 0; + } + + struct C2paStream *stream = (struct C2paStream*)(uintptr_t)streamPtr; + + // This consumes the old reader pointer + struct C2paReader *newReader = c2pa_reader_with_stream(reader, cformat, stream); + release_cstring(env, format, cformat); + + if (newReader == NULL) { + throw_c2pa_exception(env, "Failed to configure reader with stream"); + return 0; + } + + return (jlong)(uintptr_t)newReader; +} + +JNIEXPORT jlong JNICALL Java_org_contentauth_c2pa_Reader_withFragmentNative(JNIEnv *env, jobject obj, jlong readerPtr, jstring format, jlong streamPtr, jlong fragmentPtr) { + if (readerPtr == 0 || format == NULL || streamPtr == 0 || fragmentPtr == 0) { + (*env)->ThrowNew(env, (*env)->FindClass(env, "java/lang/IllegalArgumentException"), + "Reader, format, stream, and fragment cannot be null"); + return 0; + } + + struct C2paReader *reader = (struct C2paReader*)(uintptr_t)readerPtr; + const char *cformat = jstring_to_cstring(env, format); + if (cformat == NULL) { + return 0; + } + + struct C2paStream *stream = (struct C2paStream*)(uintptr_t)streamPtr; + struct C2paStream *fragment = (struct C2paStream*)(uintptr_t)fragmentPtr; + + // This consumes the old reader pointer + struct C2paReader *newReader = c2pa_reader_with_fragment(reader, cformat, stream, fragment); + release_cstring(env, format, cformat); + + if (newReader == NULL) { + throw_c2pa_exception(env, "Failed to configure reader with fragment"); + return 0; + } + + return (jlong)(uintptr_t)newReader; +} + // Ed25519 signing JNIEXPORT jbyteArray JNICALL Java_org_contentauth_c2pa_C2PA_ed25519SignNative(JNIEnv *env, jclass clazz, jbyteArray data, jstring privateKey) { if (data == NULL || privateKey == NULL) { diff --git a/library/src/main/kotlin/org/contentauth/c2pa/Builder.kt b/library/src/main/kotlin/org/contentauth/c2pa/Builder.kt index e11f7fc..078d130 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/Builder.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/Builder.kt @@ -115,12 +115,29 @@ class Builder internal constructor(private var ptr: Long) : Closeable { loadC2PALibraries() } + /** + * Default assertion labels that should be placed in `created_assertions`. + * + * These are assertions that are typically generated by the signing application + * and should be attributed to the signer per the C2PA 2.3 specification. + * Override by passing a custom [C2PASettings] to [fromJson(String, C2PASettings)]. + */ + val DEFAULT_CREATED_ASSERTION_LABELS: List = listOf( + "c2pa.actions", + "c2pa.thumbnail.claim", + "c2pa.thumbnail.ingredient", + "c2pa.ingredient", + ) + /** * 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. + * This method automatically configures the SDK to place common assertions + * (actions, thumbnails, metadata) in `created_assertions` as intended by + * most applications. CAWG identity assertions are correctly placed in + * `gathered_assertions` per the CAWG specification. + * + * For full control over settings, use [fromJson(String, C2PASettings)]. * * @param manifestJSON The manifest definition as a JSON string * @return A Builder instance configured with the provided manifest @@ -142,12 +159,36 @@ class Builder internal constructor(private var ptr: Long) : Closeable { * """ * val builder = Builder.fromJson(manifestJson) * ``` + * + * @see DEFAULT_CREATED_ASSERTION_LABELS + * @see fromJson(String, C2PASettings) */ @JvmStatic @Throws(C2PAError::class) - fun fromJson(manifestJSON: String): Builder = executeC2PAOperation("Failed to create builder from JSON") { - val handle = nativeFromJson(manifestJSON) - if (handle == 0L) null else Builder(handle) + fun fromJson(manifestJSON: String): Builder { + if (manifestJSON.isBlank()) { + throw C2PAError.Api("Manifest JSON must not be empty") + } + + val labelsArray = DEFAULT_CREATED_ASSERTION_LABELS.joinToString(", ") { "\"$it\"" } + val settingsJson = """ + { + "version": 1, + "builder": { + "created_assertion_labels": [$labelsArray] + } + } + """.trimIndent() + + val settings = C2PASettings.create().apply { + updateFromString(settingsJson, "json") + } + val context = C2PAContext.fromSettings(settings) + settings.close() + + val builder = fromContext(context).withDefinition(manifestJSON) + context.close() + return builder } /** @@ -168,9 +209,123 @@ class Builder internal constructor(private var ptr: Long) : Closeable { if (handle == 0L) null else Builder(handle) } - @JvmStatic private external fun nativeFromJson(manifestJson: String): Long + /** + * Creates a builder from a shared [C2PAContext]. + * + * The context can be reused to create multiple builders and readers. + * The builder will inherit the context's settings. + * + * @param context The context to create the builder from + * @return A Builder instance configured with the context's settings + * @throws C2PAError.Api if the builder cannot be created + * + * @sample + * ```kotlin + * val settings = C2PASettings.create() + * .updateFromString(settingsJson, "json") + * val context = C2PAContext.fromSettings(settings) + * + * val builder = Builder.fromContext(context) + * .withDefinition(manifestJson) + * ``` + * + * @see C2PAContext + * @see withDefinition + */ + @JvmStatic + @Throws(C2PAError::class) + fun fromContext(context: C2PAContext): Builder = executeC2PAOperation("Failed to create builder from context") { + val handle = nativeFromContext(context.ptr) + if (handle == 0L) null else Builder(handle) + } + + /** + * Creates a builder from a manifest definition with custom settings. + * + * This gives full control over all SDK settings while also providing + * the manifest definition. The caller retains ownership of [settings] + * and may close it after this call returns. + * + * @param manifestJSON The manifest definition as a JSON string + * @param settings The settings to configure the builder with + * @return A Builder instance configured with the provided settings and manifest + * @throws C2PAError.Api if the JSON is invalid or settings cannot be applied + * + * @sample + * ```kotlin + * val settings = C2PASettings.create() + * .updateFromString(settingsJson, "json") + * val builder = Builder.fromJson(manifestJson, settings) + * settings.close() + * ``` + * + * @see C2PASettings + * @see fromJson + */ + @JvmStatic + @Throws(C2PAError::class) + fun fromJson(manifestJSON: String, settings: C2PASettings): Builder { + if (manifestJSON.isBlank()) { + throw C2PAError.Api("Manifest JSON must not be empty") + } + + val context = C2PAContext.fromSettings(settings) + val builder = fromContext(context).withDefinition(manifestJSON) + context.close() + return builder + } @JvmStatic private external fun nativeFromArchive(streamHandle: Long): Long + + @JvmStatic private external fun nativeFromContext(contextPtr: Long): Long + } + + /** + * Updates the builder with a new manifest definition. + * + * @param manifestJSON The manifest definition as a JSON string + * @return This builder for fluent chaining + * @throws C2PAError.Api if the manifest JSON is invalid + * + * @sample + * ```kotlin + * val builder = Builder.fromContext(context) + * .withDefinition(manifestJson) + * ``` + */ + @Throws(C2PAError::class) + fun withDefinition(manifestJSON: String): Builder { + val newPtr = withDefinitionNative(ptr, manifestJSON) + if (newPtr == 0L) { + ptr = 0 + throw C2PAError.Api(C2PA.getError() ?: "Failed to set builder definition") + } + ptr = newPtr + return this + } + + /** + * Configures the builder with an archive stream. + * + * @param archive The input stream containing the C2PA archive + * @return This builder for fluent chaining + * @throws C2PAError.Api if the archive is invalid + * + * @sample + * ```kotlin + * val builder = Builder.fromContext(context) + * .withArchive(archiveStream) + * ``` + */ + @Throws(C2PAError::class) + fun withArchive(archive: Stream): Builder { + val newPtr = withArchiveNative(ptr, archive.rawPtr) + if (newPtr == 0L) { + ptr = 0 + throw C2PAError.Api(C2PA.getError() ?: "Failed to set builder archive") + } + ptr = newPtr + return this } /** @@ -181,29 +336,26 @@ class Builder internal constructor(private var ptr: Long) : Closeable { * ingredients are required. * * @param intent The [BuilderIntent] specifying the type of manifest + * @return This builder for fluent chaining * @throws C2PAError.Api if the intent cannot be set * * @sample * ```kotlin * val builder = Builder.fromJson(manifestJson) - * builder.setIntent(BuilderIntent.Create(DigitalSourceType.DIGITAL_CAPTURE)) - * ``` - * - * @sample - * ```kotlin - * val builder = Builder.fromJson(manifestJson) - * builder.setIntent(BuilderIntent.Edit) + * .setIntent(BuilderIntent.Create(DigitalSourceType.DIGITAL_CAPTURE)) + * .addAction(Action(PredefinedAction.CREATED)) * ``` * * @see BuilderIntent * @see DigitalSourceType */ @Throws(C2PAError::class) - fun setIntent(intent: BuilderIntent) { + fun setIntent(intent: BuilderIntent): Builder { val result = setIntentNative(ptr, intent.toNativeIntent(), intent.toNativeDigitalSourceType()) if (result < 0) { throw C2PAError.Api(C2PA.getError() ?: "Failed to set intent") } + return this } /** @@ -214,57 +366,98 @@ class Builder internal constructor(private var ptr: Long) : Closeable { * history. * * @param action The [Action] to add to the manifest + * @return This builder for fluent chaining * @throws C2PAError.Api if the action cannot be added * * @sample * ```kotlin * val builder = Builder.fromJson(manifestJson) - * builder.addAction(Action(PredefinedAction.EDITED, DigitalSourceType.DIGITAL_CAPTURE)) - * builder.addAction(Action(PredefinedAction.CROPPED, DigitalSourceType.DIGITAL_CAPTURE)) + * .addAction(Action(PredefinedAction.EDITED, DigitalSourceType.DIGITAL_CAPTURE)) + * .addAction(Action(PredefinedAction.CROPPED, DigitalSourceType.DIGITAL_CAPTURE)) * ``` * * @see Action * @see PredefinedAction */ @Throws(C2PAError::class) - fun addAction(action: Action) { + fun addAction(action: Action): Builder { val result = addActionNative(ptr, action.toJson()) if (result < 0) { throw C2PAError.Api(C2PA.getError() ?: "Failed to add action") } + return this } - /** Set the no-embed flag */ - fun setNoEmbed() = setNoEmbedNative(ptr) + /** + * Sets the no-embed flag, preventing the manifest from being embedded in the asset. + * + * @return This builder for fluent chaining + */ + fun setNoEmbed(): Builder { + setNoEmbedNative(ptr) + return this + } - /** Set the remote URL */ + /** + * Sets a remote URL where the manifest will be hosted. + * + * @param url The remote URL for the manifest + * @return This builder for fluent chaining + * @throws C2PAError.Api if the remote URL cannot be set + */ @Throws(C2PAError::class) - fun setRemoteURL(url: String) { + fun setRemoteURL(url: String): Builder { val result = setRemoteUrlNative(ptr, url) if (result < 0) { throw C2PAError.Api(C2PA.getError() ?: "Failed to set remote URL") } + return this } - /** Add a resource to the builder */ + /** + * Adds a resource to the builder. + * + * @param uri The URI identifying the resource + * @param stream The stream containing the resource data + * @return This builder for fluent chaining + * @throws C2PAError.Api if the resource cannot be added + */ @Throws(C2PAError::class) - fun addResource(uri: String, stream: Stream) { + fun addResource(uri: String, stream: Stream): Builder { val result = addResourceNative(ptr, uri, stream.rawPtr) if (result < 0) { throw C2PAError.Api(C2PA.getError() ?: "Failed to add resource") } + return this } - /** Add an ingredient from a stream */ + /** + * Adds an ingredient from a stream. + * + * @param ingredientJSON JSON describing the ingredient + * @param format The MIME type of the ingredient (e.g., "image/jpeg") + * @param source The stream containing the ingredient data + * @return This builder for fluent chaining + * @throws C2PAError.Api if the ingredient cannot be added + */ @Throws(C2PAError::class) - fun addIngredient(ingredientJSON: String, format: String, source: Stream) { + fun addIngredient(ingredientJSON: String, format: String, source: Stream): Builder { val result = addIngredientFromStreamNative(ptr, ingredientJSON, format, source.rawPtr) if (result < 0) { throw C2PAError.Api(C2PA.getError() ?: "Failed to add ingredient") } + return this } - /** Write the builder to an archive */ + /** + * Writes the builder state to an archive stream. + * + * Archives are portable representations of a manifest and its associated resources + * that can later be loaded with [fromArchive] or [withArchive]. + * + * @param dest The output stream to write the archive to + * @throws C2PAError.Api if the archive cannot be written + */ @Throws(C2PAError::class) fun toArchive(dest: Stream) { val result = toArchiveNative(ptr, dest.rawPtr) @@ -273,7 +466,20 @@ class Builder internal constructor(private var ptr: Long) : Closeable { } } - /** Sign and write the manifest */ + /** + * Signs the manifest and writes the signed asset to the destination stream. + * + * This is the primary method for producing a signed C2PA asset. The source stream + * provides the original asset data, and the signed output (with embedded manifest) + * is written to the destination stream. + * + * @param format The MIME type of the asset (e.g., "image/jpeg", "image/png") + * @param source The input stream containing the original asset + * @param dest The output stream for the signed asset + * @param signer The [Signer] to use for signing + * @return A [SignResult] containing the manifest size and optional manifest bytes + * @throws C2PAError.Api if signing fails + */ @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) @@ -283,7 +489,20 @@ class Builder internal constructor(private var ptr: Long) : Closeable { return result } - /** Create a hashed placeholder for later signing */ + /** + * Creates a data-hashed placeholder for deferred signing workflows. + * + * This generates a placeholder manifest that can be embedded in an asset before + * the final signature is applied. Use [signDataHashedEmbeddable] to produce the + * final signed manifest after computing the asset's data hash. + * + * @param reservedSize The number of bytes to reserve for the manifest + * @param format The MIME type of the asset (e.g., "image/jpeg") + * @return The placeholder manifest as a byte array + * @throws C2PAError.Api if the placeholder cannot be created + * + * @see signDataHashedEmbeddable + */ @Throws(C2PAError::class) fun dataHashedPlaceholder(reservedSize: Long, format: String): ByteArray { val result = dataHashedPlaceholderNative(ptr, reservedSize, format) @@ -293,7 +512,22 @@ class Builder internal constructor(private var ptr: Long) : Closeable { return result } - /** Sign using data hash (advanced use) */ + /** + * Produces a signed manifest using a pre-computed data hash. + * + * This completes the deferred signing workflow started with [dataHashedPlaceholder]. + * The caller provides the hash of the asset data, and this method returns the final + * signed manifest bytes that can be embedded in the asset. + * + * @param signer The [Signer] to use for signing + * @param dataHash The hex-encoded hash of the asset data + * @param format The MIME type of the asset (e.g., "image/jpeg") + * @param asset Optional stream containing the asset (used for validation) + * @return The signed manifest as a byte array + * @throws C2PAError.Api if signing fails + * + * @see dataHashedPlaceholder + */ @Throws(C2PAError::class) fun signDataHashedEmbeddable(signer: Signer, dataHash: String, format: String, asset: Stream? = null): ByteArray { val result = @@ -318,6 +552,8 @@ class Builder internal constructor(private var ptr: Long) : Closeable { } private external fun free(handle: Long) + private external fun withDefinitionNative(handle: Long, manifestJson: String): Long + private external fun withArchiveNative(handle: Long, streamHandle: Long): Long private external fun setIntentNative(handle: Long, intent: Int, digitalSourceType: Int): Int private external fun addActionNative(handle: Long, actionJson: String): Int private external fun setNoEmbedNative(handle: Long) diff --git a/library/src/main/kotlin/org/contentauth/c2pa/C2PAContext.kt b/library/src/main/kotlin/org/contentauth/c2pa/C2PAContext.kt new file mode 100644 index 0000000..e9ce847 --- /dev/null +++ b/library/src/main/kotlin/org/contentauth/c2pa/C2PAContext.kt @@ -0,0 +1,105 @@ +/* +This file is licensed to you under the Apache License, Version 2.0 +(http://www.apache.org/licenses/LICENSE-2.0) or the MIT license +(http://opensource.org/licenses/MIT), at your option. + +Unless required by applicable law or agreed to in writing, this software is +distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS OF +ANY KIND, either express or implied. See the LICENSE-MIT and LICENSE-APACHE +files for the specific language governing permissions and limitations under +each license. +*/ + +package org.contentauth.c2pa + +import java.io.Closeable + +/** + * C2PA Context for creating readers and builders with shared configuration. + * + * C2PAContext wraps the native C2PAContext struct and provides an immutable, shareable + * configuration context. Once created, a context can be used to create multiple + * [Reader] and [Builder] instances that share the same settings. + * + * ## Usage + * + * ### Default context + * ```kotlin + * val context = C2PAContext.create() + * val builder = Builder.fromContext(context) + * val reader = Reader.fromContext(context) + * context.close() + * ``` + * + * ### Custom settings + * ```kotlin + * val settings = C2PASettings.create() + * .updateFromString(settingsJson, "json") + * + * val context = C2PAContext.fromSettings(settings) + * settings.close() + * + * val builder = Builder.fromContext(context) + * .withDefinition(manifestJson) + * ``` + * + * ## Resource Management + * + * C2PAContext implements [Closeable] and must be closed when done to free native resources. + * The context can be closed after creating readers/builders from it. + * + * @property ptr Internal pointer to the native C2PAContext instance + * @see C2PASettings + * @see Builder + * @see Reader + * @since 1.0.0 + */ +class C2PAContext internal constructor(internal var ptr: Long) : Closeable { + + companion object { + init { + loadC2PALibraries() + } + + /** + * Creates a context with default settings. + * + * @return A new [C2PAContext] with default configuration + * @throws C2PAError.Api if the context cannot be created + */ + @JvmStatic + @Throws(C2PAError::class) + fun create(): C2PAContext = executeC2PAOperation("Failed to create C2PAContext") { + val handle = nativeNew() + if (handle == 0L) null else C2PAContext(handle) + } + + /** + * Creates a context with custom settings. + * + * The settings are cloned internally, so the caller retains ownership of [settings]. + * + * @param settings The settings to configure this context with + * @return A new [C2PAContext] configured with the provided settings + * @throws C2PAError.Api if the context cannot be created with the given settings + */ + @JvmStatic + @Throws(C2PAError::class) + fun fromSettings(settings: C2PASettings): C2PAContext = executeC2PAOperation("Failed to create C2PAContext with settings") { + val handle = nativeNewWithSettings(settings.ptr) + if (handle == 0L) null else C2PAContext(handle) + } + + @JvmStatic private external fun nativeNew(): Long + @JvmStatic private external fun nativeNewWithSettings(settingsPtr: Long): Long + } + + override fun close() { + if (ptr != 0L) { + free(ptr) + ptr = 0 + } + } + + private external fun free(handle: Long) +} diff --git a/library/src/main/kotlin/org/contentauth/c2pa/C2PAJson.kt b/library/src/main/kotlin/org/contentauth/c2pa/C2PAJson.kt new file mode 100644 index 0000000..288f9aa --- /dev/null +++ b/library/src/main/kotlin/org/contentauth/c2pa/C2PAJson.kt @@ -0,0 +1,42 @@ +/* +This file is licensed to you under the Apache License, Version 2.0 +(http://www.apache.org/licenses/LICENSE-2.0) or the MIT license +(http://opensource.org/licenses/MIT), at your option. + +Unless required by applicable law or agreed to in writing, this software is +distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS OF +ANY KIND, either express or implied. See the LICENSE-MIT and LICENSE-APACHE +files for the specific language governing permissions and limitations under +each license. +*/ + +package org.contentauth.c2pa + +import kotlinx.serialization.json.Json + +/** + * Centralized JSON configuration for C2PA manifests and settings. + */ +object C2PAJson { + + /** + * Default JSON configuration for C2PA manifest serialization. + * + * Settings: + * - Does not encode default values (smaller output) + * - Ignores unknown keys (forward compatibility with newer C2PA versions) + */ + val default: Json = Json { + encodeDefaults = false + ignoreUnknownKeys = true + } + + /** + * Pretty-printed JSON configuration for debugging and display. + */ + val pretty: Json = Json { + encodeDefaults = false + ignoreUnknownKeys = true + prettyPrint = true + } +} diff --git a/library/src/main/kotlin/org/contentauth/c2pa/C2PASettings.kt b/library/src/main/kotlin/org/contentauth/c2pa/C2PASettings.kt new file mode 100644 index 0000000..fb70ada --- /dev/null +++ b/library/src/main/kotlin/org/contentauth/c2pa/C2PASettings.kt @@ -0,0 +1,133 @@ +/* +This file is licensed to you under the Apache License, Version 2.0 +(http://www.apache.org/licenses/LICENSE-2.0) or the MIT license +(http://opensource.org/licenses/MIT), at your option. + +Unless required by applicable law or agreed to in writing, this software is +distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS OF +ANY KIND, either express or implied. See the LICENSE-MIT and LICENSE-APACHE +files for the specific language governing permissions and limitations under +each license. +*/ + +package org.contentauth.c2pa + +import org.contentauth.c2pa.settings.C2PASettingsDefinition +import java.io.Closeable + +/** + * C2PA Settings for configuring context-based operations. + * + * C2PASettings wraps the native C2PASettings struct and provides a fluent API for + * configuring settings that can be passed to [C2PAContext]. + * + * ## Usage + * + * ```kotlin + * val settings = C2PASettings.create() + * .updateFromString("""{"version": 1, "builder": {"created_assertion_labels": ["c2pa.actions"]}}""", "json") + * .setValue("verify.verify_after_sign", "true") + * + * val context = C2PAContext.fromSettings(settings) + * settings.close() // settings can be closed after creating the context + * ``` + * + * ## Resource Management + * + * C2PASettings implements [Closeable] and must be closed when done to free native resources. + * + * @property ptr Internal pointer to the native C2PASettings instance + * @see C2PAContext + * @since 1.0.0 + */ +class C2PASettings internal constructor(internal var ptr: Long) : Closeable { + + companion object { + init { + loadC2PALibraries() + } + + /** + * Creates a new settings instance with default values. + * + * @return A new [C2PASettings] instance + * @throws C2PAError.Api if the settings cannot be created + */ + @JvmStatic + @Throws(C2PAError::class) + fun create(): C2PASettings = executeC2PAOperation("Failed to create C2PASettings") { + val handle = nativeNew() + if (handle == 0L) null else C2PASettings(handle) + } + + /** + * Creates a new settings instance from a [C2PASettingsDefinition]. + * + * @param definition The typed settings definition to apply. + * @return A new [C2PASettings] instance configured from the definition. + * @throws C2PAError.Api if the settings cannot be created or the definition is invalid. + */ + @JvmStatic + @Throws(C2PAError::class) + fun fromDefinition(definition: C2PASettingsDefinition): C2PASettings = + create().updateFrom(definition) + + @JvmStatic private external fun nativeNew(): Long + } + + /** + * Updates settings from a JSON or TOML string. + * + * @param settingsStr The settings string in JSON or TOML format + * @param format The format of the string ("json" or "toml") + * @return This settings instance for fluent chaining + * @throws C2PAError.Api if the settings string is invalid + */ + @Throws(C2PAError::class) + fun updateFromString(settingsStr: String, format: String): C2PASettings { + val result = updateFromStringNative(ptr, settingsStr, format) + if (result < 0) { + throw C2PAError.Api(C2PA.getError() ?: "Failed to update settings from string") + } + return this + } + + /** + * Updates settings from a typed [C2PASettingsDefinition]. + * + * @param definition The typed settings definition to apply. + * @return This settings instance for fluent chaining. + * @throws C2PAError.Api if the definition is invalid. + */ + @Throws(C2PAError::class) + fun updateFrom(definition: C2PASettingsDefinition): C2PASettings = + updateFromString(definition.toJson(), "json") + + /** + * Sets a specific configuration value using dot notation. + * + * @param path Dot-separated path (e.g., "verify.verify_after_sign") + * @param value JSON value as a string (e.g., "true", "\"ps256\"", "42") + * @return This settings instance for fluent chaining + * @throws C2PAError.Api if the path or value is invalid + */ + @Throws(C2PAError::class) + fun setValue(path: String, value: String): C2PASettings { + val result = setValueNative(ptr, path, value) + if (result < 0) { + throw C2PAError.Api(C2PA.getError() ?: "Failed to set settings value") + } + return this + } + + override fun close() { + if (ptr != 0L) { + free(ptr) + ptr = 0 + } + } + + private external fun free(handle: Long) + private external fun updateFromStringNative(handle: Long, settingsStr: String, format: String): Int + private external fun setValueNative(handle: Long, path: String, value: String): Int +} diff --git a/library/src/main/kotlin/org/contentauth/c2pa/CertificateManager.kt b/library/src/main/kotlin/org/contentauth/c2pa/CertificateManager.kt index 3aeb92b..eb7b110 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/CertificateManager.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/CertificateManager.kt @@ -1,4 +1,4 @@ -/* +/* This file is licensed to you under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) or the MIT license (http://opensource.org/licenses/MIT), at your option. @@ -21,7 +21,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json import org.bouncycastle.asn1.ASN1ObjectIdentifier import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers import org.bouncycastle.asn1.x500.X500Name @@ -211,8 +210,7 @@ object CertificateManager { } } - // Private helper methods - + /** Builds an X.500 distinguished name from the certificate configuration. */ private fun buildX500Name(config: CertificateConfig): X500Name { val parts = mutableListOf() parts.add("CN=${config.commonName}") @@ -225,8 +223,8 @@ object CertificateManager { return X500Name(parts.joinToString(", ")) } + /** Creates a [ContentSigner] using the Android KeyStore for the given private key. */ private fun createContentSigner(privateKey: PrivateKey): ContentSigner { - // For EC keys, use SHA256withECDSA val signatureAlgorithm = when (privateKey.algorithm) { "EC" -> "SHA256withECDSA" @@ -241,6 +239,7 @@ object CertificateManager { return AndroidKeyStoreContentSigner(privateKey, signatureAlgorithm) } + /** Converts a PKCS#10 certification request to PEM-encoded string. */ private fun csrToPEM(csr: PKCS10CertificationRequest): String { val writer = StringWriter() val pemWriter = PemWriter(writer) @@ -250,6 +249,7 @@ object CertificateManager { return writer.toString() } + /** Creates a StrongBox-backed EC key pair in the Android KeyStore. */ private fun createStrongBoxKey( config: StrongBoxSigner.Config, tempCertConfig: TempCertificateConfig = @@ -424,11 +424,6 @@ object CertificateManager { val serial_number: String, ) - private val json = Json { - ignoreUnknownKeys = true - isLenient = true - } - private suspend fun submitCSR( csr: String, metadata: CSRMetadata, @@ -448,7 +443,7 @@ object CertificateManager { apiKey?.let { connection.setRequestProperty("X-API-Key", it) } val request = CSRRequest(csr, metadata) - val requestJson = json.encodeToString(request) + val requestJson = C2PAJson.default.encodeToString(request) connection.outputStream.use { output -> output.write(requestJson.toByteArray()) @@ -458,7 +453,7 @@ object CertificateManager { val response = connection.inputStream.bufferedReader().use { it.readText() } connection.disconnect() - val csrResponse = json.decodeFromString(response) + val csrResponse = C2PAJson.default.decodeFromString(response) Result.success(csrResponse) } else { val error = diff --git a/library/src/main/kotlin/org/contentauth/c2pa/Reader.kt b/library/src/main/kotlin/org/contentauth/c2pa/Reader.kt index cfa4bc5..9d0da47 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/Reader.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/Reader.kt @@ -102,6 +102,37 @@ class Reader internal constructor(private var ptr: Long) : Closeable { if (handle == 0L) null else Reader(handle) } + /** + * Creates a reader from a shared [C2PAContext]. + * + * The context can be reused to create multiple readers and builders. + * The reader will inherit the context's settings. Use [withStream] or + * [withFragment] to configure the reader with media data. + * + * @param context The context to create the reader from + * @return A Reader instance configured with the context's settings + * @throws C2PAError.Api if the reader cannot be created + * + * @sample + * ```kotlin + * val context = C2PAContext.create() + * val reader = Reader.fromContext(context) + * .withStream("image/jpeg", stream) + * val json = reader.json() + * ``` + * + * @see C2PAContext + * @see withStream + * @see withFragment + */ + @JvmStatic + @Throws(C2PAError::class) + fun fromContext(context: C2PAContext): Reader = + executeC2PAOperation("Failed to create reader from context") { + val handle = nativeFromContext(context.ptr) + if (handle == 0L) null else Reader(handle) + } + /** * Creates a reader from manifest data and an associated media stream. * @@ -130,6 +161,8 @@ class Reader internal constructor(private var ptr: Long) : Closeable { if (handle == 0L) null else Reader(handle) } + @JvmStatic private external fun nativeFromContext(contextPtr: Long): Long + @JvmStatic private external fun fromStreamNative(format: String, streamHandle: Long): Long @JvmStatic @@ -140,6 +173,62 @@ class Reader internal constructor(private var ptr: Long) : Closeable { ): Long } + /** + * Configures the reader with a media stream. + * + * @param format The MIME type of the media (e.g., "image/jpeg", "video/mp4") + * @param stream The input stream containing the media file + * @return This reader for fluent chaining + * @throws C2PAError.Api if the stream cannot be read or the format is unsupported + * + * @sample + * ```kotlin + * val reader = Reader.fromContext(context) + * .withStream("image/jpeg", stream) + * val json = reader.json() + * ``` + */ + @Throws(C2PAError::class) + fun withStream(format: String, stream: Stream): Reader { + val newPtr = withStreamNative(ptr, format, stream.rawPtr) + if (newPtr == 0L) { + ptr = 0 + throw C2PAError.Api(C2PA.getError() ?: "Failed to configure reader with stream") + } + ptr = newPtr + return this + } + + /** + * Configures the reader with a fragment stream for fragmented media. + * + * This is used for fragmented BMFF media formats where manifests are stored + * in separate fragments. + * + * @param format The MIME type of the media (e.g., "video/mp4") + * @param stream The main asset stream + * @param fragment The fragment stream + * @return This reader for fluent chaining + * @throws C2PAError.Api if the streams cannot be read or the format is unsupported + * + * @sample + * ```kotlin + * val reader = Reader.fromContext(context) + * .withFragment("video/mp4", mainStream, fragmentStream) + * val json = reader.json() + * ``` + */ + @Throws(C2PAError::class) + fun withFragment(format: String, stream: Stream, fragment: Stream): Reader { + val newPtr = withFragmentNative(ptr, format, stream.rawPtr, fragment.rawPtr) + if (newPtr == 0L) { + ptr = 0 + throw C2PAError.Api(C2PA.getError() ?: "Failed to configure reader with fragment") + } + ptr = newPtr + return this + } + /** * Converts the C2PA manifest to a JSON string representation. * @@ -302,6 +391,8 @@ class Reader internal constructor(private var ptr: Long) : Closeable { } private external fun free(handle: Long) + private external fun withStreamNative(handle: Long, format: String, streamHandle: Long): Long + private external fun withFragmentNative(handle: Long, format: String, streamHandle: Long, fragmentHandle: Long): Long private external fun toJsonNative(handle: Long): String? private external fun toDetailedJsonNative(handle: Long): String? private external fun remoteUrlNative(handle: Long): String? diff --git a/library/src/main/kotlin/org/contentauth/c2pa/Stream.kt b/library/src/main/kotlin/org/contentauth/c2pa/Stream.kt index e6de3ed..912fc59 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/Stream.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/Stream.kt @@ -1,4 +1,4 @@ -/* +/* This file is licensed to you under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) or the MIT license (http://opensource.org/licenses/MIT), at your option. @@ -12,7 +12,6 @@ each license. package org.contentauth.c2pa -import java.io.ByteArrayOutputStream import java.io.Closeable import java.io.File import java.io.IOException @@ -37,6 +36,12 @@ typealias StreamFlusher = () -> Int /** Abstract base class for C2PA streams */ abstract class Stream : Closeable { + companion object { + init { + loadC2PALibraries() + } + } + private var nativeHandle: Long = 0 internal val rawPtr: Long get() = nativeHandle @@ -96,10 +101,17 @@ class DataStream(private val data: ByteArray) : Stream() { override fun write(data: ByteArray, length: Long): Long = throw UnsupportedOperationException("DataStream is read-only") - override fun flush(): Long = 0L + + override fun flush(): Long = + throw UnsupportedOperationException("DataStream is read-only") } -/** Stream implementation with callbacks */ +/** + * Stream implementation with callbacks. + * + * Consider using the type-safe factory methods [forReading], [forWriting], or [forReadWrite] + * to ensure required callbacks are provided at compile time. + */ class CallbackStream( private val reader: StreamReader? = null, private val seeker: StreamSeeker? = null, @@ -117,7 +129,7 @@ class CallbackStream( override fun seek(offset: Long, mode: Int): Long { val seekMode = - SeekMode.values().find { it.value == mode } + SeekMode.entries.find { it.value == mode } ?: throw IllegalArgumentException("Invalid seek mode: $mode") return seeker?.invoke(offset, seekMode) ?: throw UnsupportedOperationException( @@ -137,6 +149,55 @@ class CallbackStream( ?: throw UnsupportedOperationException( "Flush operation not supported: no flusher callback provided", ) + + companion object { + /** + * Creates a read-only callback stream. + * + * @param reader Callback to read data into a buffer, returning bytes read. + * @param seeker Callback to seek to a position, returning the new position. + * @return A CallbackStream configured for reading. + */ + fun forReading( + reader: StreamReader, + seeker: StreamSeeker, + ): CallbackStream = CallbackStream(reader = reader, seeker = seeker) + + /** + * Creates a write-only callback stream. + * + * @param writer Callback to write data from a buffer, returning bytes written. + * @param seeker Callback to seek to a position, returning the new position. + * @param flusher Callback to flush the stream, returning 0 on success. + * @return A CallbackStream configured for writing. + */ + fun forWriting( + writer: StreamWriter, + seeker: StreamSeeker, + flusher: StreamFlusher, + ): CallbackStream = CallbackStream(writer = writer, seeker = seeker, flusher = flusher) + + /** + * Creates a read-write callback stream. + * + * @param reader Callback to read data into a buffer, returning bytes read. + * @param writer Callback to write data from a buffer, returning bytes written. + * @param seeker Callback to seek to a position, returning the new position. + * @param flusher Callback to flush the stream, returning 0 on success. + * @return A CallbackStream configured for both reading and writing. + */ + fun forReadWrite( + reader: StreamReader, + writer: StreamWriter, + seeker: StreamSeeker, + flusher: StreamFlusher, + ): CallbackStream = CallbackStream( + reader = reader, + writer = writer, + seeker = seeker, + flusher = flusher, + ) + } } /** File-based stream implementation */ @@ -223,17 +284,13 @@ class FileStream(fileURL: File, mode: Mode = Mode.READ_WRITE, createIfNeeded: Bo * output. */ class ByteArrayStream(initialData: ByteArray? = null) : Stream() { - private val buffer = ByteArrayOutputStream() + private var data: ByteArray = initialData?.copyOf() ?: ByteArray(0) private var position = 0 - private var data: ByteArray = initialData ?: ByteArray(0) - - init { - initialData?.let { buffer.write(it) } - } + private var size = data.size override fun read(buffer: ByteArray, length: Long): Long { - if (position >= data.size) return 0 - val toRead = minOf(length.toInt(), data.size - position) + if (position >= size) return 0 + val toRead = minOf(length.toInt(), size - position) System.arraycopy(data, position, buffer, 0, toRead) position += toRead return toRead.toLong() @@ -244,42 +301,37 @@ class ByteArrayStream(initialData: ByteArray? = null) : Stream() { when (mode) { SeekMode.START.value -> offset.toInt() SeekMode.CURRENT.value -> position + offset.toInt() - SeekMode.END.value -> data.size + offset.toInt() + SeekMode.END.value -> size + offset.toInt() else -> return -1L } - position = position.coerceIn(0, data.size) + position = position.coerceIn(0, size) return position.toLong() } - override fun write(writeData: ByteArray, length: Long): Long { + override fun write(data: ByteArray, length: Long): Long { val len = length.toInt() - if (position < data.size) { - // Writing in the middle - need to handle carefully - val newData = data.toMutableList() - for (i in 0 until len) { - if (position + i < newData.size) { - newData[position + i] = writeData[i] - } else { - newData.add(writeData[i]) - } - } - data = newData.toByteArray() - buffer.reset() - buffer.write(data) - } else { - // Appending - buffer.write(writeData, 0, len) - data = buffer.toByteArray() + val requiredCapacity = position + len + + // Expand buffer if needed (grow by 2x or to required size, whichever is larger) + if (requiredCapacity > this.data.size) { + val newCapacity = maxOf(this.data.size * 2, requiredCapacity) + this.data = this.data.copyOf(newCapacity) } + + // Copy data directly into buffer + System.arraycopy(data, 0, this.data, position, len) position += len - return length - } - override fun flush(): Long { - data = buffer.toByteArray() - return 0 + // Update size if we wrote past the current end + if (position > size) { + size = position + } + + return len.toLong() } + override fun flush(): Long = 0 + /** Get the current data in the stream */ - fun getData(): ByteArray = data + fun getData(): ByteArray = data.copyOf(size) } diff --git a/library/src/main/kotlin/org/contentauth/c2pa/WebServiceSigner.kt b/library/src/main/kotlin/org/contentauth/c2pa/WebServiceSigner.kt index 9419104..c47d696 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/WebServiceSigner.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/WebServiceSigner.kt @@ -1,4 +1,4 @@ -/* +/* This file is licensed to you under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) or the MIT license (http://opensource.org/licenses/MIT), at your option. @@ -14,7 +14,6 @@ package org.contentauth.c2pa import android.util.Base64 import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request @@ -39,12 +38,6 @@ class WebServiceSigner( private val bearerToken: String? = null, private val customHeaders: Map = emptyMap(), ) { - - private val json = Json { - ignoreUnknownKeys = true - isLenient = true - } - private val httpClient = OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) @@ -98,13 +91,13 @@ class WebServiceSigner( val responseBody = response.body?.string() ?: throw SignerException.InvalidResponse - return json.decodeFromString(responseBody) + return C2PAJson.default.decodeFromString(responseBody) } private fun signData(data: ByteArray, signingURL: String): ByteArray { val dataToSignBase64 = Base64.encodeToString(data, Base64.NO_WRAP) val requestJson = - json.encodeToString(SignRequest.serializer(), SignRequest(claim = dataToSignBase64)) + C2PAJson.default.encodeToString(SignRequest.serializer(), SignRequest(claim = dataToSignBase64)) val requestBuilder = Request.Builder() @@ -124,7 +117,7 @@ class WebServiceSigner( } val responseBody = response.body?.string() ?: throw SignerException.InvalidResponse - val signResponse = json.decodeFromString(responseBody) + val signResponse = C2PAJson.default.decodeFromString(responseBody) return Base64.decode(signResponse.signature, Base64.NO_WRAP) } diff --git a/library/src/main/kotlin/org/contentauth/c2pa/settings/C2PASettingsDefinition.kt b/library/src/main/kotlin/org/contentauth/c2pa/settings/C2PASettingsDefinition.kt new file mode 100644 index 0000000..459afec --- /dev/null +++ b/library/src/main/kotlin/org/contentauth/c2pa/settings/C2PASettingsDefinition.kt @@ -0,0 +1,619 @@ +/* +This file is licensed to you under the Apache License, Version 2.0 +(http://www.apache.org/licenses/LICENSE-2.0) or the MIT license +(http://opensource.org/licenses/MIT), at your option. + +Unless required by applicable law or agreed to in writing, this software is +distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS OF +ANY KIND, either express or implied. See the LICENSE-MIT and LICENSE-APACHE +files for the specific language governing permissions and limitations under +each license. +*/ + +package org.contentauth.c2pa.settings + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.contentauth.c2pa.C2PAJson +import org.contentauth.c2pa.manifest.ResourceRef + +/** + * Typed representation of C2PA settings, matching the Rust `Settings` struct in c2pa-rs. + * + * Provides compile-time type safety for constructing settings JSON. The native C2PA library + * handles actual validation when the JSON is passed to [org.contentauth.c2pa.C2PASettings.updateFromString]. + * + * Generated from the c2pa-rs JSON Schema (`library/schemas/c2pa-settings.schema.json`). + * + * ## Usage + * + * ```kotlin + * val definition = C2PASettingsDefinition( + * version = 1, + * verify = VerifySettings(verifyAfterSign = true), + * ) + * val settings = C2PASettings.fromDefinition(definition) + * ``` + * + * @property version Configuration version (must be 1). + * @property trust Settings for configuring the C2PA trust lists. + * @property cawgTrust Settings for configuring the CAWG trust lists. + * @property core Settings for configuring core features. + * @property verify Settings for configuring verification. + * @property builder Settings for configuring the builder. + * @property signer Settings for configuring the base C2PA signer. + * @property cawgX509Signer Settings for configuring the CAWG x509 signer. + * @see org.contentauth.c2pa.C2PASettings + * @since 1.0.0 + */ +@Serializable +data class C2PASettingsDefinition( + val version: Int? = null, + val trust: TrustSettings? = null, + @SerialName("cawg_trust") + val cawgTrust: TrustSettings? = null, + val core: CoreSettings? = null, + val verify: VerifySettings? = null, + val builder: BuilderSettingsDefinition? = null, + val signer: SignerSettings? = null, + @SerialName("cawg_x509_signer") + val cawgX509Signer: SignerSettings? = null, +) { + + companion object { + /** + * Deserializes a [C2PASettingsDefinition] from a JSON string. + * + * @param jsonString The JSON string to parse. + * @return The deserialized settings definition. + */ + fun fromJson(jsonString: String): C2PASettingsDefinition = + C2PAJson.default.decodeFromString(serializer(), jsonString) + } + + /** Serializes this settings definition to a compact JSON string. */ + fun toJson(): String = C2PAJson.default.encodeToString(serializer(), this) + + /** Serializes this settings definition to a pretty-printed JSON string. */ + fun toPrettyJson(): String = C2PAJson.pretty.encodeToString(serializer(), this) +} + +/** + * Trust list configuration. + * + * @property verifyTrustList Whether to verify trust lists. + * @property userAnchors User-defined trust anchors (PEM format). + * @property trustAnchors Trust anchors (PEM format). + * @property trustConfig Trust configuration. + * @property allowedList Allowed list configuration. + */ +@Serializable +data class TrustSettings( + @SerialName("verify_trust_list") + val verifyTrustList: Boolean? = null, + @SerialName("user_anchors") + val userAnchors: String? = null, + @SerialName("trust_anchors") + val trustAnchors: String? = null, + @SerialName("trust_config") + val trustConfig: String? = null, + @SerialName("allowed_list") + val allowedList: String? = null, +) + +/** + * Core settings for configuring fundamental behavior. + * + * @property merkleTreeChunkSizeInKb Chunk size for Merkle tree computation in KB. + * @property merkleTreeMaxProofs Maximum number of Merkle tree proofs. + * @property backingStoreMemoryThresholdInMb Memory threshold before spilling to disk in MB. + * @property decodeIdentityAssertions Whether to decode identity assertions. + * @property allowedNetworkHosts Allowed network host patterns for fetching remote resources. + */ +@Serializable +data class CoreSettings( + @SerialName("merkle_tree_chunk_size_in_kb") + val merkleTreeChunkSizeInKb: Int? = null, + @SerialName("merkle_tree_max_proofs") + val merkleTreeMaxProofs: Int? = null, + @SerialName("backing_store_memory_threshold_in_mb") + val backingStoreMemoryThresholdInMb: Int? = null, + @SerialName("decode_identity_assertions") + val decodeIdentityAssertions: Boolean? = null, + @SerialName("allowed_network_hosts") + val allowedNetworkHosts: List? = null, +) + +/** + * Verification settings. + * + * @property verifyAfterReading Whether to verify manifests after reading. + * @property verifyAfterSign Whether to verify manifests after signing. + * @property verifyTrust Whether to verify trust. + * @property verifyTimestampTrust Whether to verify timestamp trust. + * @property ocspFetch Whether to fetch OCSP responses. + * @property remoteManifestFetch Whether to fetch remote manifests. + * @property skipIngredientConflictResolution Whether to skip ingredient conflict resolution. + * @property strictV1Validation Whether to use strict V1 validation. + */ +@Serializable +data class VerifySettings( + @SerialName("verify_after_reading") + val verifyAfterReading: Boolean? = null, + @SerialName("verify_after_sign") + val verifyAfterSign: Boolean? = null, + @SerialName("verify_trust") + val verifyTrust: Boolean? = null, + @SerialName("verify_timestamp_trust") + val verifyTimestampTrust: Boolean? = null, + @SerialName("ocsp_fetch") + val ocspFetch: Boolean? = null, + @SerialName("remote_manifest_fetch") + val remoteManifestFetch: Boolean? = null, + @SerialName("skip_ingredient_conflict_resolution") + val skipIngredientConflictResolution: Boolean? = null, + @SerialName("strict_v1_validation") + val strictV1Validation: Boolean? = null, +) + +/** + * Builder settings for configuring manifest creation. + * + * @property vendor Vendor identifier. + * @property claimGeneratorInfo Claim generator information. + * @property thumbnail Thumbnail generation settings. + * @property actions Actions settings. + * @property certificateStatusFetch OCSP fetch scope for certificate status. + * @property certificateStatusShouldOverride Whether certificate status should override. + * @property intent Default builder intent. + * @property createdAssertionLabels Labels for assertions marked as created. + * @property preferBoxHash Whether to prefer box hash. + * @property generateC2paArchive Whether to generate a C2PA archive. + * @property autoTimestampAssertion Automatic timestamp assertion settings. + */ +@Serializable +data class BuilderSettingsDefinition( + val vendor: String? = null, + @SerialName("claim_generator_info") + val claimGeneratorInfo: ClaimGeneratorInfoSettings? = null, + val thumbnail: ThumbnailSettings? = null, + val actions: ActionsSettings? = null, + @SerialName("certificate_status_fetch") + val certificateStatusFetch: OcspFetchScope? = null, + @SerialName("certificate_status_should_override") + val certificateStatusShouldOverride: Boolean? = null, + val intent: SettingsIntent? = null, + @SerialName("created_assertion_labels") + val createdAssertionLabels: List? = null, + @SerialName("prefer_box_hash") + val preferBoxHash: Boolean? = null, + @SerialName("generate_c2pa_archive") + val generateC2paArchive: Boolean? = null, + @SerialName("auto_timestamp_assertion") + val autoTimestampAssertion: TimeStampSettings? = null, +) + +/** + * Claim generator information for settings. + * + * @property name Name of the claim generator. + * @property version Version of the claim generator. + * @property icon Reference to an icon resource. + * @property operatingSystem Operating system identifier (null for auto-detection). + * @property other Additional key-value pairs flattened into the JSON output. + */ +@Serializable +data class ClaimGeneratorInfoSettings( + val name: String, + val version: String? = null, + val icon: ResourceRef? = null, + @SerialName("operating_system") + val operatingSystem: String? = null, + val other: Map? = null, +) + +/** + * Thumbnail generation settings. + * + * @property enabled Whether to generate thumbnails. + * @property ignoreErrors Whether to ignore thumbnail generation errors. + * @property longEdge Maximum long edge size in pixels. + * @property format Thumbnail image format. + * @property preferSmallestFormat Whether to prefer the smallest format. + * @property quality Thumbnail image quality. + */ +@Serializable +data class ThumbnailSettings( + val enabled: Boolean? = null, + @SerialName("ignore_errors") + val ignoreErrors: Boolean? = null, + @SerialName("long_edge") + val longEdge: Int? = null, + val format: ThumbnailFormat? = null, + @SerialName("prefer_smallest_format") + val preferSmallestFormat: Boolean? = null, + val quality: ThumbnailQuality? = null, +) + +/** Thumbnail image format. */ +@Serializable +enum class ThumbnailFormat { + @SerialName("png") + PNG, + + @SerialName("jpeg") + JPEG, + + @SerialName("gif") + GIF, + + @SerialName("webp") + WEBP, + + @SerialName("tiff") + TIFF, + ; +} + +/** Thumbnail image quality. */ +@Serializable +enum class ThumbnailQuality { + @SerialName("low") + LOW, + + @SerialName("medium") + MEDIUM, + + @SerialName("high") + HIGH, + ; +} + +/** + * Actions settings for configuring automatic action generation. + * + * @property allActionsIncluded Whether all actions are included. + * @property templates Action templates added to the Actions assertion. + * @property actions Action definitions (excluded from the JSON schema due to CBOR dependency; use [JsonElement]). + * @property autoCreatedAction Auto-generated created action settings. + * @property autoOpenedAction Auto-generated opened action settings. + * @property autoPlacedAction Auto-generated placed action settings. + */ +@Serializable +data class ActionsSettings( + @SerialName("all_actions_included") + val allActionsIncluded: Boolean? = null, + val templates: List? = null, + val actions: JsonElement? = null, + @SerialName("auto_created_action") + val autoCreatedAction: AutoActionSettings? = null, + @SerialName("auto_opened_action") + val autoOpenedAction: AutoActionSettings? = null, + @SerialName("auto_placed_action") + val autoPlacedAction: AutoActionSettings? = null, +) + +/** + * Settings for an action template. + * + * @property action The label associated with this action (e.g., "c2pa.created"). + * @property softwareAgent The software agent that performed the action. + * @property softwareAgentIndex 0-based index into the softwareAgents array. + * @property sourceType One of the defined URI values at `https://cv.iptc.org/newscodes/digitalsourcetype/`. + * @property icon Reference to an icon resource. + * @property description Description of the template. + * @property templateParameters Additional parameters for the template. + */ +@Serializable +data class ActionTemplateSettings( + val action: String, + @SerialName("software_agent") + val softwareAgent: ClaimGeneratorInfoSettings? = null, + @SerialName("software_agent_index") + val softwareAgentIndex: Int? = null, + @SerialName("source_type") + val sourceType: String? = null, + val icon: ResourceRef? = null, + val description: String? = null, + @SerialName("template_parameters") + val templateParameters: JsonObject? = null, +) + +/** + * Settings for automatic action generation. + * + * @property enabled Whether this auto action is enabled. + * @property sourceType Digital source type for the action. + */ +@Serializable +data class AutoActionSettings( + val enabled: Boolean, + @SerialName("source_type") + val sourceType: String? = null, +) + +/** + * Timestamp assertion settings. + * + * @property enabled Whether to auto-generate a timestamp assertion. + * @property skipExisting Whether to skip fetching timestamps for manifests that already have one. + * @property fetchScope Which manifests to fetch timestamps for. + */ +@Serializable +data class TimeStampSettings( + val enabled: Boolean? = null, + @SerialName("skip_existing") + val skipExisting: Boolean? = null, + @SerialName("fetch_scope") + val fetchScope: TimeStampFetchScope? = null, +) + +/** Scope of manifests to fetch timestamps for. */ +@Serializable +enum class TimeStampFetchScope { + @SerialName("parent") + PARENT, + + @SerialName("all") + ALL, + ; +} + +/** Scope of manifests to fetch OCSP responses for. */ +@Serializable +enum class OcspFetchScope { + @SerialName("all") + ALL, + + @SerialName("active") + ACTIVE, + ; +} + +/** + * Builder intent for settings, matching the Rust `BuilderIntent` enum. + * + * Serialized as: + * - `"edit"` for [Edit] + * - `"update"` for [Update] + * - `{"create": ""}` for [Create] + */ +@Serializable(with = SettingsIntentSerializer::class) +sealed class SettingsIntent { + + /** + * A new digital creation with the specified digital source type URL. + * + * @property digitalSourceType The IPTC/C2PA digital source type URL. + */ + data class Create(val digitalSourceType: String) : SettingsIntent() + + /** An edit of a pre-existing parent asset. */ + data object Edit : SettingsIntent() + + /** A restricted version of edit for non-editorial changes. */ + data object Update : SettingsIntent() +} + +/** + * Custom serializer for [SettingsIntent] that handles the polymorphic JSON format. + * + * - `"edit"` / `"update"` are serialized as plain strings. + * - `Create` is serialized as `{"create": ""}`. + */ +internal object SettingsIntentSerializer : KSerializer { + + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("SettingsIntent") + + @OptIn(ExperimentalSerializationApi::class) + override fun serialize(encoder: Encoder, value: SettingsIntent) { + val jsonEncoder = encoder as? JsonEncoder + ?: throw IllegalStateException("SettingsIntent can only be serialized as JSON") + + val element: JsonElement = when (value) { + is SettingsIntent.Edit -> JsonPrimitive("edit") + is SettingsIntent.Update -> JsonPrimitive("update") + is SettingsIntent.Create -> buildJsonObject { + put("create", JsonPrimitive(value.digitalSourceType)) + } + } + jsonEncoder.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): SettingsIntent { + val jsonDecoder = decoder as? JsonDecoder + ?: throw IllegalStateException("SettingsIntent can only be deserialized from JSON") + + val element = jsonDecoder.decodeJsonElement() + return when { + element is JsonPrimitive && element.isString -> { + when (element.content) { + "edit" -> SettingsIntent.Edit + "update" -> SettingsIntent.Update + else -> throw IllegalStateException("Unknown intent string: ${element.content}") + } + } + element is JsonObject -> { + val sourceType = element["create"]?.jsonPrimitive?.content + ?: throw IllegalStateException("Expected 'create' key in intent object") + SettingsIntent.Create(sourceType) + } + else -> throw IllegalStateException("Unexpected JSON element for SettingsIntent: $element") + } + } +} + +/** + * Signer settings, matching the Rust `SignerSettings` enum. + * + * Serialized as `{"local": {...}}` or `{"remote": {...}}`. + */ +@Serializable(with = SignerSettingsSerializer::class) +sealed class SignerSettings { + + /** + * A locally configured signer. + * + * @property alg Signing algorithm. + * @property signCert Certificate chain in PEM format. + * @property privateKey Private key in PEM format. + * @property tsaUrl Time stamp authority URL. + * @property referencedAssertions Referenced assertions for CAWG identity signing. + * @property roles Roles for CAWG identity signing. + */ + data class Local( + val alg: String, + @SerialName("sign_cert") + val signCert: String, + @SerialName("private_key") + val privateKey: String, + @SerialName("tsa_url") + val tsaUrl: String? = null, + @SerialName("referenced_assertions") + val referencedAssertions: List? = null, + val roles: List? = null, + ) : SignerSettings() + + /** + * A remotely configured signer. + * + * @property url URL that the signer will use for signing. + * @property alg Signing algorithm. + * @property signCert Certificate chain in PEM format. + * @property tsaUrl Time stamp authority URL. + * @property referencedAssertions Referenced assertions for CAWG identity signing. + * @property roles Roles for CAWG identity signing. + */ + data class Remote( + val url: String, + val alg: String, + @SerialName("sign_cert") + val signCert: String, + @SerialName("tsa_url") + val tsaUrl: String? = null, + @SerialName("referenced_assertions") + val referencedAssertions: List? = null, + val roles: List? = null, + ) : SignerSettings() +} + +/** + * Custom serializer for [SignerSettings] that handles the tagged enum format. + * + * - Local is serialized as `{"local": {"alg": ..., "sign_cert": ..., "private_key": ...}}`. + * - Remote is serialized as `{"remote": {"url": ..., "alg": ..., "sign_cert": ...}}`. + */ +internal object SignerSettingsSerializer : KSerializer { + + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("SignerSettings") + + @OptIn(ExperimentalSerializationApi::class) + override fun serialize(encoder: Encoder, value: SignerSettings) { + val jsonEncoder = encoder as? JsonEncoder + ?: throw IllegalStateException("SignerSettings can only be serialized as JSON") + + val jsonObject = when (value) { + is SignerSettings.Local -> buildJsonObject { + put("local", buildJsonObject { + put("alg", JsonPrimitive(value.alg)) + put("sign_cert", JsonPrimitive(value.signCert)) + put("private_key", JsonPrimitive(value.privateKey)) + value.tsaUrl?.let { put("tsa_url", JsonPrimitive(it)) } + value.referencedAssertions?.let { refs -> + put("referenced_assertions", kotlinx.serialization.json.JsonArray( + refs.map { JsonPrimitive(it) }, + )) + } + value.roles?.let { roles -> + put("roles", kotlinx.serialization.json.JsonArray( + roles.map { JsonPrimitive(it) }, + )) + } + }) + } + is SignerSettings.Remote -> buildJsonObject { + put("remote", buildJsonObject { + put("url", JsonPrimitive(value.url)) + put("alg", JsonPrimitive(value.alg)) + put("sign_cert", JsonPrimitive(value.signCert)) + value.tsaUrl?.let { put("tsa_url", JsonPrimitive(it)) } + value.referencedAssertions?.let { refs -> + put("referenced_assertions", kotlinx.serialization.json.JsonArray( + refs.map { JsonPrimitive(it) }, + )) + } + value.roles?.let { roles -> + put("roles", kotlinx.serialization.json.JsonArray( + roles.map { JsonPrimitive(it) }, + )) + } + }) + } + } + jsonEncoder.encodeJsonElement(jsonObject) + } + + override fun deserialize(decoder: Decoder): SignerSettings { + val jsonDecoder = decoder as? JsonDecoder + ?: throw IllegalStateException("SignerSettings can only be deserialized from JSON") + + val jsonObject = jsonDecoder.decodeJsonElement().jsonObject + + return when { + "local" in jsonObject -> { + val local = jsonObject["local"]!!.jsonObject + SignerSettings.Local( + alg = local["alg"]!!.jsonPrimitive.content, + signCert = local["sign_cert"]!!.jsonPrimitive.content, + privateKey = local["private_key"]!!.jsonPrimitive.content, + tsaUrl = local["tsa_url"]?.takeIf { it !is JsonNull }?.jsonPrimitive?.content, + referencedAssertions = local["referenced_assertions"] + ?.takeIf { it !is JsonNull } + ?.let { element -> + (element as kotlinx.serialization.json.JsonArray).map { it.jsonPrimitive.content } + }, + roles = local["roles"] + ?.takeIf { it !is JsonNull } + ?.let { element -> + (element as kotlinx.serialization.json.JsonArray).map { it.jsonPrimitive.content } + }, + ) + } + "remote" in jsonObject -> { + val remote = jsonObject["remote"]!!.jsonObject + SignerSettings.Remote( + url = remote["url"]!!.jsonPrimitive.content, + alg = remote["alg"]!!.jsonPrimitive.content, + signCert = remote["sign_cert"]!!.jsonPrimitive.content, + tsaUrl = remote["tsa_url"]?.takeIf { it !is JsonNull }?.jsonPrimitive?.content, + referencedAssertions = remote["referenced_assertions"] + ?.takeIf { it !is JsonNull } + ?.let { element -> + (element as kotlinx.serialization.json.JsonArray).map { it.jsonPrimitive.content } + }, + roles = remote["roles"] + ?.takeIf { it !is JsonNull } + ?.let { element -> + (element as kotlinx.serialization.json.JsonArray).map { it.jsonPrimitive.content } + }, + ) + } + else -> throw IllegalStateException("Expected 'local' or 'remote' key in SignerSettings") + } + } +} diff --git a/test-app/app/src/main/kotlin/org/contentauth/c2pa/testapp/TestScreen.kt b/test-app/app/src/main/kotlin/org/contentauth/c2pa/testapp/TestScreen.kt index c200f92..de756fc 100644 --- a/test-app/app/src/main/kotlin/org/contentauth/c2pa/testapp/TestScreen.kt +++ b/test-app/app/src/main/kotlin/org/contentauth/c2pa/testapp/TestScreen.kt @@ -47,6 +47,7 @@ import kotlinx.coroutines.withContext import org.contentauth.c2pa.test.shared.BuilderTests import org.contentauth.c2pa.test.shared.CoreTests import org.contentauth.c2pa.test.shared.ManifestTests +import org.contentauth.c2pa.test.shared.SettingsDefinitionTests import org.contentauth.c2pa.test.shared.SignerTests import org.contentauth.c2pa.test.shared.StreamTests import org.contentauth.c2pa.test.shared.TestBase @@ -63,11 +64,15 @@ private fun loadResourceWithExtensions(resourceName: String): ByteArray? = TestBase.loadSharedResourceAsBytes("$resourceName.jpg") ?: TestBase.loadSharedResourceAsBytes("$resourceName.pem") ?: TestBase.loadSharedResourceAsBytes("$resourceName.key") + ?: TestBase.loadSharedResourceAsBytes("$resourceName.toml") + ?: TestBase.loadSharedResourceAsBytes("$resourceName.json") private fun loadResourceStringWithExtensions(resourceName: String): String? = TestBase.loadSharedResourceAsString("$resourceName.jpg") ?: TestBase.loadSharedResourceAsString("$resourceName.pem") ?: TestBase.loadSharedResourceAsString("$resourceName.key") + ?: TestBase.loadSharedResourceAsString("$resourceName.toml") + ?: TestBase.loadSharedResourceAsString("$resourceName.json") private fun copyResourceToCache(context: Context, resourceName: String, fileName: String): File { val file = File(context.cacheDir, fileName) @@ -129,6 +134,16 @@ private class AppWebServiceTests(private val context: Context) : WebServiceTests copyResourceToCache(context, resourceName, fileName) } +private class AppSettingsDefinitionTests(private val context: Context) : SettingsDefinitionTests() { + override fun getContext(): Context = context + override fun loadResourceAsBytes(resourceName: String): ByteArray = loadResourceWithExtensions(resourceName) + ?: throw IllegalArgumentException("Resource not found: $resourceName") + override fun loadResourceAsString(resourceName: String): String = loadResourceStringWithExtensions(resourceName) + ?: throw IllegalArgumentException("Resource not found: $resourceName") + override fun copyResourceToFile(resourceName: String, fileName: String): File = + copyResourceToCache(context, resourceName, fileName) +} + private class AppManifestTests(private val context: Context) : ManifestTests() { override fun getContext(): Context = context override fun loadResourceAsBytes(resourceName: String): ByteArray = loadResourceWithExtensions(resourceName) @@ -204,7 +219,9 @@ private suspend fun runAllTests(context: Context): List = withContex results.add(coreTests.testConcurrentOperations()) results.add(coreTests.testReaderResourceErrorHandling()) - // Additional Stream Tests (large buffer handling) + // Additional Stream Tests + results.add(streamTests.testCallbackStreamFactories()) + results.add(streamTests.testByteArrayStreamBufferGrowth()) results.add(streamTests.testLargeBufferHandling()) // Manifest Tests @@ -229,6 +246,22 @@ private suspend fun runAllTests(context: Context): List = withContex results.add(manifestTests.testAllValidationStatusCodes()) results.add(manifestTests.testAllDigitalSourceTypes()) + // Settings Definition Tests + val settingsDefinitionTests = AppSettingsDefinitionTests(context) + results.add(settingsDefinitionTests.testRoundTrip()) + results.add(settingsDefinitionTests.testFromJson()) + results.add(settingsDefinitionTests.testSettingsIntent()) + results.add(settingsDefinitionTests.testToJson()) + results.add(settingsDefinitionTests.testSignerSettings()) + results.add(settingsDefinitionTests.testCawgSigner()) + results.add(settingsDefinitionTests.testIgnoreUnknownKeys()) + results.add(settingsDefinitionTests.testBuilderSettings()) + results.add(settingsDefinitionTests.testFromDefinition()) + results.add(settingsDefinitionTests.testUpdateFrom()) + results.add(settingsDefinitionTests.testPrettyJson()) + results.add(settingsDefinitionTests.testEnumSerialization()) + results.add(settingsDefinitionTests.testActionTemplates()) + results } diff --git a/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/BuilderTests.kt b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/BuilderTests.kt index 046d21f..1c69e01 100644 --- a/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/BuilderTests.kt +++ b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/BuilderTests.kt @@ -20,6 +20,8 @@ import org.contentauth.c2pa.BuilderIntent import org.contentauth.c2pa.ByteArrayStream import org.contentauth.c2pa.C2PA import org.contentauth.c2pa.C2PAError +import org.contentauth.c2pa.C2PAContext +import org.contentauth.c2pa.C2PASettings import org.contentauth.c2pa.DigitalSourceType import org.contentauth.c2pa.FileStream import org.contentauth.c2pa.PredefinedAction @@ -39,52 +41,44 @@ abstract class BuilderTests : TestBase() { val manifestJson = TEST_MANIFEST_JSON try { - val builder = Builder.fromJson(manifestJson) - try { + Builder.fromJson(manifestJson).use { builder -> val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") val sourceStream = ByteArrayStream(sourceImageData) val fileTest = File.createTempFile("c2pa-stream-api-test", ".jpg") val destStream = FileStream(fileTest) - try { - val certPem = loadResourceAsString("es256_certs") - val keyPem = loadResourceAsString("es256_private") - - val signerInfo = SignerInfo(SigningAlgorithm.ES256, certPem, keyPem) - val signer = Signer.fromInfo(signerInfo) + sourceStream.use { + destStream.use { + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + + val signerInfo = SignerInfo(SigningAlgorithm.ES256, certPem, keyPem) + Signer.fromInfo(signerInfo).use { signer -> + val result = + builder.sign( + "image/jpeg", + sourceStream, + destStream, + signer, + ) - try { - val result = - builder.sign( - "image/jpeg", - sourceStream, - destStream, - signer, + val manifest = C2PA.readFile(fileTest.absolutePath) + val json = JSONObject(manifest) + val success = json.has("manifests") + + TestResult( + "Builder API", + success, + if (success) { + "Successfully signed image" + } else { + "Signing failed" + }, + "Original: ${sourceImageData.size}, Signed: ${fileTest.length()}, Result size: ${result.size}\n\n$json", ) - - val manifest = C2PA.readFile(fileTest.absolutePath) - val json = JSONObject(manifest) - val success = json.has("manifests") - - TestResult( - "Builder API", - success, - if (success) { - "Successfully signed image" - } else { - "Signing failed" - }, - "Original: ${sourceImageData.size}, Signed: ${fileTest.length()}, Result size: ${result.size}\n\n$json", - ) - } finally { - signer.close() + } } - } finally { - sourceStream.close() - destStream.close() } - } finally { - builder.close() } } catch (e: C2PAError) { TestResult("Builder API", false, "Failed to create builder", e.toString()) @@ -97,11 +91,9 @@ abstract class BuilderTests : TestBase() { val manifestJson = TEST_MANIFEST_JSON try { - val builder = Builder.fromJson(manifestJson) - try { + Builder.fromJson(manifestJson).use { builder -> builder.setNoEmbed() - val archiveStream = ByteArrayStream() - try { + ByteArrayStream().use { archiveStream -> builder.toArchive(archiveStream) val data = archiveStream.getData() val success = data.isNotEmpty() @@ -115,11 +107,7 @@ abstract class BuilderTests : TestBase() { }, "Archive size: ${data.size}", ) - } finally { - archiveStream.close() } - } finally { - builder.close() } } catch (e: C2PAError) { TestResult( @@ -135,34 +123,42 @@ abstract class BuilderTests : TestBase() { suspend fun testBuilderRemoteUrl(): TestResult = withContext(Dispatchers.IO) { runTest("Builder Remote URL") { val manifestJson = TEST_MANIFEST_JSON + val remoteUrl = "https://example.com/manifest.c2pa" try { - val builder = Builder.fromJson(manifestJson) - try { - builder.setRemoteURL("https://example.com/manifest.c2pa") - builder.setNoEmbed() - val archive = ByteArrayStream() + Builder.fromJson(manifestJson).use { builder -> + builder.setRemoteURL(remoteUrl) + + val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") + val sourceStream = ByteArrayStream(sourceImageData) + val fileTest = File.createTempFile("c2pa-remote-url-test", ".jpg") + val destStream = FileStream(fileTest) + try { - builder.toArchive(archive) - val archiveData = archive.getData() - val archiveStr = String(archiveData) - val success = - archiveStr.contains("https://example.com/manifest.c2pa") - TestResult( - "Builder Remote URL", - success, - if (success) { - "Remote URL set successfully" - } else { - "Remote URL not found in archive" - }, - "Archive contains URL: $success", - ) + sourceStream.use { + destStream.use { + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)).use { signer -> + val signResult = builder.sign("image/jpeg", sourceStream, destStream, signer) + val hasManifestBytes = signResult.manifestBytes != null && signResult.manifestBytes!!.isNotEmpty() + val success = signResult.size > 0 && hasManifestBytes + TestResult( + "Builder Remote URL", + success, + if (success) { + "Remote URL set successfully" + } else { + "Remote signing failed" + }, + "Sign result size: ${signResult.size}, Has manifest bytes: $hasManifestBytes", + ) + } + } + } } finally { - archive.close() + fileTest.delete() } - } finally { - builder.close() } } catch (e: C2PAError) { TestResult( @@ -180,36 +176,42 @@ abstract class BuilderTests : TestBase() { val manifestJson = TEST_MANIFEST_JSON try { - val builder = Builder.fromJson(manifestJson) - try { + Builder.fromJson(manifestJson).use { builder -> val thumbnailData = createSimpleJPEGThumbnail() - val thumbnailStream = ByteArrayStream(thumbnailData) - try { + ByteArrayStream(thumbnailData).use { thumbnailStream -> builder.addResource("thumbnail", thumbnailStream) - builder.setNoEmbed() - val archive = ByteArrayStream() + + val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") + val sourceStream = ByteArrayStream(sourceImageData) + val fileTest = File.createTempFile("c2pa-resource-test", ".jpg") + val destStream = FileStream(fileTest) + try { - builder.toArchive(archive) - val archiveStr = String(archive.getData()) - val success = archiveStr.contains("thumbnail") - TestResult( - "Builder Add Resource", - success, - if (success) { - "Resource added successfully" - } else { - "Resource not found in archive" - }, - "Thumbnail size: ${thumbnailData.size} bytes, Found in archive: $success", - ) + sourceStream.use { + destStream.use { + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)).use { signer -> + builder.sign("image/jpeg", sourceStream, destStream, signer) + val manifest = C2PA.readFile(fileTest.absolutePath) + val success = manifest.contains("thumbnail") + TestResult( + "Builder Add Resource", + success, + if (success) { + "Resource added successfully" + } else { + "Resource not found in signed manifest" + }, + "Thumbnail size: ${thumbnailData.size} bytes, Found in manifest: $success", + ) + } + } + } } finally { - archive.close() + fileTest.delete() } - } finally { - thumbnailStream.close() } - } finally { - builder.close() } } catch (e: C2PAError) { TestResult( @@ -227,43 +229,48 @@ abstract class BuilderTests : TestBase() { val manifestJson = TEST_MANIFEST_JSON try { - val builder = Builder.fromJson(manifestJson) - try { + Builder.fromJson(manifestJson).use { builder -> val ingredientJson = """{"title": "Test Ingredient", "format": "image/jpeg"}""" val ingredientImageData = loadResourceAsBytes("pexels_asadphoto_457882") - val ingredientStream = ByteArrayStream(ingredientImageData) - try { + ByteArrayStream(ingredientImageData).use { ingredientStream -> builder.addIngredient( ingredientJson, "image/jpeg", ingredientStream, ) - builder.setNoEmbed() - val archive = ByteArrayStream() + + val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") + val sourceStream = ByteArrayStream(sourceImageData) + val fileTest = File.createTempFile("c2pa-ingredient-test", ".jpg") + val destStream = FileStream(fileTest) + try { - builder.toArchive(archive) - val archiveStr = String(archive.getData()) - val success = - archiveStr.contains("\"title\":\"Test Ingredient\"") - TestResult( - "Builder Add Ingredient", - success, - if (success) { - "Ingredient added successfully" - } else { - "Ingredient not found in archive" - }, - "Ingredient found: $success", - ) + sourceStream.use { + destStream.use { + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)).use { signer -> + builder.sign("image/jpeg", sourceStream, destStream, signer) + val manifest = C2PA.readFile(fileTest.absolutePath) + val success = manifest.contains("Test Ingredient") + TestResult( + "Builder Add Ingredient", + success, + if (success) { + "Ingredient added successfully" + } else { + "Ingredient not found in signed manifest" + }, + "Ingredient found: $success", + ) + } + } + } } finally { - archive.close() + fileTest.delete() } - } finally { - ingredientStream.close() } - } finally { - builder.close() } } catch (e: C2PAError) { TestResult( @@ -281,32 +288,28 @@ abstract class BuilderTests : TestBase() { val manifestJson = TEST_MANIFEST_JSON try { - val originalBuilder = Builder.fromJson(manifestJson) - try { + Builder.fromJson(manifestJson).use { originalBuilder -> val thumbnailData = createSimpleJPEGThumbnail() - val thumbnailStream = ByteArrayStream(thumbnailData) - originalBuilder.addResource("test_thumbnail", thumbnailStream) - thumbnailStream.close() + ByteArrayStream(thumbnailData).use { thumbnailStream -> + originalBuilder.addResource("test_thumbnail", thumbnailStream) + } originalBuilder.setNoEmbed() - val archiveStream = ByteArrayStream() - try { + ByteArrayStream().use { archiveStream -> originalBuilder.toArchive(archiveStream) val archiveData = archiveStream.getData() - val newArchiveStream = ByteArrayStream(archiveData) - var builderCreated = false - try { - val newBuilder = Builder.fromArchive(newArchiveStream) - builderCreated = true - newBuilder.close() - } catch (e: Exception) { - builderCreated = false + ByteArrayStream(archiveData).use { newArchiveStream -> + try { + Builder.fromArchive(newArchiveStream).use { + builderCreated = true + } + } catch (e: Exception) { + builderCreated = false + } } - newArchiveStream.close() - val hasData = archiveData.isNotEmpty() val success = hasData && builderCreated @@ -321,11 +324,7 @@ abstract class BuilderTests : TestBase() { }, "Archive size: ${archiveData.size} bytes, Builder created: $builderCreated", ) - } finally { - archiveStream.close() } - } finally { - originalBuilder.close() } } catch (e: Exception) { TestResult( @@ -343,55 +342,44 @@ abstract class BuilderTests : TestBase() { try { val manifestJson = TEST_MANIFEST_JSON - val builder = Builder.fromJson(manifestJson) + val fileTest = File.createTempFile("c2pa-manifest-direct-sign", ".jpg") try { - val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") - val sourceStream = ByteArrayStream(sourceImageData) - val fileTest = File.createTempFile("c2pa-manifest-direct-sign", ".jpg") - val destStream = FileStream(fileTest) - - val certPem = loadResourceAsString("es256_certs") - val keyPem = loadResourceAsString("es256_private") - val signer = - Signer.fromInfo( - SignerInfo(SigningAlgorithm.ES256, certPem, keyPem), - ) - - val signResult = - builder.sign("image/jpeg", sourceStream, destStream, signer) - - sourceStream.close() - destStream.close() - signer.close() + val signResult = Builder.fromJson(manifestJson).use { builder -> + val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") + ByteArrayStream(sourceImageData).use { sourceStream -> + FileStream(fileTest).use { destStream -> + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + Signer.fromInfo( + SignerInfo(SigningAlgorithm.ES256, certPem, keyPem), + ).use { signer -> + builder.sign("image/jpeg", sourceStream, destStream, signer) + } + } + } + } val freshImageData = loadResourceAsBytes("pexels_asadphoto_457882") - val freshStream = ByteArrayStream(freshImageData) - - val success = + val success = ByteArrayStream(freshImageData).use { freshStream -> if (signResult.manifestBytes != null) { try { - val reader = - Reader.fromManifestAndStream( - "image/jpeg", - freshStream, - signResult.manifestBytes!!, - ) - try { + Reader.fromManifestAndStream( + "image/jpeg", + freshStream, + signResult.manifestBytes!!, + ).use { reader -> val json = reader.json() - json.contains("\"c2pa.test\"") - } finally { - reader.close() + // Check for c2pa.created action which is in TEST_MANIFEST_JSON + json.contains("\"c2pa.created\"") } } catch (_: Exception) { false } } else { val manifest = C2PA.readFile(fileTest.absolutePath) - manifest.contains("\"c2pa.test\"") + manifest.contains("\"c2pa.created\"") } - - freshStream.close() - fileTest.delete() + } TestResult( "Reader with Manifest Data", @@ -404,7 +392,7 @@ abstract class BuilderTests : TestBase() { "Manifest bytes available: ${signResult.manifestBytes != null}, Test assertion found: $success", ) } finally { - builder.close() + fileTest.delete() } } catch (e: Exception) { TestResult( @@ -420,35 +408,33 @@ abstract class BuilderTests : TestBase() { suspend fun testJsonRoundTrip(): TestResult = withContext(Dispatchers.IO) { runTest("JSON Round-trip") { val testImageData = loadResourceAsBytes("adobe_20220124_ci") - val memStream = ByteArrayStream(testImageData) try { - val reader = Reader.fromStream("image/jpeg", memStream) - try { - val originalJson = reader.json() - val json1 = JSONObject(originalJson) - - // Extract just the manifest part for rebuilding - val manifestsValue = json1.opt("manifests") - val success = - when (manifestsValue) { - is JSONArray -> manifestsValue.length() > 0 - is JSONObject -> manifestsValue.length() > 0 - else -> false - } + ByteArrayStream(testImageData).use { memStream -> + Reader.fromStream("image/jpeg", memStream).use { reader -> + val originalJson = reader.json() + val json1 = JSONObject(originalJson) - TestResult( - "JSON Round-trip", - success, - if (success) { - "JSON parsed successfully" - } else { - "Failed to parse JSON" - }, - "Manifests type: ${manifestsValue?.javaClass?.simpleName}, Has content: $success", - ) - } finally { - reader.close() + // Extract just the manifest part for rebuilding + val manifestsValue = json1.opt("manifests") + val success = + when (manifestsValue) { + is JSONArray -> manifestsValue.length() > 0 + is JSONObject -> manifestsValue.length() > 0 + else -> false + } + + TestResult( + "JSON Round-trip", + success, + if (success) { + "JSON parsed successfully" + } else { + "Failed to parse JSON" + }, + "Manifests type: ${manifestsValue?.javaClass?.simpleName}, Has content: $success", + ) + } } } catch (e: C2PAError) { TestResult( @@ -457,8 +443,6 @@ abstract class BuilderTests : TestBase() { "Failed to read manifest", e.toString(), ) - } finally { - memStream.close() } } } @@ -468,8 +452,7 @@ abstract class BuilderTests : TestBase() { val manifestJson = TEST_MANIFEST_JSON try { - val builder = Builder.fromJson(manifestJson) - try { + Builder.fromJson(manifestJson).use { builder -> // Test Create intent with digital source type builder.setIntent(BuilderIntent.Create(DigitalSourceType.DIGITAL_CAPTURE)) @@ -479,48 +462,438 @@ abstract class BuilderTests : TestBase() { val destStream = FileStream(fileTest) try { - val certPem = loadResourceAsString("es256_certs") - val keyPem = loadResourceAsString("es256_private") - val signer = Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)) + sourceStream.use { + destStream.use { + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)).use { signer -> + builder.sign("image/jpeg", sourceStream, destStream, signer) + + val manifest = C2PA.readFile(fileTest.absolutePath) + val json = JSONObject(manifest) + + // Check for c2pa.created action which should be auto-added by Create intent + val manifestStr = manifest.lowercase() + val hasCreatedAction = manifestStr.contains("c2pa.created") || + manifestStr.contains("digitalcapture") + + TestResult( + "Builder Set Intent", + true, + "Intent set and signed successfully", + "Has created action or digital source: $hasCreatedAction\nManifest preview: ${manifest.take(500)}...", + ) + } + } + } + } finally { + fileTest.delete() + } + } + } catch (e: C2PAError) { + TestResult( + "Builder Set Intent", + false, + "Failed to set intent", + e.toString(), + ) + } catch (e: Exception) { + TestResult( + "Builder Set Intent", + false, + "Exception: ${e.message}", + e.toString(), + ) + } + } + } - try { - builder.sign("image/jpeg", sourceStream, destStream, signer) + suspend fun testBuilderFromContextWithSettings(): TestResult = withContext(Dispatchers.IO) { + runTest("Builder from Context with Settings") { + val manifestJson = TEST_MANIFEST_JSON - val manifest = C2PA.readFile(fileTest.absolutePath) - val json = JSONObject(manifest) + try { + val settingsJson = """ + { + "version": 1, + "builder": { + "created_assertion_labels": ["c2pa.actions"] + } + } + """.trimIndent() + + val builder = C2PASettings.create().use { settings -> + settings.updateFromString(settingsJson, "json") + C2PAContext.fromSettings(settings).use { context -> + Builder.fromContext(context).withDefinition(manifestJson) + } + } + + builder.use { + val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") + val sourceStream = ByteArrayStream(sourceImageData) + val fileTest = File.createTempFile("c2pa-context-settings-test", ".jpg") + val destStream = FileStream(fileTest) + + try { + sourceStream.use { + destStream.use { + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)).use { signer -> + builder.sign("image/jpeg", sourceStream, destStream, signer) + + val manifest = C2PA.readFile(fileTest.absolutePath) + val json = JSONObject(manifest) + val hasManifests = json.has("manifests") + val hasCreatedAction = manifest.contains("c2pa.created") + + val success = hasManifests && hasCreatedAction + + TestResult( + "Builder from Context with Settings", + success, + if (success) { + "Context-based builder with settings works" + } else { + "Failed to sign with context-based builder" + }, + "Has manifests: $hasManifests, Has created action: $hasCreatedAction\nManifest preview: ${manifest.take(500)}...", + ) + } + } + } + } finally { + fileTest.delete() + } + } + } catch (e: C2PAError) { + TestResult( + "Builder from Context with Settings", + false, + "Failed to create builder from context", + e.toString(), + ) + } catch (e: Exception) { + TestResult( + "Builder from Context with Settings", + false, + "Exception: ${e.message}", + e.toString(), + ) + } + } + } + + suspend fun testBuilderFromJsonWithSettings(): TestResult = withContext(Dispatchers.IO) { + runTest("Builder fromJson with C2PASettings") { + val manifestJson = TEST_MANIFEST_JSON + + try { + val settingsJson = """ + { + "version": 1, + "builder": { + "created_assertion_labels": ["c2pa.actions"] + } + } + """.trimIndent() + + val builder = C2PASettings.create().use { settings -> + settings.updateFromString(settingsJson, "json") + Builder.fromJson(manifestJson, settings) + } + + builder.use { + val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") + val sourceStream = ByteArrayStream(sourceImageData) + val fileTest = File.createTempFile("c2pa-fromjson-settings-test", ".jpg") + val destStream = FileStream(fileTest) + + try { + sourceStream.use { + destStream.use { + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)).use { signer -> + builder.sign("image/jpeg", sourceStream, destStream, signer) + + val manifest = C2PA.readFile(fileTest.absolutePath) + val json = JSONObject(manifest) + val hasManifests = json.has("manifests") + val hasCreatedAction = manifest.contains("c2pa.created") + val success = hasManifests && hasCreatedAction + + TestResult( + "Builder fromJson with C2PASettings", + success, + if (success) { + "fromJson(manifest, settings) works" + } else { + "Failed to sign with fromJson(manifest, settings)" + }, + "Has manifests: $hasManifests, Has created action: $hasCreatedAction", + ) + } + } + } + } finally { + fileTest.delete() + } + } + } catch (e: Exception) { + TestResult( + "Builder fromJson with C2PASettings", + false, + "Exception: ${e.message}", + e.toString(), + ) + } + } + } + + suspend fun testBuilderWithArchive(): TestResult = withContext(Dispatchers.IO) { + runTest("Builder withArchive") { + val manifestJson = TEST_MANIFEST_JSON + + try { + val archiveData = Builder.fromJson(manifestJson).use { originalBuilder -> + originalBuilder.setNoEmbed() + ByteArrayStream().use { archiveStream -> + originalBuilder.toArchive(archiveStream) + archiveStream.getData() + } + } + + val newBuilder = C2PAContext.create().use { context -> + ByteArrayStream(archiveData).use { newArchiveStream -> + Builder.fromContext(context).withArchive(newArchiveStream) + } + } + + val signSuccess = newBuilder.use { + val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") + val sourceStream = ByteArrayStream(sourceImageData) + val fileTest = File.createTempFile("c2pa-witharchive-test", ".jpg") + val destStream = FileStream(fileTest) + + try { + sourceStream.use { + destStream.use { + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)).use { signer -> + newBuilder.sign("image/jpeg", sourceStream, destStream, signer) + val manifest = C2PA.readFile(fileTest.absolutePath) + manifest.contains("c2pa.created") + } + } + } + } finally { + fileTest.delete() + } + } + + val success = archiveData.isNotEmpty() && signSuccess + TestResult( + "Builder withArchive", + success, + if (success) { + "withArchive round-trip successful" + } else { + "withArchive round-trip failed" + }, + "Archive size: ${archiveData.size}, Sign success: $signSuccess", + ) + } catch (e: Exception) { + TestResult( + "Builder withArchive", + false, + "Exception: ${e.message}", + e.toString(), + ) + } + } + } + + suspend fun testReaderFromContext(): TestResult = withContext(Dispatchers.IO) { + runTest("Reader fromContext with withStream") { + try { + // First, sign an image so we have something to read + val fileTest = File.createTempFile("c2pa-reader-context-test", ".jpg") + try { + Builder.fromJson(TEST_MANIFEST_JSON).use { builder -> + val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") + ByteArrayStream(sourceImageData).use { sourceStream -> + FileStream(fileTest).use { destStream -> + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)).use { signer -> + builder.sign("image/jpeg", sourceStream, destStream, signer) + } + } + } + } - // Check for c2pa.created action which should be auto-added by Create intent - val manifestStr = manifest.lowercase() - val hasCreatedAction = manifestStr.contains("c2pa.created") || - manifestStr.contains("digitalcapture") + // Now read using the context-based API + val signedData = fileTest.readBytes() + ByteArrayStream(signedData).use { signedStream -> + val reader = C2PAContext.create().use { context -> + Reader.fromContext(context).withStream("image/jpeg", signedStream) + } + + reader.use { + val json = reader.json() + val hasManifests = json.contains("manifests") + val hasCreatedAction = json.contains("c2pa.created") + val isEmbedded = reader.isEmbedded() + val remoteUrl = reader.remoteUrl() + + val success = hasManifests && hasCreatedAction && isEmbedded && remoteUrl == null TestResult( - "Builder Set Intent", - true, - "Intent set and signed successfully", - "Has created action or digital source: $hasCreatedAction\nManifest preview: ${manifest.take(500)}...", + "Reader fromContext with withStream", + success, + if (success) { + "Context-based reader works" + } else { + "Context-based reader failed" + }, + "Has manifests: $hasManifests, Has created action: $hasCreatedAction, " + + "Is embedded: $isEmbedded, Remote URL: $remoteUrl", ) - } finally { - signer.close() + } + } + } finally { + fileTest.delete() + } + } catch (e: Exception) { + TestResult( + "Reader fromContext with withStream", + false, + "Exception: ${e.message}", + e.toString(), + ) + } + } + } + + suspend fun testSettingsSetValue(): TestResult = withContext(Dispatchers.IO) { + runTest("C2PASettings setValue") { + try { + val builder = C2PASettings.create().use { settings -> + settings.updateFromString("""{"version": 1}""", "json") + .setValue("verify.verify_after_sign", "false") + C2PAContext.fromSettings(settings).use { context -> + Builder.fromContext(context) + .withDefinition(TEST_MANIFEST_JSON) + } + } + + builder.use { + val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") + val sourceStream = ByteArrayStream(sourceImageData) + val fileTest = File.createTempFile("c2pa-setvalue-test", ".jpg") + val destStream = FileStream(fileTest) + + try { + sourceStream.use { + destStream.use { + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)).use { signer -> + builder.sign("image/jpeg", sourceStream, destStream, signer) + val manifest = C2PA.readFile(fileTest.absolutePath) + val success = manifest.contains("manifests") + + TestResult( + "C2PASettings setValue", + success, + if (success) { + "setValue works for building context" + } else { + "setValue failed" + }, + "Signed with setValue-configured settings", + ) + } + } } } finally { - sourceStream.close() - destStream.close() fileTest.delete() } - } finally { - builder.close() } - } catch (e: C2PAError) { + } catch (e: Exception) { TestResult( - "Builder Set Intent", + "C2PASettings setValue", false, - "Failed to set intent", + "Exception: ${e.message}", e.toString(), ) + } + } + } + + suspend fun testBuilderIntentEditAndUpdate(): TestResult = withContext(Dispatchers.IO) { + runTest("Builder Intent Edit and Update") { + try { + Builder.fromJson(TEST_MANIFEST_JSON).use { builder -> + builder.setIntent(BuilderIntent.Edit) + + // Add a parent ingredient (required for Edit) + val ingredientImageData = loadResourceAsBytes("pexels_asadphoto_457882") + ByteArrayStream(ingredientImageData).use { ingredientStream -> + builder.addIngredient( + """{"title": "Parent Image", "format": "image/jpeg"}""", + "image/jpeg", + ingredientStream, + ) + } + + val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") + val sourceStream = ByteArrayStream(sourceImageData) + val fileTest = File.createTempFile("c2pa-edit-intent-test", ".jpg") + val destStream = FileStream(fileTest) + + try { + sourceStream.use { + destStream.use { + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)).use { signer -> + builder.sign("image/jpeg", sourceStream, destStream, signer) + val manifest = C2PA.readFile(fileTest.absolutePath) + val editSuccess = manifest.contains("manifests") + + // Test Update intent + Builder.fromJson(TEST_MANIFEST_JSON).use { builder2 -> + builder2.setIntent(BuilderIntent.Update) + + val updateSuccess = true // setIntent didn't throw + + val success = editSuccess && updateSuccess + + TestResult( + "Builder Intent Edit and Update", + success, + if (success) { + "Edit and Update intents work" + } else { + "Intent test failed" + }, + "Edit signed: $editSuccess, Update set: $updateSuccess", + ) + } + } + } + } + } finally { + fileTest.delete() + } + } } catch (e: Exception) { TestResult( - "Builder Set Intent", + "Builder Intent Edit and Update", false, "Exception: ${e.message}", e.toString(), @@ -534,8 +907,7 @@ abstract class BuilderTests : TestBase() { val manifestJson = TEST_MANIFEST_JSON try { - val builder = Builder.fromJson(manifestJson) - try { + Builder.fromJson(manifestJson).use { builder -> // Add multiple actions builder.addAction( Action( @@ -564,42 +936,38 @@ abstract class BuilderTests : TestBase() { val destStream = FileStream(fileTest) try { - val certPem = loadResourceAsString("es256_certs") - val keyPem = loadResourceAsString("es256_private") - val signer = Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)) - - try { - builder.sign("image/jpeg", sourceStream, destStream, signer) - - val manifest = C2PA.readFile(fileTest.absolutePath) - val manifestLower = manifest.lowercase() - - val hasEditedAction = manifestLower.contains("c2pa.edited") - val hasCroppedAction = manifestLower.contains("c2pa.cropped") - val hasCustomAction = manifestLower.contains("com.example.custom_action") - - val success = hasEditedAction && hasCroppedAction && hasCustomAction - - TestResult( - "Builder Add Action", - success, - if (success) { - "All actions added successfully" - } else { - "Some actions missing" - }, - "Edited: $hasEditedAction, Cropped: $hasCroppedAction, Custom: $hasCustomAction\nManifest preview: ${manifest.take(500)}...", - ) - } finally { - signer.close() + sourceStream.use { + destStream.use { + val certPem = loadResourceAsString("es256_certs") + val keyPem = loadResourceAsString("es256_private") + Signer.fromInfo(SignerInfo(SigningAlgorithm.ES256, certPem, keyPem)).use { signer -> + builder.sign("image/jpeg", sourceStream, destStream, signer) + + val manifest = C2PA.readFile(fileTest.absolutePath) + val manifestLower = manifest.lowercase() + + val hasEditedAction = manifestLower.contains("c2pa.edited") + val hasCroppedAction = manifestLower.contains("c2pa.cropped") + val hasCustomAction = manifestLower.contains("com.example.custom_action") + + val success = hasEditedAction && hasCroppedAction && hasCustomAction + + TestResult( + "Builder Add Action", + success, + if (success) { + "All actions added successfully" + } else { + "Some actions missing" + }, + "Edited: $hasEditedAction, Cropped: $hasCroppedAction, Custom: $hasCustomAction\nManifest preview: ${manifest.take(500)}...", + ) + } + } } } finally { - sourceStream.close() - destStream.close() fileTest.delete() } - } finally { - builder.close() } } catch (e: C2PAError) { TestResult( diff --git a/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/SettingsDefinitionTests.kt b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/SettingsDefinitionTests.kt new file mode 100644 index 0000000..97af290 --- /dev/null +++ b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/SettingsDefinitionTests.kt @@ -0,0 +1,508 @@ +/* +This file is licensed to you under the Apache License, Version 2.0 +(http://www.apache.org/licenses/LICENSE-2.0) or the MIT license +(http://opensource.org/licenses/MIT), at your option. + +Unless required by applicable law or agreed to in writing, this software is +distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS OF +ANY KIND, either express or implied. See the LICENSE-MIT and LICENSE-APACHE +files for the specific language governing permissions and limitations under +each license. +*/ + +package org.contentauth.c2pa.test.shared + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.contentauth.c2pa.C2PASettings +import org.contentauth.c2pa.settings.ActionTemplateSettings +import org.contentauth.c2pa.settings.ActionsSettings +import org.contentauth.c2pa.settings.AutoActionSettings +import org.contentauth.c2pa.settings.BuilderSettingsDefinition +import org.contentauth.c2pa.settings.C2PASettingsDefinition +import org.contentauth.c2pa.settings.ClaimGeneratorInfoSettings +import org.contentauth.c2pa.settings.CoreSettings +import org.contentauth.c2pa.settings.OcspFetchScope +import org.contentauth.c2pa.settings.SettingsIntent +import org.contentauth.c2pa.settings.SignerSettings +import org.contentauth.c2pa.settings.ThumbnailFormat +import org.contentauth.c2pa.settings.ThumbnailQuality +import org.contentauth.c2pa.settings.ThumbnailSettings +import org.contentauth.c2pa.settings.TimeStampFetchScope +import org.contentauth.c2pa.settings.TimeStampSettings +import org.contentauth.c2pa.settings.TrustSettings +import org.contentauth.c2pa.settings.VerifySettings + +/** + * Tests for [C2PASettingsDefinition] serialization and integration with [C2PASettings]. + */ +abstract class SettingsDefinitionTests : TestBase() { + + companion object { + const val VALID_CERT = "-----BEGIN CERTIFICATE-----\nMIIBkTCB+wIJAL0X" + + "N2n8v5MHMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRl\nc3RjYTAeFw0yMzAxMDEwMDAwMD" + + "BaFw0yNDAxMDEwMDAwMDBaMBMxETAPBgNVBAMM\nCHRlc3RjZXJ0MFkwEwYHKoZIzj0CAQYI" + + "KoZIzj0DAQcDQgAEZlkPH79yUjJBZnYz\nvJMhDJkSCBMkMmQ1WYaA8xb6E2L3fVJSMOhb" + + "H8vvCAb5gC4FAq8L1RK2d0c9kBIy\nqjCF4DANBgkqhkiG9w0BAQsFAANBAHVK5kQ3TjVY1x" + + "u4G6DXb0m+8NZUL5OOTLKJ\nR9p8k7E8Y0WJxcM8c3VUo0Dc7s/ZWKZ5RKsMbBDJyH8WF+YR" + + "CU=\n-----END CERTIFICATE-----\n" + + const val VALID_KEY = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49" + + "AgEGCCqGSM49AwEHBG0wawIBAQQgTESTKEYDATA00000\n00000000000000000000000000hR" + + "ANCAAQPaL6RkAkYkKU4+IryBSYxJM3h77sF\niMrbvbI8fG7w2Bbl9otNG/cch3DAw5rGAPV7" + + "NWkyl3QGuV/wt0MrAPDo\n-----END PRIVATE KEY-----\n" + } + + /** Test basic serialization round-trip. */ + suspend fun testRoundTrip(): TestResult = runTest("Settings Definition Round Trip") { + withContext(Dispatchers.IO) { + val definition = C2PASettingsDefinition( + version = 1, + verify = VerifySettings(verifyAfterSign = true), + core = CoreSettings(merkleTreeMaxProofs = 10), + ) + + val json = definition.toJson() + val restored = C2PASettingsDefinition.fromJson(json) + + check(restored.version == 1) { "Version mismatch: ${restored.version}" } + check(restored.verify?.verifyAfterSign == true) { "verifyAfterSign mismatch" } + check(restored.core?.merkleTreeMaxProofs == 10) { "merkleTreeMaxProofs mismatch" } + check(restored.trust == null) { "trust should be null" } + check(restored.signer == null) { "signer should be null" } + + TestResult("Settings Definition Round Trip", true, "Round-trip serialization works") + } + } + + /** Test fromJson with a known-good JSON string. */ + suspend fun testFromJson(): TestResult = runTest("Settings Definition fromJson") { + withContext(Dispatchers.IO) { + val json = """ + { + "version": 1, + "trust": { + "verify_trust_list": false, + "user_anchors": "some-anchors" + }, + "verify": { + "verify_after_reading": true, + "ocsp_fetch": false + } + } + """.trimIndent() + + val definition = C2PASettingsDefinition.fromJson(json) + + check(definition.version == 1) { "Version mismatch" } + check(definition.trust?.verifyTrustList == false) { "verifyTrustList mismatch" } + check(definition.trust?.userAnchors == "some-anchors") { "userAnchors mismatch" } + check(definition.verify?.verifyAfterReading == true) { "verifyAfterReading mismatch" } + check(definition.verify?.ocspFetch == false) { "ocspFetch mismatch" } + + TestResult("Settings Definition fromJson", true, "fromJson parses correctly") + } + } + + /** Test SettingsIntent polymorphic serialization. */ + suspend fun testSettingsIntent(): TestResult = runTest("Settings Intent Serialization") { + withContext(Dispatchers.IO) { + // Test Edit + val editDef = C2PASettingsDefinition( + version = 1, + builder = BuilderSettingsDefinition(intent = SettingsIntent.Edit), + ) + val editJson = editDef.toJson() + check("\"edit\"" in editJson) { "Edit should serialize as string 'edit'" } + val editRestored = C2PASettingsDefinition.fromJson(editJson) + check(editRestored.builder?.intent is SettingsIntent.Edit) { "Edit deserialization failed" } + + // Test Update + val updateDef = C2PASettingsDefinition( + version = 1, + builder = BuilderSettingsDefinition(intent = SettingsIntent.Update), + ) + val updateJson = updateDef.toJson() + check("\"update\"" in updateJson) { "Update should serialize as string 'update'" } + val updateRestored = C2PASettingsDefinition.fromJson(updateJson) + check(updateRestored.builder?.intent is SettingsIntent.Update) { + "Update deserialization failed" + } + + // Test Create + val createDef = C2PASettingsDefinition( + version = 1, + builder = BuilderSettingsDefinition( + intent = SettingsIntent.Create( + "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture", + ), + ), + ) + val createJson = createDef.toJson() + check("\"create\"" in createJson) { "Create should have 'create' key" } + check("digitalCapture" in createJson) { "Create should contain source type" } + val createRestored = C2PASettingsDefinition.fromJson(createJson) + val createIntent = createRestored.builder?.intent as? SettingsIntent.Create + check(createIntent != null) { "Create deserialization failed" } + check("digitalCapture" in createIntent.digitalSourceType) { + "Digital source type mismatch" + } + + TestResult("Settings Intent Serialization", true, "All intent variants serialize correctly") + } + } + + /** Test toJson output correctness for complex settings. */ + suspend fun testToJson(): TestResult = runTest("Settings Definition toJson") { + withContext(Dispatchers.IO) { + val definition = C2PASettingsDefinition( + version = 1, + trust = TrustSettings(verifyTrustList = true), + core = CoreSettings( + backingStoreMemoryThresholdInMb = 256, + allowedNetworkHosts = listOf("*.example.com"), + ), + builder = BuilderSettingsDefinition( + thumbnail = ThumbnailSettings( + enabled = true, + format = ThumbnailFormat.JPEG, + quality = ThumbnailQuality.HIGH, + longEdge = 512, + ), + actions = ActionsSettings( + autoCreatedAction = AutoActionSettings(enabled = true), + ), + autoTimestampAssertion = TimeStampSettings( + enabled = false, + fetchScope = TimeStampFetchScope.ALL, + ), + ), + ) + + val json = definition.toJson() + + check("\"version\":1" in json) { "Version missing from JSON" } + check("\"verify_trust_list\":true" in json) { "verify_trust_list missing" } + check("\"backing_store_memory_threshold_in_mb\":256" in json) { + "backing_store threshold missing" + } + check("\"allowed_network_hosts\"" in json) { "allowed_network_hosts missing" } + check("\"jpeg\"" in json) { "Thumbnail format should be lowercase" } + check("\"high\"" in json) { "Thumbnail quality should be lowercase" } + check("\"long_edge\":512" in json) { "long_edge missing" } + check("\"all\"" in json) { "fetch_scope should be 'all'" } + + TestResult("Settings Definition toJson", true, "toJson produces correct output") + } + } + + /** Test SignerSettings serialization. */ + suspend fun testSignerSettings(): TestResult = runTest("Signer Settings Serialization") { + withContext(Dispatchers.IO) { + // Test Local signer + val localDef = C2PASettingsDefinition( + version = 1, + signer = SignerSettings.Local( + alg = "es256", + signCert = VALID_CERT, + privateKey = VALID_KEY, + tsaUrl = "http://timestamp.example.com", + ), + ) + val localJson = localDef.toJson() + check("\"local\"" in localJson) { "Local signer should have 'local' key" } + check("\"alg\":\"es256\"" in localJson) { "Algorithm missing" } + check("\"sign_cert\"" in localJson) { "sign_cert missing" } + check("\"private_key\"" in localJson) { "private_key missing" } + check("\"tsa_url\"" in localJson) { "tsa_url missing" } + + val localRestored = C2PASettingsDefinition.fromJson(localJson) + val localSigner = localRestored.signer as? SignerSettings.Local + check(localSigner != null) { "Local signer deserialization failed" } + check(localSigner.alg == "es256") { "Algorithm mismatch" } + check(localSigner.tsaUrl == "http://timestamp.example.com") { "TSA URL mismatch" } + + // Test Remote signer + val remoteDef = C2PASettingsDefinition( + version = 1, + signer = SignerSettings.Remote( + url = "http://signer.example.com/sign", + alg = "ps256", + signCert = VALID_CERT, + referencedAssertions = listOf("cawg.training-mining"), + roles = listOf("signer"), + ), + ) + val remoteJson = remoteDef.toJson() + check("\"remote\"" in remoteJson) { "Remote signer should have 'remote' key" } + check("\"url\"" in remoteJson) { "URL missing" } + + val remoteRestored = C2PASettingsDefinition.fromJson(remoteJson) + val remoteSigner = remoteRestored.signer as? SignerSettings.Remote + check(remoteSigner != null) { "Remote signer deserialization failed" } + check(remoteSigner.url == "http://signer.example.com/sign") { "URL mismatch" } + check(remoteSigner.referencedAssertions == listOf("cawg.training-mining")) { + "Referenced assertions mismatch" + } + check(remoteSigner.roles == listOf("signer")) { "Roles mismatch" } + + TestResult("Signer Settings Serialization", true, "Local and remote signers serialize correctly") + } + } + + /** Test CAWG signer with referenced assertions. */ + suspend fun testCawgSigner(): TestResult = runTest("CAWG Signer Settings") { + withContext(Dispatchers.IO) { + val settingsJson = loadSharedResourceAsString("test_settings_with_cawg_signing.json") + ?: throw IllegalArgumentException("Resource not found: test_settings_with_cawg_signing.json") + val definition = C2PASettingsDefinition.fromJson(settingsJson) + + check(definition.signer != null) { "signer should not be null" } + check(definition.cawgX509Signer != null) { "cawg_x509_signer should not be null" } + + val signer = definition.signer as? SignerSettings.Local + check(signer != null) { "signer should be Local" } + check(signer.alg == "es256") { "signer alg mismatch" } + + val cawgSigner = definition.cawgX509Signer as? SignerSettings.Local + check(cawgSigner != null) { "cawg_x509_signer should be Local" } + check(cawgSigner.referencedAssertions == listOf("cawg.training-mining")) { + "CAWG referenced assertions mismatch" + } + + // Round-trip + val roundTripped = C2PASettingsDefinition.fromJson(definition.toJson()) + val rtCawg = roundTripped.cawgX509Signer as? SignerSettings.Local + check(rtCawg?.referencedAssertions == listOf("cawg.training-mining")) { + "Round-trip CAWG assertions mismatch" + } + + TestResult("CAWG Signer Settings", true, "CAWG signer with referenced assertions works") + } + } + + /** Test unknown fields are ignored during deserialization. */ + suspend fun testIgnoreUnknownKeys(): TestResult = runTest("Ignore Unknown Keys") { + withContext(Dispatchers.IO) { + val json = """ + { + "version": 1, + "future_field": "some_value", + "verify": { + "verify_after_sign": true, + "unknown_bool": false + } + } + """.trimIndent() + + val definition = C2PASettingsDefinition.fromJson(json) + check(definition.version == 1) { "Version mismatch" } + check(definition.verify?.verifyAfterSign == true) { "verifyAfterSign mismatch" } + + TestResult("Ignore Unknown Keys", true, "Unknown keys are ignored gracefully") + } + } + + /** Test builder settings with all fields. */ + suspend fun testBuilderSettings(): TestResult = runTest("Builder Settings") { + withContext(Dispatchers.IO) { + val definition = C2PASettingsDefinition( + version = 1, + builder = BuilderSettingsDefinition( + vendor = "test-vendor", + claimGeneratorInfo = ClaimGeneratorInfoSettings( + name = "TestApp", + version = "1.0.0", + ), + thumbnail = ThumbnailSettings( + enabled = true, + ignoreErrors = false, + longEdge = 2048, + format = ThumbnailFormat.PNG, + preferSmallestFormat = false, + quality = ThumbnailQuality.LOW, + ), + createdAssertionLabels = listOf("c2pa.actions"), + preferBoxHash = true, + generateC2paArchive = false, + certificateStatusFetch = OcspFetchScope.ACTIVE, + ), + ) + + val json = definition.toJson() + val restored = C2PASettingsDefinition.fromJson(json) + + check(restored.builder?.vendor == "test-vendor") { "vendor mismatch" } + check(restored.builder?.claimGeneratorInfo?.name == "TestApp") { + "claimGeneratorInfo.name mismatch" + } + check(restored.builder?.claimGeneratorInfo?.version == "1.0.0") { + "claimGeneratorInfo.version mismatch" + } + check(restored.builder?.thumbnail?.format == ThumbnailFormat.PNG) { + "thumbnail format mismatch" + } + check(restored.builder?.thumbnail?.quality == ThumbnailQuality.LOW) { + "thumbnail quality mismatch" + } + check(restored.builder?.thumbnail?.longEdge == 2048) { "longEdge mismatch" } + check(restored.builder?.createdAssertionLabels == listOf("c2pa.actions")) { + "createdAssertionLabels mismatch" + } + check(restored.builder?.preferBoxHash == true) { "preferBoxHash mismatch" } + check(restored.builder?.generateC2paArchive == false) { "generateC2paArchive mismatch" } + check(restored.builder?.certificateStatusFetch == OcspFetchScope.ACTIVE) { + "certificateStatusFetch mismatch" + } + + TestResult("Builder Settings", true, "All builder fields serialize correctly") + } + } + + /** Test integration with C2PASettings.fromDefinition. */ + suspend fun testFromDefinition(): TestResult = runTest("C2PASettings.fromDefinition") { + withContext(Dispatchers.IO) { + val definition = C2PASettingsDefinition( + version = 1, + verify = VerifySettings(verifyAfterSign = true), + builder = BuilderSettingsDefinition( + createdAssertionLabels = listOf("c2pa.actions"), + ), + ) + + C2PASettings.fromDefinition(definition).use { + // If we got here without an exception, the native settings were created + it.setValue("verify.verify_after_sign", "true") + } + + TestResult("C2PASettings.fromDefinition", true, "fromDefinition creates native settings") + } + } + + /** Test integration with C2PASettings.updateFrom. */ + suspend fun testUpdateFrom(): TestResult = runTest("C2PASettings.updateFrom") { + withContext(Dispatchers.IO) { + C2PASettings.create().use { + val definition = C2PASettingsDefinition( + version = 1, + verify = VerifySettings( + verifyAfterReading = false, + ocspFetch = true, + ), + ) + it.updateFrom(definition) + // If we got here without an exception, the update succeeded + it.setValue("verify.strict_v1_validation", "false") + } + + TestResult("C2PASettings.updateFrom", true, "updateFrom applies definition to existing settings") + } + } + + /** Test pretty JSON output. */ + suspend fun testPrettyJson(): TestResult = runTest("Settings Definition Pretty JSON") { + withContext(Dispatchers.IO) { + val definition = C2PASettingsDefinition( + version = 1, + verify = VerifySettings(verifyAfterSign = true), + ) + + val prettyJson = definition.toPrettyJson() + check("\n" in prettyJson) { "Pretty JSON should contain newlines" } + check(" " in prettyJson || "\t" in prettyJson) { "Pretty JSON should be indented" } + + // Should still be parseable + val restored = C2PASettingsDefinition.fromJson(prettyJson) + check(restored.version == 1) { "Pretty JSON should round-trip" } + + TestResult("Settings Definition Pretty JSON", true, "Pretty JSON output works correctly") + } + } + + /** Test enum serialization formats. */ + suspend fun testEnumSerialization(): TestResult = runTest("Enum Serialization") { + withContext(Dispatchers.IO) { + val definition = C2PASettingsDefinition( + version = 1, + builder = BuilderSettingsDefinition( + thumbnail = ThumbnailSettings( + format = ThumbnailFormat.WEBP, + quality = ThumbnailQuality.MEDIUM, + ), + certificateStatusFetch = OcspFetchScope.ALL, + autoTimestampAssertion = TimeStampSettings( + fetchScope = TimeStampFetchScope.PARENT, + ), + ), + ) + + val json = definition.toJson() + check("\"webp\"" in json) { "ThumbnailFormat.WEBP should serialize as 'webp'" } + check("\"medium\"" in json) { "ThumbnailQuality.MEDIUM should serialize as 'medium'" } + check("\"parent\"" in json) { "TimeStampFetchScope.PARENT should serialize as 'parent'" } + + // Verify round-trip + val restored = C2PASettingsDefinition.fromJson(json) + check(restored.builder?.thumbnail?.format == ThumbnailFormat.WEBP) { + "ThumbnailFormat round-trip failed" + } + check(restored.builder?.thumbnail?.quality == ThumbnailQuality.MEDIUM) { + "ThumbnailQuality round-trip failed" + } + check(restored.builder?.autoTimestampAssertion?.fetchScope == TimeStampFetchScope.PARENT) { + "TimeStampFetchScope round-trip failed" + } + + TestResult("Enum Serialization", true, "All enums serialize as lowercase strings") + } + } + + /** Test ActionTemplateSettings serialization. */ + suspend fun testActionTemplates(): TestResult = runTest("Action Template Settings") { + withContext(Dispatchers.IO) { + val definition = C2PASettingsDefinition( + version = 1, + builder = BuilderSettingsDefinition( + actions = ActionsSettings( + templates = listOf( + ActionTemplateSettings( + action = "c2pa.created", + sourceType = "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture", + description = "Photo captured by camera", + ), + ActionTemplateSettings( + action = "c2pa.edited", + softwareAgent = ClaimGeneratorInfoSettings( + name = "PhotoEditor", + version = "2.0", + ), + ), + ), + ), + ), + ) + + val json = definition.toJson() + check("\"c2pa.created\"" in json) { "First template action missing" } + check("\"c2pa.edited\"" in json) { "Second template action missing" } + check("\"PhotoEditor\"" in json) { "Software agent name missing" } + check("digitalCapture" in json) { "Source type missing" } + check("\"Photo captured by camera\"" in json) { "Description missing" } + + // Round-trip + val restored = C2PASettingsDefinition.fromJson(json) + val templates = restored.builder?.actions?.templates + check(templates != null) { "Templates should not be null" } + check(templates.size == 2) { "Should have 2 templates, got ${templates.size}" } + check(templates[0].action == "c2pa.created") { "First template action mismatch" } + check(templates[0].description == "Photo captured by camera") { + "First template description mismatch" + } + check(templates[1].softwareAgent?.name == "PhotoEditor") { + "Second template software agent mismatch" + } + + TestResult("Action Template Settings", true, "Action templates serialize correctly") + } + } +} diff --git a/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/StreamTests.kt b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/StreamTests.kt index e12ded6..4fac7fe 100644 --- a/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/StreamTests.kt +++ b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/StreamTests.kt @@ -253,6 +253,151 @@ abstract class StreamTests : TestBase() { } } + suspend fun testCallbackStreamFactories(): TestResult = withContext(Dispatchers.IO) { + runTest("Callback Stream Factories") { + val errors = mutableListOf() + + // forReading factory + CallbackStream.forReading( + reader = { _, _ -> 0 }, + seeker = { _, _ -> 0L }, + ).use { stream -> + // Should support read and seek + stream.read(ByteArray(1), 1) + stream.seek(0, SeekMode.START.value) + // Should throw on write + try { + stream.write(ByteArray(1), 1) + errors.add("forReading should not support write") + } catch (e: UnsupportedOperationException) { + // expected + } + // Should throw on flush + try { + stream.flush() + errors.add("forReading should not support flush") + } catch (e: UnsupportedOperationException) { + // expected + } + } + + // forWriting factory + CallbackStream.forWriting( + writer = { _, length -> length }, + seeker = { _, _ -> 0L }, + flusher = { 0 }, + ).use { stream -> + // Should support write, seek, flush + stream.write(ByteArray(1), 1) + stream.seek(0, SeekMode.START.value) + stream.flush() + // Should throw on read + try { + stream.read(ByteArray(1), 1) + errors.add("forWriting should not support read") + } catch (e: UnsupportedOperationException) { + // expected + } + } + + // forReadWrite factory + CallbackStream.forReadWrite( + reader = { _, _ -> 0 }, + writer = { _, length -> length }, + seeker = { _, _ -> 0L }, + flusher = { 0 }, + ).use { stream -> + // Should support all operations + stream.read(ByteArray(1), 1) + stream.write(ByteArray(1), 1) + stream.seek(0, SeekMode.START.value) + stream.flush() + } + + val success = errors.isEmpty() + TestResult( + "Callback Stream Factories", + success, + if (success) "All factory methods work correctly" else "Factory method failures", + errors.joinToString("\n"), + ) + } + } + + suspend fun testByteArrayStreamBufferGrowth(): TestResult = withContext(Dispatchers.IO) { + runTest("ByteArrayStream Buffer Growth") { + val errors = mutableListOf() + + // Start with empty stream + val stream = ByteArrayStream() + stream.use { + // Write data to trigger buffer growth + val data1 = ByteArray(100) { 0xAA.toByte() } + it.write(data1, 100) + + // Verify position and data + var result = it.getData() + if (result.size != 100) { + errors.add("After first write: expected size 100, got ${result.size}") + } + + // Write more to trigger growth + val data2 = ByteArray(200) { 0xBB.toByte() } + it.write(data2, 200) + + result = it.getData() + if (result.size != 300) { + errors.add("After second write: expected size 300, got ${result.size}") + } + + // Seek back and verify read + it.seek(0, SeekMode.START.value) + val readBuf = ByteArray(100) + val bytesRead = it.read(readBuf, 100) + if (bytesRead != 100L) { + errors.add("Read returned $bytesRead instead of 100") + } + if (readBuf[0] != 0xAA.toByte()) { + errors.add("Read data mismatch at position 0") + } + + // Seek to middle and overwrite + it.seek(50, SeekMode.START.value) + val data3 = ByteArray(10) { 0xCC.toByte() } + it.write(data3, 10) + + // Size should not change (overwrite within existing bounds) + result = it.getData() + if (result.size != 300) { + errors.add("After overwrite: expected size 300, got ${result.size}") + } + if (result[50] != 0xCC.toByte()) { + errors.add("Overwrite data mismatch at position 50") + } + + // Seek to end and verify + val endPos = it.seek(0, SeekMode.END.value) + if (endPos != 300L) { + errors.add("Seek to end returned $endPos instead of 300") + } + + // Read at end should return 0 + val endRead = it.read(ByteArray(10), 10) + if (endRead != 0L) { + errors.add("Read at end returned $endRead instead of 0") + } + } + + val success = errors.isEmpty() + TestResult( + "ByteArrayStream Buffer Growth", + success, + if (success) "Buffer growth and operations work correctly" else "Buffer growth failures", + errors.joinToString("\n"), + ) + } + } + suspend fun testLargeBufferHandling(): TestResult = withContext(Dispatchers.IO) { runTest("Large Buffer Handling") { val largeSize = Int.MAX_VALUE.toLong() + 1L diff --git a/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/TestBase.kt b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/TestBase.kt index 0c4a22e..e1a4a83 100644 --- a/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/TestBase.kt +++ b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/TestBase.kt @@ -1,4 +1,4 @@ -/* +/* This file is licensed to you under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) or the MIT license (http://opensource.org/licenses/MIT), at your option. @@ -23,12 +23,22 @@ import java.io.File */ abstract class TestBase { + /** Status of an individual test execution. */ enum class TestStatus { PASSED, FAILED, SKIPPED, } + /** + * Result of a single test execution. + * + * @property name The test name. + * @property success Whether the test passed. + * @property message A human-readable summary of the outcome. + * @property details Optional additional details (e.g., stack traces, data dumps). + * @property status The test status, derived from [success] by default. + */ data class TestResult( val name: String, val success: Boolean, @@ -38,10 +48,23 @@ abstract class TestBase { ) companion object { + // Note: C2PA 2.3 spec requires first action to be "c2pa.created" or "c2pa.opened" const val TEST_MANIFEST_JSON = """{ "claim_generator": "test_app/1.0", - "assertions": [{"label": "c2pa.test", "data": {"test": true}}] + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture" + } + ] + } + } + ] }""" /** Load a test resource from the classpath (test-shared module resources). */