From 15345029c52be6ec36bd0dbd8940ee31d16f7fb2 Mon Sep 17 00:00:00 2001 From: n8fr8 Date: Thu, 4 Sep 2025 21:58:01 -0400 Subject: [PATCH 1/9] add ManifestBuilder package for simple, compliant manifest JSON generation --- .../c2pa/manifest/AttestationBuilder.kt | 510 ++++++++++++++++++ .../contentauth/c2pa/manifest/C2PAActions.kt | 102 ++++ .../c2pa/manifest/ManifestBuilder.kt | 243 +++++++++ .../c2pa/manifest/ManifestHelpers.kt | 401 ++++++++++++++ 4 files changed, 1256 insertions(+) create mode 100644 library/src/main/kotlin/org/contentauth/c2pa/manifest/AttestationBuilder.kt create mode 100644 library/src/main/kotlin/org/contentauth/c2pa/manifest/C2PAActions.kt create mode 100644 library/src/main/kotlin/org/contentauth/c2pa/manifest/ManifestBuilder.kt create mode 100644 library/src/main/kotlin/org/contentauth/c2pa/manifest/ManifestHelpers.kt diff --git a/library/src/main/kotlin/org/contentauth/c2pa/manifest/AttestationBuilder.kt b/library/src/main/kotlin/org/contentauth/c2pa/manifest/AttestationBuilder.kt new file mode 100644 index 0000000..5a0101b --- /dev/null +++ b/library/src/main/kotlin/org/contentauth/c2pa/manifest/AttestationBuilder.kt @@ -0,0 +1,510 @@ +package org.contentauth.c2pa.manifest + +import org.json.JSONArray +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.* + +abstract class Attestation(val type: String) { + abstract fun toJsonObject(): JSONObject +} + +class CreativeWorkAttestation : Attestation(C2PAAssertionTypes.CREATIVE_WORK) { + private val authors = mutableListOf() + private var dateCreated: String? = null + private var reviewStatus: String? = null + + data class Author( + val name: String, + val credential: String? = null, + val identifier: String? = null + ) + + fun addAuthor(name: String, credential: String? = null, identifier: String? = null): CreativeWorkAttestation { + authors.add(Author(name, credential, identifier)) + return this + } + + fun dateCreated(date: Date): CreativeWorkAttestation { + val iso8601 = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + this.dateCreated = iso8601.format(date) + return this + } + + fun dateCreated(isoDateString: String): CreativeWorkAttestation { + this.dateCreated = isoDateString + return this + } + + fun reviewStatus(status: String): CreativeWorkAttestation { + this.reviewStatus = status + return this + } + + override fun toJsonObject(): JSONObject { + return JSONObject().apply { + if (authors.isNotEmpty()) { + put("author", JSONArray().apply { + authors.forEach { author -> + put(JSONObject().apply { + put("name", author.name) + author.credential?.let { put("credential", it) } + author.identifier?.let { put("@id", it) } + }) + } + }) + } + dateCreated?.let { put("dateCreated", it) } + reviewStatus?.let { put("reviewStatus", it) } + } + } +} + +class ActionsAttestation : Attestation("c2pa.actions") { + private val actionsList = mutableListOf() + + fun addAction(action: Action): ActionsAttestation { + actionsList.add(action) + return this + } + + fun addCreatedAction(softwareAgent: String? = null, whenTimestamp: String? = null): ActionsAttestation { + actionsList.add(Action(C2PAActions.CREATED, whenTimestamp, softwareAgent)) + return this + } + + fun addEditedAction(softwareAgent: String? = null, whenTimestamp: String? = null, changes: List = emptyList()): ActionsAttestation { + actionsList.add(Action(C2PAActions.EDITED, whenTimestamp, softwareAgent, changes)) + return this + } + + fun addOpenedAction(softwareAgent: String? = null, whenTimestamp: String? = null): ActionsAttestation { + actionsList.add(Action(C2PAActions.OPENED, whenTimestamp, softwareAgent)) + return this + } + + fun addPlacedAction(softwareAgent: String? = null, whenTimestamp: String? = null): ActionsAttestation { + actionsList.add(Action(C2PAActions.PLACED, whenTimestamp, softwareAgent)) + return this + } + + fun addDrawingAction(softwareAgent: String? = null, whenTimestamp: String? = null): ActionsAttestation { + actionsList.add(Action(C2PAActions.DRAWING, whenTimestamp, softwareAgent)) + return this + } + + fun addColorAdjustmentsAction(softwareAgent: String? = null, whenTimestamp: String? = null, parameters: Map = emptyMap()): ActionsAttestation { + actionsList.add(Action(C2PAActions.COLOR_ADJUSTMENTS, whenTimestamp, softwareAgent, emptyList(), null, parameters)) + return this + } + + fun addResizedAction(softwareAgent: String? = null, whenTimestamp: String? = null): ActionsAttestation { + actionsList.add(Action(C2PAActions.RESIZED, whenTimestamp, softwareAgent)) + return this + } + + override fun toJsonObject(): JSONObject { + return JSONObject().apply { + put("actions", JSONArray().apply { + actionsList.forEach { action -> + put(JSONObject().apply { + put("action", action.action) + action.whenTimestamp?.let { put("when", it) } + action.softwareAgent?.let { put("softwareAgent", it) } + action.reason?.let { put("reason", it) } + + if (action.changes.isNotEmpty()) { + put("changes", JSONArray().apply { + action.changes.forEach { change -> + put(JSONObject().apply { + put("field", change.field) + put("description", change.description) + }) + } + }) + } + + if (action.parameters.isNotEmpty()) { + val params = JSONObject() + action.parameters.forEach { (key, value) -> + params.put(key, value) + } + put("parameters", params) + } + }) + } + }) + } + } +} + +class AssertionMetadataAttestation : Attestation("c2pa.assertion.metadata") { + private val metadata = mutableMapOf() + + fun addMetadata(key: String, value: Any): AssertionMetadataAttestation { + metadata[key] = value + return this + } + + fun dateTime(dateTime: String): AssertionMetadataAttestation { + metadata["dateTime"] = dateTime + return this + } + + fun location(location: JSONObject): AssertionMetadataAttestation { + metadata["location"] = location + return this + } + + fun device(device: String): AssertionMetadataAttestation { + metadata["device"] = device + return this + } + + override fun toJsonObject(): JSONObject { + val json = JSONObject() + metadata.forEach { (key, value) -> + when (value) { + is JSONObject, is JSONArray -> json.put(key, value) + is String -> json.put(key, value) + is Number -> json.put(key, value) + is Boolean -> json.put(key, value) + else -> json.put(key, value.toString()) + } + } + return json + } +} + +class ThumbnailAttestation : Attestation("c2pa.thumbnail") { + private var format: String? = null + private var identifier: String? = null + private var contentType: String = "image/jpeg" + + fun format(format: String): ThumbnailAttestation { + this.format = format + return this + } + + fun identifier(identifier: String): ThumbnailAttestation { + this.identifier = identifier + return this + } + + fun contentType(contentType: String): ThumbnailAttestation { + this.contentType = contentType + return this + } + + override fun toJsonObject(): JSONObject { + return JSONObject().apply { + format?.let { put("format", it) } + identifier?.let { put("identifier", it) } + put("contentType", contentType) + } + } +} + +class DataHashAttestation : Attestation("c2pa.data_hash") { + private var exclusions: List> = emptyList() + private var name: String = "jumbf manifest" + private var pad: Int? = null + + fun exclusions(exclusions: List>): DataHashAttestation { + this.exclusions = exclusions + return this + } + + fun name(name: String): DataHashAttestation { + this.name = name + return this + } + + fun pad(pad: Int): DataHashAttestation { + this.pad = pad + return this + } + + override fun toJsonObject(): JSONObject { + return JSONObject().apply { + if (exclusions.isNotEmpty()) { + put("exclusions", JSONArray().apply { + exclusions.forEach { exclusion -> + put(JSONObject().apply { + exclusion.forEach { (key, value) -> + put(key, value) + } + }) + } + }) + } + put("name", name) + pad?.let { put("pad", it) } + } + } +} + +data class VerifiedIdentity( + val type: String, + val username: String, + val uri: String, + val verifiedAt: String, + val provider: IdentityProvider +) + +data class IdentityProvider( + val id: String, + val name: String +) + +data class CredentialSchema( + val id: String = "https://cawg.io/identity/1.1/ica/schema/", + val type: String = "JSONSchema" +) + +class CAWGIdentityAttestation : Attestation(C2PAAssertionTypes.CAWG_IDENTITY) { + private val contexts = mutableListOf( + "https://www.w3.org/ns/credentials/v2", + "https://cawg.io/identity/1.1/ica/context/" + ) + private val types = mutableListOf("VerifiableCredential", "IdentityClaimsAggregationCredential") + private var issuer: String = "did:web:connected-identities.identity.adobe.com" + private var validFrom: String? = null + private val verifiedIdentities = mutableListOf() + private val credentialSchemas = mutableListOf() + + init { + credentialSchemas.add(CredentialSchema()) + } + + fun issuer(issuer: String): CAWGIdentityAttestation { + this.issuer = issuer + return this + } + + fun validFrom(validFrom: String): CAWGIdentityAttestation { + this.validFrom = validFrom + return this + } + + fun validFromNow(): CAWGIdentityAttestation { + val iso8601 = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + this.validFrom = iso8601.format(Date()) + return this + } + + fun addContext(context: String): CAWGIdentityAttestation { + if (!contexts.contains(context)) { + contexts.add(context) + } + return this + } + + fun addType(type: String): CAWGIdentityAttestation { + if (!types.contains(type)) { + types.add(type) + } + return this + } + + fun addVerifiedIdentity( + type: String, + username: String, + uri: String, + verifiedAt: String, + providerId: String, + providerName: String + ): CAWGIdentityAttestation { + verifiedIdentities.add( + VerifiedIdentity( + type = type, + username = username, + uri = uri, + verifiedAt = verifiedAt, + provider = IdentityProvider(providerId, providerName) + ) + ) + return this + } + + fun addSocialMediaIdentity( + username: String, + uri: String, + verifiedAt: String, + providerId: String, + providerName: String + ): CAWGIdentityAttestation { + return addVerifiedIdentity(CAWGIdentityTypes.SOCIAL_MEDIA, username, uri, verifiedAt, providerId, providerName) + } + + fun addInstagramIdentity(username: String, verifiedAt: String): CAWGIdentityAttestation { + return addSocialMediaIdentity( + username = username, + uri = "https://www.instagram.com/$username", + verifiedAt = verifiedAt, + providerId = CAWGProviders.INSTAGRAM, + providerName = "instagram" + ) + } + + fun addTwitterIdentity(username: String, verifiedAt: String): CAWGIdentityAttestation { + return addSocialMediaIdentity( + username = username, + uri = "https://twitter.com/$username", + verifiedAt = verifiedAt, + providerId = CAWGProviders.TWITTER, + providerName = "twitter" + ) + } + + fun addLinkedInIdentity(displayName: String, profileUrl: String, verifiedAt: String): CAWGIdentityAttestation { + return addSocialMediaIdentity( + username = displayName, + uri = profileUrl, + verifiedAt = verifiedAt, + providerId = CAWGProviders.LINKEDIN, + providerName = "linkedin" + ) + } + + fun addBehanceIdentity(username: String, verifiedAt: String): CAWGIdentityAttestation { + return addSocialMediaIdentity( + username = username, + uri = "https://www.behance.net/$username", + verifiedAt = verifiedAt, + providerId = CAWGProviders.BEHANCE, + providerName = "behance" + ) + } + + fun addYouTubeIdentity(channelName: String, channelUrl: String, verifiedAt: String): CAWGIdentityAttestation { + return addSocialMediaIdentity( + username = channelName, + uri = channelUrl, + verifiedAt = verifiedAt, + providerId = CAWGProviders.YOUTUBE, + providerName = "youtube" + ) + } + + fun addGitHubIdentity(username: String, verifiedAt: String): CAWGIdentityAttestation { + return addSocialMediaIdentity( + username = username, + uri = "https://github.com/$username", + verifiedAt = verifiedAt, + providerId = CAWGProviders.GITHUB, + providerName = "github" + ) + } + + fun addCredentialSchema(id: String, type: String): CAWGIdentityAttestation { + credentialSchemas.add(CredentialSchema(id, type)) + return this + } + + override fun toJsonObject(): JSONObject { + return JSONObject().apply { + put("@context", JSONArray(contexts)) + put("type", JSONArray(types)) + put("issuer", issuer) + validFrom?.let { put("validFrom", it) } + + if (verifiedIdentities.isNotEmpty()) { + put("verifiedIdentities", JSONArray().apply { + verifiedIdentities.forEach { identity -> + put(JSONObject().apply { + put("type", identity.type) + put("username", identity.username) + put("uri", identity.uri) + put("verifiedAt", identity.verifiedAt) + put("provider", JSONObject().apply { + put("id", identity.provider.id) + put("name", identity.provider.name) + }) + }) + } + }) + } + + if (credentialSchemas.isNotEmpty()) { + put("credentialSchema", JSONArray().apply { + credentialSchemas.forEach { schema -> + put(JSONObject().apply { + put("id", schema.id) + put("type", schema.type) + }) + } + }) + } + } + } +} + +class AttestationBuilder { + private val attestations = mutableListOf() + + fun addCreativeWork(configure: CreativeWorkAttestation.() -> Unit): AttestationBuilder { + val attestation = CreativeWorkAttestation() + attestation.configure() + attestations.add(attestation) + return this + } + + fun addActions(configure: ActionsAttestation.() -> Unit): AttestationBuilder { + val attestation = ActionsAttestation() + attestation.configure() + attestations.add(attestation) + return this + } + + fun addAssertionMetadata(configure: AssertionMetadataAttestation.() -> Unit): AttestationBuilder { + val attestation = AssertionMetadataAttestation() + attestation.configure() + attestations.add(attestation) + return this + } + + fun addThumbnail(configure: ThumbnailAttestation.() -> Unit): AttestationBuilder { + val attestation = ThumbnailAttestation() + attestation.configure() + attestations.add(attestation) + return this + } + + fun addDataHash(configure: DataHashAttestation.() -> Unit): AttestationBuilder { + val attestation = DataHashAttestation() + attestation.configure() + attestations.add(attestation) + return this + } + + fun addCAWGIdentity(configure: CAWGIdentityAttestation.() -> Unit): AttestationBuilder { + val attestation = CAWGIdentityAttestation() + attestation.configure() + attestations.add(attestation) + return this + } + + fun addCustomAttestation(type: String, data: JSONObject): AttestationBuilder { + attestations.add(object : Attestation(type) { + override fun toJsonObject(): JSONObject = data + }) + return this + } + + fun build(): Map { + return attestations.associate { it.type to it.toJsonObject() } + } + + fun buildForManifest(manifestBuilder: ManifestBuilder): ManifestBuilder { + val builtAttestations = build() + builtAttestations.forEach { (type, data) -> + manifestBuilder.addAssertion(type, data) + } + return manifestBuilder + } +} \ No newline at end of file diff --git a/library/src/main/kotlin/org/contentauth/c2pa/manifest/C2PAActions.kt b/library/src/main/kotlin/org/contentauth/c2pa/manifest/C2PAActions.kt new file mode 100644 index 0000000..cb1d88c --- /dev/null +++ b/library/src/main/kotlin/org/contentauth/c2pa/manifest/C2PAActions.kt @@ -0,0 +1,102 @@ +package org.contentauth.c2pa.manifest + +object C2PAActions { + const val CREATED = "c2pa.created" + const val EDITED = "c2pa.edited" + const val OPENED = "c2pa.opened" + const val PLACED = "c2pa.placed" + const val DRAWING = "c2pa.drawing" + const val COLOR_ADJUSTMENTS = "c2pa.color_adjustments" + const val RESIZED = "c2pa.resized" + const val CROPPED = "c2pa.cropped" + const val FILTERED = "c2pa.filtered" + const val ORIENTATION = "c2pa.orientation" + const val TRANSCODED = "c2pa.transcoded" + const val RECOMPRESSED = "c2pa.recompressed" + const val VERSION_CREATED = "c2pa.version_created" + const val CONVERTED = "c2pa.converted" + const val PRODUCED = "c2pa.produced" + const val PUBLISHED = "c2pa.published" + const val REDACTED = "c2pa.redacted" + + object Adobe { + const val PHOTOSHOP_EDITED = "adobe.photoshop.edited" + const val ILLUSTRATOR_EDITED = "adobe.illustrator.edited" + const val INDESIGN_EDITED = "adobe.indesign.edited" + const val LIGHTROOM_EDITED = "adobe.lightroom.edited" + const val PREMIERE_EDITED = "adobe.premiere.edited" + const val AFTER_EFFECTS_EDITED = "adobe.after_effects.edited" + } +} + +object C2PAAssertionTypes { + const val CREATIVE_WORK = "c2pa.creative_work" + const val ACTIONS = "c2pa.actions" + const val ASSERTION_METADATA = "c2pa.assertion.metadata" + const val THUMBNAIL = "c2pa.thumbnail" + const val DATA_HASH = "c2pa.data_hash" + const val HASH_DATA = "c2pa.hash.data" + const val BMFF_HASH = "c2pa.hash.bmff" + const val EXIF = "stds.exif" + const val IPTC = "stds.iptc" + const val XMP = "stds.xmp" + const val SCHEMA_ORG_CREATIVE_WORK = "stds.schema-org.CreativeWork" + const val INGREDIENT = "c2pa.ingredient" + const val CAWG_IDENTITY = "cawg.identity" +} + +object CAWGIdentityTypes { + const val SOCIAL_MEDIA = "cawg.social_media" + const val EMAIL = "cawg.email" + const val PHONE = "cawg.phone" + const val WEBSITE = "cawg.website" + const val PROFESSIONAL = "cawg.professional" +} + +object CAWGProviders { + const val INSTAGRAM = "https://instagram.com" + const val BEHANCE = "https://behance.net" + const val LINKEDIN = "https://linkedin.com" + const val TWITTER = "https://twitter.com" + const val FACEBOOK = "https://facebook.com" + const val YOUTUBE = "https://youtube.com" + const val TIKTOK = "https://tiktok.com" + const val SNAPCHAT = "https://snapchat.com" + const val PINTEREST = "https://pinterest.com" + const val GITHUB = "https://github.com" + const val DRIBBBLE = "https://dribbble.com" + const val ARTSTATION = "https://artstation.com" +} + +object C2PAFormats { + const val JPEG = "image/jpeg" + const val PNG = "image/png" + const val WEBP = "image/webp" + const val TIFF = "image/tiff" + const val HEIF = "image/heif" + const val AVIF = "image/avif" + const val MP4 = "video/mp4" + const val MOV = "video/quicktime" + const val AVI = "video/x-msvideo" + const val PDF = "application/pdf" + const val SVG = "image/svg+xml" + const val GIF = "image/gif" + const val BMP = "image/bmp" + const val WEBM = "video/webm" + const val OGG = "video/ogg" + const val MKV = "video/x-matroska" +} + +object C2PARelationships { + const val PARENT_OF = "parentOf" + const val COMPONENT_OF = "componentOf" + const val INGREDIENT_OF = "ingredientOf" + const val ALTERNATE_OF = "alternateOf" +} + +object TimestampAuthorities { + const val DIGICERT = "http://timestamp.digicert.com" + const val SECTIGO = "http://timestamp.sectigo.com" + const val GLOBALSIGN = "http://timestamp.globalsign.com/tsa/r6advanced1" + const val ENTRUST = "http://timestamp.entrust.net/TSS/RFC3161sha2TS" +} \ No newline at end of file diff --git a/library/src/main/kotlin/org/contentauth/c2pa/manifest/ManifestBuilder.kt b/library/src/main/kotlin/org/contentauth/c2pa/manifest/ManifestBuilder.kt new file mode 100644 index 0000000..b06042e --- /dev/null +++ b/library/src/main/kotlin/org/contentauth/c2pa/manifest/ManifestBuilder.kt @@ -0,0 +1,243 @@ +package org.contentauth.c2pa.manifest + +import org.json.JSONArray +import org.json.JSONObject +import java.util.* + +data class ClaimGenerator( + val name: String, + val version: String, + val icon: String? = null +) + +data class Ingredient( + val title: String? = null, + val format: String, + val instanceId: String = UUID.randomUUID().toString(), + val documentId: String? = null, + val provenance: String? = null, + val hash: String? = null, + val relationship: String = "parentOf", + val validationStatus: List = emptyList(), + val thumbnail: Thumbnail? = null +) + +data class Thumbnail( + val format: String, + val identifier: String, + val contentType: String = "image/jpeg" +) + +data class Action( + val action: String, + val whenTimestamp: String? = null, + val softwareAgent: String? = null, + val changes: List = emptyList(), + val reason: String? = null, + val parameters: Map = emptyMap() +) + +data class ActionChange( + val field: String, + val description: String +) + +class ManifestBuilder { + private var claimGenerator: ClaimGenerator? = null + private var format: String? = null + private var title: String? = null + private var instanceId: String = UUID.randomUUID().toString() + private var documentId: String? = null + private val ingredients = mutableListOf() + private val actions = mutableListOf() + private val assertions = mutableMapOf() + private var thumbnail: Thumbnail? = null + private var producer: String? = null + private var taUrl: String? = null + + fun claimGenerator(name: String, version: String, icon: String? = null): ManifestBuilder { + this.claimGenerator = ClaimGenerator(name, version, icon) + return this + } + + fun format(format: String): ManifestBuilder { + this.format = format + return this + } + + fun title(title: String): ManifestBuilder { + this.title = title + return this + } + + fun instanceId(instanceId: String): ManifestBuilder { + this.instanceId = instanceId + return this + } + + fun documentId(documentId: String): ManifestBuilder { + this.documentId = documentId + return this + } + + fun producer(producer: String): ManifestBuilder { + this.producer = producer + return this + } + + fun timestampAuthorityUrl(taUrl: String): ManifestBuilder { + this.taUrl = taUrl + return this + } + + fun addIngredient(ingredient: Ingredient): ManifestBuilder { + ingredients.add(ingredient) + return this + } + + fun addAction(action: Action): ManifestBuilder { + actions.add(action) + return this + } + + fun addThumbnail(thumbnail: Thumbnail): ManifestBuilder { + this.thumbnail = thumbnail + return this + } + + fun addAssertion(type: String, data: Any): ManifestBuilder { + assertions[type] = data + return this + } + + fun build(): JSONObject { + val manifest = JSONObject() + + // Add claim version + manifest.put("claim_version", 1) + + // Add timestamp authority URL if present + taUrl?.let { manifest.put("ta_url", it) } + + // Add basic manifest fields + format?.let { manifest.put("format", it) } + title?.let { manifest.put("title", it) } + manifest.put("instanceID", instanceId) + documentId?.let { manifest.put("documentID", it) } + producer?.let { manifest.put("producer", it) } + + // Add claim generator info as array + claimGenerator?.let { generator -> + manifest.put("claim_generator_info", JSONArray().apply { + put(JSONObject().apply { + put("name", generator.name) + put("version", generator.version) + generator.icon?.let { put("icon", it) } + }) + }) + } + + // Add thumbnail + thumbnail?.let { thumb -> + manifest.put("thumbnail", JSONObject().apply { + put("format", thumb.format) + put("identifier", thumb.identifier) + }) + } + + // Add ingredients + if (ingredients.isNotEmpty()) { + manifest.put("ingredients", JSONArray().apply { + ingredients.forEach { ingredient -> + put(JSONObject().apply { + ingredient.title?.let { put("title", it) } + put("format", ingredient.format) + put("instanceID", ingredient.instanceId) + ingredient.documentId?.let { put("documentID", it) } + ingredient.provenance?.let { put("provenance", it) } + ingredient.hash?.let { put("hash", it) } + put("relationship", ingredient.relationship) + + if (ingredient.validationStatus.isNotEmpty()) { + put("validationStatus", JSONArray(ingredient.validationStatus)) + } + + ingredient.thumbnail?.let { thumb -> + put("thumbnail", JSONObject().apply { + put("format", thumb.format) + put("identifier", thumb.identifier) + }) + } + }) + } + }) + } + + // Build assertions array + val assertionsArray = JSONArray() + + // Add actions as an assertion if present + if (actions.isNotEmpty()) { + assertionsArray.put(JSONObject().apply { + put("label", "c2pa.actions") + put("data", JSONObject().apply { + put("actions", JSONArray().apply { + actions.forEach { action -> + put(JSONObject().apply { + put("action", action.action) + action.whenTimestamp?.let { put("when", it) } + action.softwareAgent?.let { put("softwareAgent", it) } + action.reason?.let { put("reason", it) } + + if (action.changes.isNotEmpty()) { + put("changes", JSONArray().apply { + action.changes.forEach { change -> + put(JSONObject().apply { + put("field", change.field) + put("description", change.description) + }) + } + }) + } + + if (action.parameters.isNotEmpty()) { + val params = JSONObject() + action.parameters.forEach { (key, value) -> + params.put(key, value) + } + put("parameters", params) + } + }) + } + }) + }) + }) + } + + // Add other assertions + assertions.forEach { (label, data) -> + assertionsArray.put(JSONObject().apply { + put("label", label) + when (data) { + is JSONObject -> put("data", data) + is JSONArray -> put("data", data) + is String -> put("data", data) + is Number -> put("data", data) + is Boolean -> put("data", data) + else -> put("data", data.toString()) + } + }) + } + + // Add assertions array if not empty + if (assertionsArray.length() > 0) { + manifest.put("assertions", assertionsArray) + } + + return manifest + } + + fun buildJson(): String { + return build().toString(2) + } +} \ No newline at end of file diff --git a/library/src/main/kotlin/org/contentauth/c2pa/manifest/ManifestHelpers.kt b/library/src/main/kotlin/org/contentauth/c2pa/manifest/ManifestHelpers.kt new file mode 100644 index 0000000..749365e --- /dev/null +++ b/library/src/main/kotlin/org/contentauth/c2pa/manifest/ManifestHelpers.kt @@ -0,0 +1,401 @@ +package org.contentauth.c2pa.manifest + +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.* + +object ManifestHelpers { + + fun createBasicImageManifest( + title: String, + format: String = C2PAFormats.JPEG, + claimGeneratorName: String = "Android C2PA SDK", + claimGeneratorVersion: String = "1.0.0", + timestampAuthorityUrl: String? = null + ): ManifestBuilder { + val builder = ManifestBuilder() + .title(title) + .format(format) + .claimGenerator(claimGeneratorName, claimGeneratorVersion) + + timestampAuthorityUrl?.let { builder.timestampAuthorityUrl(it) } + return builder + } + + fun createImageEditManifest( + title: String, + originalIngredientTitle: String, + originalFormat: String = C2PAFormats.JPEG, + format: String = C2PAFormats.JPEG, + softwareAgent: String? = null, + claimGeneratorName: String = "Android C2PA SDK", + claimGeneratorVersion: String = "1.0.0" + ): ManifestBuilder { + val ingredient = Ingredient( + title = originalIngredientTitle, + format = originalFormat, + relationship = C2PARelationships.PARENT_OF + ) + + return ManifestBuilder() + .title(title) + .format(format) + .claimGenerator(claimGeneratorName, claimGeneratorVersion) + .addIngredient(ingredient) + .addAction(Action( + action = C2PAActions.EDITED, + whenTimestamp = getCurrentTimestamp(), + softwareAgent = softwareAgent + )) + } + + fun createPhotoManifest( + title: String, + format: String = C2PAFormats.JPEG, + authorName: String? = null, + deviceName: String? = null, + location: JSONObject? = null, + claimGeneratorName: String = "Android C2PA SDK", + claimGeneratorVersion: String = "1.0.0" + ): ManifestBuilder { + val builder = ManifestBuilder() + .title(title) + .format(format) + .claimGenerator(claimGeneratorName, claimGeneratorVersion) + .addAction(Action( + action = C2PAActions.CREATED, + whenTimestamp = getCurrentTimestamp(), + softwareAgent = deviceName + )) + + if (authorName != null || deviceName != null || location != null) { + val attestationBuilder = AttestationBuilder() + + if (authorName != null) { + attestationBuilder.addCreativeWork { + addAuthor(authorName) + dateCreated(Date()) + } + } + + attestationBuilder.addAssertionMetadata { + dateTime(getCurrentTimestamp()) + deviceName?.let { device(it) } + location?.let { location(it) } + } + + attestationBuilder.buildForManifest(builder) + } + + return builder + } + + fun createVideoEditManifest( + title: String, + originalIngredientTitle: String, + originalFormat: String = C2PAFormats.MP4, + format: String = C2PAFormats.MP4, + editingSoftware: String? = null, + editActions: List = emptyList(), + claimGeneratorName: String = "Android C2PA SDK", + claimGeneratorVersion: String = "1.0.0" + ): ManifestBuilder { + val ingredient = Ingredient( + title = originalIngredientTitle, + format = originalFormat, + relationship = C2PARelationships.PARENT_OF + ) + + val builder = ManifestBuilder() + .title(title) + .format(format) + .claimGenerator(claimGeneratorName, claimGeneratorVersion) + .addIngredient(ingredient) + + val timestamp = getCurrentTimestamp() + + builder.addAction(Action( + action = C2PAActions.OPENED, + whenTimestamp = timestamp, + softwareAgent = editingSoftware + )) + + editActions.forEach { actionType -> + builder.addAction(Action( + action = actionType, + whenTimestamp = timestamp, + softwareAgent = editingSoftware + )) + } + + return builder + } + + fun createCompositeManifest( + title: String, + format: String = C2PAFormats.JPEG, + ingredients: List, + compositingSoftware: String? = null, + claimGeneratorName: String = "Android C2PA SDK", + claimGeneratorVersion: String = "1.0.0" + ): ManifestBuilder { + val builder = ManifestBuilder() + .title(title) + .format(format) + .claimGenerator(claimGeneratorName, claimGeneratorVersion) + .addAction(Action( + action = C2PAActions.CREATED, + whenTimestamp = getCurrentTimestamp(), + softwareAgent = compositingSoftware + )) + + ingredients.forEach { ingredient -> + builder.addIngredient(ingredient) + builder.addAction(Action( + action = C2PAActions.PLACED, + whenTimestamp = getCurrentTimestamp(), + softwareAgent = compositingSoftware + )) + } + + return builder + } + + fun createScreenshotManifest( + deviceName: String, + appName: String? = null, + format: String = C2PAFormats.PNG, + claimGeneratorName: String = "Android C2PA SDK", + claimGeneratorVersion: String = "1.0.0" + ): ManifestBuilder { + val builder = ManifestBuilder() + .title("Screenshot") + .format(format) + .claimGenerator(claimGeneratorName, claimGeneratorVersion) + .producer(deviceName) + .addAction(Action( + action = C2PAActions.CREATED, + whenTimestamp = getCurrentTimestamp(), + softwareAgent = appName ?: "Screenshot" + )) + + val attestationBuilder = AttestationBuilder() + attestationBuilder.addAssertionMetadata { + device(deviceName) + dateTime(getCurrentTimestamp()) + addMetadata("capture_method", "screenshot") + appName?.let { addMetadata("source_application", it) } + } + + attestationBuilder.buildForManifest(builder) + return builder + } + + fun createSocialMediaShareManifest( + originalTitle: String, + platform: String, + originalFormat: String = C2PAFormats.JPEG, + format: String = C2PAFormats.JPEG, + claimGeneratorName: String = "Android C2PA SDK", + claimGeneratorVersion: String = "1.0.0" + ): ManifestBuilder { + val ingredient = Ingredient( + title = originalTitle, + format = originalFormat, + relationship = C2PARelationships.PARENT_OF + ) + + return ManifestBuilder() + .title("Shared on $platform") + .format(format) + .claimGenerator(claimGeneratorName, claimGeneratorVersion) + .addIngredient(ingredient) + .addAction(Action( + action = C2PAActions.RECOMPRESSED, + whenTimestamp = getCurrentTimestamp(), + softwareAgent = platform, + reason = "Social media optimization" + )) + .addAction(Action( + action = C2PAActions.PUBLISHED, + whenTimestamp = getCurrentTimestamp(), + softwareAgent = platform + )) + } + + fun createFilteredImageManifest( + originalTitle: String, + filterName: String, + originalFormat: String = C2PAFormats.JPEG, + format: String = C2PAFormats.JPEG, + appName: String? = null, + claimGeneratorName: String = "Android C2PA SDK", + claimGeneratorVersion: String = "1.0.0" + ): ManifestBuilder { + val ingredient = Ingredient( + title = originalTitle, + format = originalFormat, + relationship = C2PARelationships.PARENT_OF + ) + + val filterParameters = mapOf( + "filter_name" to filterName, + "filter_type" to "digital_filter" + ) + + return ManifestBuilder() + .title("$originalTitle (Filtered)") + .format(format) + .claimGenerator(claimGeneratorName, claimGeneratorVersion) + .addIngredient(ingredient) + .addAction(Action( + action = C2PAActions.FILTERED, + whenTimestamp = getCurrentTimestamp(), + softwareAgent = appName, + parameters = filterParameters + )) + } + + private fun getCurrentTimestamp(): String { + val iso8601 = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + return iso8601.format(Date()) + } + + fun createLocation(latitude: Double, longitude: Double, name: String? = null): JSONObject { + return JSONObject().apply { + put("@type", "Place") + put("latitude", latitude) + put("longitude", longitude) + name?.let { put("name", it) } + } + } + + fun createGeoLocation( + latitude: Double, + longitude: Double, + altitude: Double? = null, + accuracy: Double? = null + ): JSONObject { + return JSONObject().apply { + put("@type", "GeoCoordinates") + put("latitude", latitude) + put("longitude", longitude) + altitude?.let { put("elevation", it) } + accuracy?.let { put("accuracy", it) } + } + } + + fun addStandardThumbnail( + manifestBuilder: ManifestBuilder, + thumbnailIdentifier: String = "thumbnail.jpg", + format: String = C2PAFormats.JPEG + ): ManifestBuilder { + return manifestBuilder.addThumbnail( + Thumbnail( + format = format, + identifier = thumbnailIdentifier, + contentType = format + ) + ) + } + + fun createCreatorVerifiedManifest( + title: String, + format: String = C2PAFormats.JPEG, + authorName: String? = null, + deviceName: String? = null, + location: JSONObject? = null, + creatorIdentities: List = emptyList(), + claimGeneratorName: String = "Android C2PA SDK", + claimGeneratorVersion: String = "1.0.0" + ): ManifestBuilder { + val builder = ManifestBuilder() + .title(title) + .format(format) + .claimGenerator(claimGeneratorName, claimGeneratorVersion) + .addAction(Action( + action = C2PAActions.CREATED, + whenTimestamp = getCurrentTimestamp(), + softwareAgent = deviceName + )) + + val attestationBuilder = AttestationBuilder() + + if (authorName != null) { + attestationBuilder.addCreativeWork { + addAuthor(authorName) + dateCreated(Date()) + } + } + + if (creatorIdentities.isNotEmpty()) { + attestationBuilder.addCAWGIdentity { + validFromNow() + creatorIdentities.forEach { identity -> + addVerifiedIdentity( + type = identity.type, + username = identity.username, + uri = identity.uri, + verifiedAt = identity.verifiedAt, + providerId = identity.provider.id, + providerName = identity.provider.name + ) + } + } + } + + if (deviceName != null || location != null) { + attestationBuilder.addAssertionMetadata { + dateTime(getCurrentTimestamp()) + deviceName?.let { device(it) } + location?.let { location(it) } + } + } + + attestationBuilder.buildForManifest(builder) + return builder + } + + fun createSocialMediaCreatorManifest( + title: String, + platform: String, + username: String, + verifiedAt: String = getCurrentTimestamp(), + format: String = C2PAFormats.JPEG, + claimGeneratorName: String = "Android C2PA SDK", + claimGeneratorVersion: String = "1.0.0" + ): ManifestBuilder { + val builder = ManifestBuilder() + .title(title) + .format(format) + .claimGenerator(claimGeneratorName, claimGeneratorVersion) + .addAction(Action( + action = C2PAActions.CREATED, + whenTimestamp = getCurrentTimestamp(), + softwareAgent = platform + )) + + val attestationBuilder = AttestationBuilder() + attestationBuilder.addCAWGIdentity { + validFromNow() + when (platform.lowercase()) { + "instagram" -> addInstagramIdentity(username, verifiedAt) + "twitter", "x" -> addTwitterIdentity(username, verifiedAt) + "behance" -> addBehanceIdentity(username, verifiedAt) + "github" -> addGitHubIdentity(username, verifiedAt) + else -> addSocialMediaIdentity( + username = username, + uri = "https://$platform.com/$username", + verifiedAt = verifiedAt, + providerId = "https://$platform.com", + providerName = platform.lowercase() + ) + } + } + + attestationBuilder.buildForManifest(builder) + return builder + } +} \ No newline at end of file From 1e5e115cfc751afd680355df8548c2428e4d3c7c Mon Sep 17 00:00:00 2001 From: n8fr8 Date: Mon, 27 Oct 2025 17:12:46 -0400 Subject: [PATCH 2/9] add new tests for ManifestBuilder package and update sdk to min 28 --- test-shared/build.gradle.kts | 2 +- .../c2pa/test/shared/ManifestBuilderTests.kt | 280 ++++++++++++++++++ 2 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/ManifestBuilderTests.kt diff --git a/test-shared/build.gradle.kts b/test-shared/build.gradle.kts index c852861..1dd5d87 100644 --- a/test-shared/build.gradle.kts +++ b/test-shared/build.gradle.kts @@ -8,7 +8,7 @@ android { compileSdk = 36 defaultConfig { - minSdk = 26 + minSdk = 28 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") diff --git a/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/ManifestBuilderTests.kt b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/ManifestBuilderTests.kt new file mode 100644 index 0000000..332b27f --- /dev/null +++ b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/ManifestBuilderTests.kt @@ -0,0 +1,280 @@ +package org.contentauth.c2pa.test.shared + +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.contentauth.c2pa.Builder +import org.contentauth.c2pa.ByteArrayStream +import org.contentauth.c2pa.C2PA +import org.contentauth.c2pa.C2PAError +import org.contentauth.c2pa.CallbackStream +import org.contentauth.c2pa.FileStream +import org.contentauth.c2pa.SeekMode +import org.contentauth.c2pa.Signer +import org.contentauth.c2pa.SignerInfo +import org.contentauth.c2pa.SigningAlgorithm +import org.contentauth.c2pa.Stream +import org.contentauth.c2pa.manifest.Action +import org.contentauth.c2pa.manifest.AttestationBuilder +import org.contentauth.c2pa.manifest.C2PAActions +import org.contentauth.c2pa.manifest.C2PAFormats +import org.contentauth.c2pa.manifest.ManifestHelpers +import org.contentauth.c2pa.manifest.Thumbnail +import org.json.JSONObject +import java.io.ByteArrayOutputStream +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +/** ManifestBuilderTests - Builder API tests for advanced manifest creation */ +abstract class ManifestBuilderTests : TestBase() { + + suspend fun testManifestBuilder (): TestResult = withContext(Dispatchers.IO) { + + runTest("Builder API") { + + // Basic image manifest + val manifestJson = getTestManifest("pexels_asadphoto_457882.jpg") + + + try { + val builder = Builder.fromJson(manifestJson) + try { + val sourceImageData = loadResourceAsBytes("pexels_asadphoto_457882") + val sourceStream = MemoryStream(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) + + try { + val result = builder.sign("image/jpeg", sourceStream.stream, destStream, signer) + + val manifest = C2PA.readFile(fileTest.absolutePath) + val json = if (manifest != null) JSONObject(manifest) else null + val success = json?.has("manifests") ?: false + + 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()) + } + } + + runTest("Builder No-Embed") { + + val manifestJson = getTestManifestAdvanced("pexels_asadphoto_457882.jpg") + + try { + val builder = Builder.fromJson(manifestJson) + try { + builder.setNoEmbed() + val archiveStream = ByteArrayStream() + try { + builder.toArchive(archiveStream) + val data = archiveStream.getData() + val success = data.isNotEmpty() + TestResult( + "Builder No-Embed", + success, + if (success) { + "Archive created successfully" + } else { + "Archive creation failed" + }, + "Archive size: ${data.size}", + ) + } finally { + archiveStream.close() + } + } finally { + builder.close() + } + } catch (e: C2PAError) { + TestResult( + "Builder No-Embed", + false, + "Failed to create builder", + e.toString(), + ) + } + } + } + + private fun getTestManifest(title: String): String { + // Basic image manifest + val manifestJson = ManifestHelpers.createBasicImageManifest( + title = title, + format = C2PAFormats.JPEG + ).claimGenerator("Android Test Suite","1.0.0") + .addAction(Action(C2PAActions.OPENED, whenTimestamp = getCurrentTimestamp(),"Android Test Suite")) + .addAssertion("c2pa.test","{\"test\": true}") + .buildJson() + + return manifestJson + } + + private fun getTestManifestAdvanced(title: String): String { + + // Basic image manifest + val manifestBuilder = ManifestHelpers.createBasicImageManifest( + title = title, + format = C2PAFormats.JPEG + ).claimGenerator("Android Test Suite","1.0.0") + .addAction(Action(C2PAActions.PLACED, whenTimestamp = getCurrentTimestamp(),"Android Test Suite")) + .addAssertion("c2pa.test","{\"test\": true}") + .addThumbnail(Thumbnail(C2PAFormats.JPEG,"${title}_thumb.jpg")) + + val attestationBuilder = AttestationBuilder() + + attestationBuilder.addCreativeWork { + addAuthor("Test Author") + dateCreated(Date()) + } + + attestationBuilder.addCreativeWork { + addAuthor("Test Author") + dateCreated(Date()) + } + + val locationJson = JSONObject().apply { + put("@type", "Place") + put("latitude", "0.0") + put("longitude", "0.0") + put("name", "Somewhere") + } + + attestationBuilder.addAssertionMetadata { + dateTime(getCurrentTimestamp()) + device("Test Device") + location(locationJson) + } + + val customAttestationJson = JSONObject().apply { + put("@type", "Integrity") + put("nonce", "something") + put("response", "b64encodedresponse") + } + + attestationBuilder.addCustomAttestation("app.integrity", customAttestationJson) + + attestationBuilder.addCAWGIdentity { + validFromNow() + addInstagramIdentity("photographer_john", "2024-10-08T18:04:08Z") + addLinkedInIdentity("John Smith", "https://www.linkedin.com/in/jsmith", "2024-10-08T18:03:41Z") + addBehanceIdentity("johnsmith_photos", "2024-10-22T19:31:17Z") + } + + attestationBuilder.buildForManifest(manifestBuilder) + + manifestBuilder.addAction(Action(C2PAActions.RECOMPRESSED, getCurrentTimestamp(), "Photo Resizer")) + + val resultJson = manifestBuilder.buildJson() + + Log.d("Test Manifest",resultJson) + + return resultJson + } + + private fun getCurrentTimestamp(): String { + val iso8601 = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + return iso8601.format(Date()) + } +} + +/** + * Memory stream implementation using CallbackStream + * Shared between instrumented tests and test app + */ +class MemoryStream { + private val buffer = ByteArrayOutputStream() + private var position = 0 + private var data: ByteArray + + val stream: Stream + + constructor() { + data = ByteArray(0) + stream = createStream() + } + + constructor(initialData: ByteArray) { + buffer.write(initialData) + data = buffer.toByteArray() + stream = createStream() + } + + private fun createStream(): Stream { + return CallbackStream( + reader = { buffer, length -> + if (position >= data.size) return@CallbackStream 0 + val toRead = minOf(length, data.size - position) + System.arraycopy(data, position, buffer, 0, toRead) + position += toRead + toRead + }, + seeker = { offset, mode -> + position = when (mode) { + SeekMode.START -> offset.toInt() + SeekMode.CURRENT -> position + offset.toInt() + SeekMode.END -> data.size + offset.toInt() + } + position = position.coerceIn(0, data.size) + position.toLong() + }, + writer = { writeData, length -> + if (position < data.size) { + // Writing in the middle - need to handle carefully + val newData = data.toMutableList() + for (i in 0 until length) { + 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, length) + data = buffer.toByteArray() + } + position += length + length + }, + flusher = { + data = buffer.toByteArray() + 0 + } + ) + } + + fun seek(offset: Long, mode: Int): Long = stream.seek(offset, mode) + fun close() = stream.close() + fun getData(): ByteArray = data +} From 2cc368e1df2ad36442f3b48c5c5956de03a77a24 Mon Sep 17 00:00:00 2001 From: n8fr8 Date: Thu, 30 Oct 2025 13:57:02 -0400 Subject: [PATCH 3/9] add support for SoftwareAgent and DigitalSourceType --- .../c2pa/manifest/AttestationBuilder.kt | 20 ++--- .../contentauth/c2pa/manifest/C2PAActions.kt | 13 ++- .../c2pa/manifest/ManifestBuilder.kt | 23 ++++-- .../c2pa/manifest/ManifestHelpers.kt | 81 +++++++++++++------ .../c2pa/test/shared/ManifestBuilderTests.kt | 14 +++- 5 files changed, 103 insertions(+), 48 deletions(-) diff --git a/library/src/main/kotlin/org/contentauth/c2pa/manifest/AttestationBuilder.kt b/library/src/main/kotlin/org/contentauth/c2pa/manifest/AttestationBuilder.kt index 5a0101b..4629a26 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/manifest/AttestationBuilder.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/manifest/AttestationBuilder.kt @@ -70,37 +70,37 @@ class ActionsAttestation : Attestation("c2pa.actions") { return this } - fun addCreatedAction(softwareAgent: String? = null, whenTimestamp: String? = null): ActionsAttestation { + fun addCreatedAction(softwareAgent: SoftwareAgent? = null, whenTimestamp: String? = null): ActionsAttestation { actionsList.add(Action(C2PAActions.CREATED, whenTimestamp, softwareAgent)) return this } - fun addEditedAction(softwareAgent: String? = null, whenTimestamp: String? = null, changes: List = emptyList()): ActionsAttestation { + fun addEditedAction(softwareAgent: SoftwareAgent? = null, whenTimestamp: String? = null, changes: List = emptyList()): ActionsAttestation { actionsList.add(Action(C2PAActions.EDITED, whenTimestamp, softwareAgent, changes)) return this } - fun addOpenedAction(softwareAgent: String? = null, whenTimestamp: String? = null): ActionsAttestation { + fun addOpenedAction(softwareAgent: SoftwareAgent? = null, whenTimestamp: String? = null): ActionsAttestation { actionsList.add(Action(C2PAActions.OPENED, whenTimestamp, softwareAgent)) return this } - fun addPlacedAction(softwareAgent: String? = null, whenTimestamp: String? = null): ActionsAttestation { + fun addPlacedAction(softwareAgent: SoftwareAgent? = null, whenTimestamp: String? = null): ActionsAttestation { actionsList.add(Action(C2PAActions.PLACED, whenTimestamp, softwareAgent)) return this } - fun addDrawingAction(softwareAgent: String? = null, whenTimestamp: String? = null): ActionsAttestation { + fun addDrawingAction(softwareAgent: SoftwareAgent? = null, whenTimestamp: String? = null): ActionsAttestation { actionsList.add(Action(C2PAActions.DRAWING, whenTimestamp, softwareAgent)) return this } - fun addColorAdjustmentsAction(softwareAgent: String? = null, whenTimestamp: String? = null, parameters: Map = emptyMap()): ActionsAttestation { + fun addColorAdjustmentsAction(softwareAgent: SoftwareAgent? = null, whenTimestamp: String? = null, parameters: Map = emptyMap()): ActionsAttestation { actionsList.add(Action(C2PAActions.COLOR_ADJUSTMENTS, whenTimestamp, softwareAgent, emptyList(), null, parameters)) return this } - fun addResizedAction(softwareAgent: String? = null, whenTimestamp: String? = null): ActionsAttestation { + fun addResizedAction(softwareAgent: SoftwareAgent? = null, whenTimestamp: String? = null): ActionsAttestation { actionsList.add(Action(C2PAActions.RESIZED, whenTimestamp, softwareAgent)) return this } @@ -114,7 +114,7 @@ class ActionsAttestation : Attestation("c2pa.actions") { action.whenTimestamp?.let { put("when", it) } action.softwareAgent?.let { put("softwareAgent", it) } action.reason?.let { put("reason", it) } - + if (action.changes.isNotEmpty()) { put("changes", JSONArray().apply { action.changes.forEach { change -> @@ -125,7 +125,7 @@ class ActionsAttestation : Attestation("c2pa.actions") { } }) } - + if (action.parameters.isNotEmpty()) { val params = JSONObject() action.parameters.forEach { (key, value) -> @@ -507,4 +507,4 @@ class AttestationBuilder { } return manifestBuilder } -} \ No newline at end of file +} diff --git a/library/src/main/kotlin/org/contentauth/c2pa/manifest/C2PAActions.kt b/library/src/main/kotlin/org/contentauth/c2pa/manifest/C2PAActions.kt index cb1d88c..3e03e34 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/manifest/C2PAActions.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/manifest/C2PAActions.kt @@ -99,4 +99,15 @@ object TimestampAuthorities { const val SECTIGO = "http://timestamp.sectigo.com" const val GLOBALSIGN = "http://timestamp.globalsign.com/tsa/r6advanced1" const val ENTRUST = "http://timestamp.entrust.net/TSS/RFC3161sha2TS" -} \ No newline at end of file +} + +//as defined https://cv.iptc.org/newscodes/digitalsourcetype/ +object DigitalSourceTypes { + const val DIGITAL_CAPTURE = "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture" + const val COMPUTATIONAL_CAPTURE = "http://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + const val TRAINED_ALGORITHMIC_MEDIA = "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia" + const val COMPOSITE_CAPTURE = "http://cv.iptc.org/newscodes/digitalsourcetype/compositeCapture" + const val SCREEN_CAPTURE = "http://cv.iptc.org/newscodes/digitalsourcetype/screenCapture" + const val HUMAN_EDITS = "http://cv.iptc.org/newscodes/digitalsourcetype/humanEdits" + const val AI_EDITS = "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia" +} diff --git a/library/src/main/kotlin/org/contentauth/c2pa/manifest/ManifestBuilder.kt b/library/src/main/kotlin/org/contentauth/c2pa/manifest/ManifestBuilder.kt index b06042e..5ed9045 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/manifest/ManifestBuilder.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/manifest/ManifestBuilder.kt @@ -31,10 +31,17 @@ data class Thumbnail( data class Action( val action: String, val whenTimestamp: String? = null, - val softwareAgent: String? = null, + val softwareAgent: SoftwareAgent? = null, val changes: List = emptyList(), val reason: String? = null, - val parameters: Map = emptyMap() + val parameters: Map = emptyMap(), + val digitalSourceType: String? = null +) + +data class SoftwareAgent( + val name: String, + val version: String, + val operatingSystem: String ) data class ActionChange( @@ -115,7 +122,7 @@ class ManifestBuilder { // Add claim version manifest.put("claim_version", 1) - + // Add timestamp authority URL if present taUrl?.let { manifest.put("ta_url", it) } @@ -157,11 +164,11 @@ class ManifestBuilder { ingredient.provenance?.let { put("provenance", it) } ingredient.hash?.let { put("hash", it) } put("relationship", ingredient.relationship) - + if (ingredient.validationStatus.isNotEmpty()) { put("validationStatus", JSONArray(ingredient.validationStatus)) } - + ingredient.thumbnail?.let { thumb -> put("thumbnail", JSONObject().apply { put("format", thumb.format) @@ -188,7 +195,7 @@ class ManifestBuilder { action.whenTimestamp?.let { put("when", it) } action.softwareAgent?.let { put("softwareAgent", it) } action.reason?.let { put("reason", it) } - + if (action.changes.isNotEmpty()) { put("changes", JSONArray().apply { action.changes.forEach { change -> @@ -199,7 +206,7 @@ class ManifestBuilder { } }) } - + if (action.parameters.isNotEmpty()) { val params = JSONObject() action.parameters.forEach { (key, value) -> @@ -240,4 +247,4 @@ class ManifestBuilder { fun buildJson(): String { return build().toString(2) } -} \ No newline at end of file +} diff --git a/library/src/main/kotlin/org/contentauth/c2pa/manifest/ManifestHelpers.kt b/library/src/main/kotlin/org/contentauth/c2pa/manifest/ManifestHelpers.kt index 749365e..1af48dd 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/manifest/ManifestHelpers.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/manifest/ManifestHelpers.kt @@ -5,7 +5,7 @@ import java.text.SimpleDateFormat import java.util.* object ManifestHelpers { - + fun createBasicImageManifest( title: String, format: String = C2PAFormats.JPEG, @@ -17,7 +17,7 @@ object ManifestHelpers { .title(title) .format(format) .claimGenerator(claimGeneratorName, claimGeneratorVersion) - + timestampAuthorityUrl?.let { builder.timestampAuthorityUrl(it) } return builder } @@ -27,7 +27,7 @@ object ManifestHelpers { originalIngredientTitle: String, originalFormat: String = C2PAFormats.JPEG, format: String = C2PAFormats.JPEG, - softwareAgent: String? = null, + softwareAgent: SoftwareAgent? = null, claimGeneratorName: String = "Android C2PA SDK", claimGeneratorVersion: String = "1.0.0" ): ManifestBuilder { @@ -36,7 +36,7 @@ object ManifestHelpers { format = originalFormat, relationship = C2PARelationships.PARENT_OF ) - + return ManifestBuilder() .title(title) .format(format) @@ -58,6 +58,9 @@ object ManifestHelpers { claimGeneratorName: String = "Android C2PA SDK", claimGeneratorVersion: String = "1.0.0" ): ManifestBuilder { + + var softwareAgent = SoftwareAgent(deviceName!!, android.os.Build.VERSION.CODENAME, android.os.Build.VERSION.BASE_OS) + val builder = ManifestBuilder() .title(title) .format(format) @@ -65,25 +68,26 @@ object ManifestHelpers { .addAction(Action( action = C2PAActions.CREATED, whenTimestamp = getCurrentTimestamp(), - softwareAgent = deviceName + softwareAgent = softwareAgent, + digitalSourceType = DigitalSourceTypes.DIGITAL_CAPTURE )) if (authorName != null || deviceName != null || location != null) { val attestationBuilder = AttestationBuilder() - + if (authorName != null) { attestationBuilder.addCreativeWork { addAuthor(authorName) dateCreated(Date()) } } - + attestationBuilder.addAssertionMetadata { dateTime(getCurrentTimestamp()) deviceName?.let { device(it) } location?.let { location(it) } } - + attestationBuilder.buildForManifest(builder) } @@ -105,7 +109,7 @@ object ManifestHelpers { format = originalFormat, relationship = C2PARelationships.PARENT_OF ) - + val builder = ManifestBuilder() .title(title) .format(format) @@ -113,18 +117,20 @@ object ManifestHelpers { .addIngredient(ingredient) val timestamp = getCurrentTimestamp() - + var softwareAgent = SoftwareAgent(claimGeneratorName, android.os.Build.VERSION.CODENAME, android.os.Build.VERSION.BASE_OS) + builder.addAction(Action( action = C2PAActions.OPENED, whenTimestamp = timestamp, - softwareAgent = editingSoftware + softwareAgent = softwareAgent, + digitalSourceType = DigitalSourceTypes.COMPOSITE_CAPTURE )) editActions.forEach { actionType -> builder.addAction(Action( action = actionType, whenTimestamp = timestamp, - softwareAgent = editingSoftware + softwareAgent = softwareAgent )) } @@ -139,6 +145,9 @@ object ManifestHelpers { claimGeneratorName: String = "Android C2PA SDK", claimGeneratorVersion: String = "1.0.0" ): ManifestBuilder { + + var softwareAgent = SoftwareAgent(claimGeneratorName, android.os.Build.VERSION.CODENAME, android.os.Build.VERSION.BASE_OS) + val builder = ManifestBuilder() .title(title) .format(format) @@ -146,7 +155,8 @@ object ManifestHelpers { .addAction(Action( action = C2PAActions.CREATED, whenTimestamp = getCurrentTimestamp(), - softwareAgent = compositingSoftware + softwareAgent = softwareAgent, + digitalSourceType = DigitalSourceTypes.COMPOSITE_CAPTURE )) ingredients.forEach { ingredient -> @@ -154,7 +164,8 @@ object ManifestHelpers { builder.addAction(Action( action = C2PAActions.PLACED, whenTimestamp = getCurrentTimestamp(), - softwareAgent = compositingSoftware + softwareAgent = softwareAgent, + digitalSourceType = DigitalSourceTypes.COMPOSITE_CAPTURE )) } @@ -168,6 +179,9 @@ object ManifestHelpers { claimGeneratorName: String = "Android C2PA SDK", claimGeneratorVersion: String = "1.0.0" ): ManifestBuilder { + + var softwareAgent = SoftwareAgent(claimGeneratorName, android.os.Build.VERSION.CODENAME, android.os.Build.VERSION.BASE_OS) + val builder = ManifestBuilder() .title("Screenshot") .format(format) @@ -176,7 +190,8 @@ object ManifestHelpers { .addAction(Action( action = C2PAActions.CREATED, whenTimestamp = getCurrentTimestamp(), - softwareAgent = appName ?: "Screenshot" + softwareAgent = softwareAgent, + digitalSourceType = DigitalSourceTypes.SCREEN_CAPTURE )) val attestationBuilder = AttestationBuilder() @@ -186,7 +201,7 @@ object ManifestHelpers { addMetadata("capture_method", "screenshot") appName?.let { addMetadata("source_application", it) } } - + attestationBuilder.buildForManifest(builder) return builder } @@ -204,7 +219,10 @@ object ManifestHelpers { format = originalFormat, relationship = C2PARelationships.PARENT_OF ) - + + var softwareAgent = SoftwareAgent(claimGeneratorName, android.os.Build.VERSION.CODENAME, android.os.Build.VERSION.BASE_OS) + + return ManifestBuilder() .title("Shared on $platform") .format(format) @@ -213,13 +231,14 @@ object ManifestHelpers { .addAction(Action( action = C2PAActions.RECOMPRESSED, whenTimestamp = getCurrentTimestamp(), - softwareAgent = platform, - reason = "Social media optimization" + softwareAgent = softwareAgent, + reason = "Social media optimization", + digitalSourceType = DigitalSourceTypes.HUMAN_EDITS )) .addAction(Action( action = C2PAActions.PUBLISHED, whenTimestamp = getCurrentTimestamp(), - softwareAgent = platform + softwareAgent = softwareAgent )) } @@ -232,17 +251,20 @@ object ManifestHelpers { claimGeneratorName: String = "Android C2PA SDK", claimGeneratorVersion: String = "1.0.0" ): ManifestBuilder { + + var softwareAgent = SoftwareAgent(claimGeneratorName, android.os.Build.VERSION.CODENAME, android.os.Build.VERSION.BASE_OS) + val ingredient = Ingredient( title = originalTitle, format = originalFormat, relationship = C2PARelationships.PARENT_OF ) - + val filterParameters = mapOf( "filter_name" to filterName, "filter_type" to "digital_filter" ) - + return ManifestBuilder() .title("$originalTitle (Filtered)") .format(format) @@ -251,7 +273,7 @@ object ManifestHelpers { .addAction(Action( action = C2PAActions.FILTERED, whenTimestamp = getCurrentTimestamp(), - softwareAgent = appName, + softwareAgent = softwareAgent, parameters = filterParameters )) } @@ -311,6 +333,9 @@ object ManifestHelpers { claimGeneratorName: String = "Android C2PA SDK", claimGeneratorVersion: String = "1.0.0" ): ManifestBuilder { + + var softwareAgent = SoftwareAgent(claimGeneratorName, android.os.Build.VERSION.CODENAME, android.os.Build.VERSION.BASE_OS) + val builder = ManifestBuilder() .title(title) .format(format) @@ -318,7 +343,7 @@ object ManifestHelpers { .addAction(Action( action = C2PAActions.CREATED, whenTimestamp = getCurrentTimestamp(), - softwareAgent = deviceName + softwareAgent = softwareAgent )) val attestationBuilder = AttestationBuilder() @@ -367,6 +392,9 @@ object ManifestHelpers { claimGeneratorName: String = "Android C2PA SDK", claimGeneratorVersion: String = "1.0.0" ): ManifestBuilder { + + var softwareAgent = SoftwareAgent(claimGeneratorName, android.os.Build.VERSION.CODENAME, android.os.Build.VERSION.BASE_OS) + val builder = ManifestBuilder() .title(title) .format(format) @@ -374,7 +402,8 @@ object ManifestHelpers { .addAction(Action( action = C2PAActions.CREATED, whenTimestamp = getCurrentTimestamp(), - softwareAgent = platform + softwareAgent = softwareAgent, + digitalSourceType = DigitalSourceTypes.DIGITAL_CAPTURE )) val attestationBuilder = AttestationBuilder() @@ -398,4 +427,4 @@ object ManifestHelpers { attestationBuilder.buildForManifest(builder) return builder } -} \ No newline at end of file +} diff --git a/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/ManifestBuilderTests.kt b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/ManifestBuilderTests.kt index 332b27f..3a04c3b 100644 --- a/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/ManifestBuilderTests.kt +++ b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/ManifestBuilderTests.kt @@ -18,7 +18,9 @@ import org.contentauth.c2pa.manifest.Action import org.contentauth.c2pa.manifest.AttestationBuilder import org.contentauth.c2pa.manifest.C2PAActions import org.contentauth.c2pa.manifest.C2PAFormats +import org.contentauth.c2pa.manifest.DigitalSourceTypes import org.contentauth.c2pa.manifest.ManifestHelpers +import org.contentauth.c2pa.manifest.SoftwareAgent import org.contentauth.c2pa.manifest.Thumbnail import org.json.JSONObject import java.io.ByteArrayOutputStream @@ -123,12 +125,16 @@ abstract class ManifestBuilderTests : TestBase() { } private fun getTestManifest(title: String): String { + + + var softwareAgent = SoftwareAgent("Android Test Suite", android.os.Build.VERSION.CODENAME, android.os.Build.VERSION.BASE_OS) + // Basic image manifest val manifestJson = ManifestHelpers.createBasicImageManifest( title = title, format = C2PAFormats.JPEG ).claimGenerator("Android Test Suite","1.0.0") - .addAction(Action(C2PAActions.OPENED, whenTimestamp = getCurrentTimestamp(),"Android Test Suite")) + .addAction(Action(C2PAActions.OPENED, whenTimestamp = getCurrentTimestamp(),softwareAgent)) .addAssertion("c2pa.test","{\"test\": true}") .buildJson() @@ -137,12 +143,14 @@ abstract class ManifestBuilderTests : TestBase() { private fun getTestManifestAdvanced(title: String): String { + var softwareAgent = SoftwareAgent("Android Test Suite", android.os.Build.VERSION.CODENAME, android.os.Build.VERSION.BASE_OS) + // Basic image manifest val manifestBuilder = ManifestHelpers.createBasicImageManifest( title = title, format = C2PAFormats.JPEG ).claimGenerator("Android Test Suite","1.0.0") - .addAction(Action(C2PAActions.PLACED, whenTimestamp = getCurrentTimestamp(),"Android Test Suite")) + .addAction(Action(C2PAActions.PLACED, whenTimestamp = getCurrentTimestamp(),softwareAgent, digitalSourceType = DigitalSourceTypes.DIGITAL_CAPTURE)) .addAssertion("c2pa.test","{\"test\": true}") .addThumbnail(Thumbnail(C2PAFormats.JPEG,"${title}_thumb.jpg")) @@ -188,7 +196,7 @@ abstract class ManifestBuilderTests : TestBase() { attestationBuilder.buildForManifest(manifestBuilder) - manifestBuilder.addAction(Action(C2PAActions.RECOMPRESSED, getCurrentTimestamp(), "Photo Resizer")) + manifestBuilder.addAction(Action(C2PAActions.RECOMPRESSED, getCurrentTimestamp(), softwareAgent)) val resultJson = manifestBuilder.buildJson() From f73f171bb023a5dd75ae20f31285fd26e53c1b09 Mon Sep 17 00:00:00 2001 From: n8fr8 Date: Wed, 5 Nov 2025 11:27:09 -0500 Subject: [PATCH 4/9] add new JSON generation for softwareAgent and digitalSourceType --- .../org/contentauth/c2pa/manifest/ManifestBuilder.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/library/src/main/kotlin/org/contentauth/c2pa/manifest/ManifestBuilder.kt b/library/src/main/kotlin/org/contentauth/c2pa/manifest/ManifestBuilder.kt index 5ed9045..0b2102b 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/manifest/ManifestBuilder.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/manifest/ManifestBuilder.kt @@ -193,8 +193,18 @@ class ManifestBuilder { put(JSONObject().apply { put("action", action.action) action.whenTimestamp?.let { put("when", it) } + action.softwareAgent?.let { put("softwareAgent", it) } + action.softwareAgent?.let { + put("softwareAgent", JSONObject().apply { + action.softwareAgent?.name.let { put("name", action.softwareAgent?.name) } + action.softwareAgent?.operatingSystem.let { put("operating_system", action.softwareAgent?.operatingSystem) } + action.softwareAgent?.version.let { put("version", action.softwareAgent?.version) } + }) + } + action.reason?.let { put("reason", it) } + action.digitalSourceType?.let { put("digitalSourceType", it) } if (action.changes.isNotEmpty()) { put("changes", JSONArray().apply { From 29bd48dca0e712dcf552aa1865c5563762f0a26c Mon Sep 17 00:00:00 2001 From: n8fr8 Date: Thu, 11 Dec 2025 08:27:01 -0500 Subject: [PATCH 5/9] refact to improve code using libs toml --- gradle/libs.versions.toml | 20 ++++++++++++++++++++ library/build.gradle.kts | 33 ++++++++++++++++----------------- 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 838772a..ec67e0f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,16 +1,32 @@ [versions] agp = "8.13.0" +appcompat = "1.7.1" +bcprovJdk15to18 = "1.81" +biometric = "1.1.0" +jna = "5.17.0" kotlin = "2.2.10" coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" +kotlinTestJunit = "2.2.10" +kotlinxCoroutinesAndroid = "1.10.2" +kotlinxSerializationJson = "1.9.0" lifecycleRuntimeKtx = "2.9.3" activityCompose = "1.10.1" composeBom = "2025.08.01" +okhttp = "5.1.0" +runner = "1.7.0" [libraries] +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } +androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-rules = { module = "androidx.test:rules", version.ref = "runner" } +androidx-runner = { module = "androidx.test:runner", version.ref = "runner" } +bcpkix-jdk15to18 = { module = "org.bouncycastle:bcpkix-jdk15to18", version.ref = "bcprovJdk15to18" } +bcprov-jdk15to18 = { module = "org.bouncycastle:bcprov-jdk15to18", version.ref = "bcprovJdk15to18" } +jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } @@ -24,6 +40,10 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest", version = "1.9.0" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlinTestJunit" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/library/build.gradle.kts b/library/build.gradle.kts index dc82d33..2bf5eae 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -80,28 +80,27 @@ android { base { archivesName.set("c2pa") } dependencies { - implementation("androidx.core:core-ktx:1.17.0") - implementation("androidx.appcompat:appcompat:1.7.1") - implementation("com.google.android.material:material:1.13.0") - implementation("androidx.biometric:biometric:1.1.0") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") - implementation("com.squareup.okhttp3:okhttp:5.1.0") - implementation("net.java.dev.jna:jna:5.17.0@aar") + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.biometric) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.serialization.json) + implementation(libs.okhttp) + implementation(libs.jna) // BouncyCastle for CSR generation - implementation("org.bouncycastle:bcprov-jdk18on:1.81") - implementation("org.bouncycastle:bcpkix-jdk18on:1.81") + implementation(libs.bcprov.jdk15to18) + implementation(libs.bcpkix.jdk15to18) - testImplementation("junit:junit:4.13.2") - testImplementation("org.jetbrains.kotlin:kotlin-test-junit:2.2.10") + testImplementation(libs.junit) + testImplementation(libs.kotlin.test.junit) androidTestImplementation(project(":test-shared")) - androidTestImplementation("androidx.test.ext:junit:1.3.0") - androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0") - androidTestImplementation("androidx.test:runner:1.7.0") - androidTestImplementation("androidx.test:rules:1.7.0") - androidTestImplementation("org.jetbrains.kotlin:kotlin-test-junit:2.2.10") + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.runner) + androidTestImplementation(libs.androidx.rules) + androidTestImplementation(libs.kotlin.test.junit) } // JaCoCo configuration From 28c0d89ed9d9e62c93f0db5dff626ac063f3a09d Mon Sep 17 00:00:00 2001 From: n8fr8 Date: Thu, 11 Dec 2025 14:59:06 -0500 Subject: [PATCH 6/9] improve tests and code coverage for ManifestBuilder and package --- .../AndroidAttestationBuilderUnitTests.kt | 454 ++++++ .../c2pa/AndroidManifestBuilderUnitTests.kt | 287 ++++ .../c2pa/AndroidManifestHelpersUnitTests.kt | 272 ++++ .../shared/AttestationBuilderUnitTests.kt | 1393 +++++++++++++++++ .../test/shared/ManifestBuilderUnitTests.kt | 1021 ++++++++++++ .../test/shared/ManifestHelpersUnitTests.kt | 1015 ++++++++++++ 6 files changed, 4442 insertions(+) create mode 100644 library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidAttestationBuilderUnitTests.kt create mode 100644 library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidManifestBuilderUnitTests.kt create mode 100644 library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidManifestHelpersUnitTests.kt create mode 100644 test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/AttestationBuilderUnitTests.kt create mode 100644 test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/ManifestBuilderUnitTests.kt create mode 100644 test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/ManifestHelpersUnitTests.kt diff --git a/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidAttestationBuilderUnitTests.kt b/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidAttestationBuilderUnitTests.kt new file mode 100644 index 0000000..bfb1c7f --- /dev/null +++ b/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidAttestationBuilderUnitTests.kt @@ -0,0 +1,454 @@ +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.AttestationBuilderUnitTests +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import kotlin.test.assertTrue + +/** + * Android instrumented tests for AttestationBuilder and all attestation types. + * These tests cover all attestation classes and their JSON output. + */ +@RunWith(AndroidJUnit4::class) +class AndroidAttestationBuilderUnitTests : AttestationBuilderUnitTests() { + + 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) + + // Data Class Tests + @Test + fun runTestVerifiedIdentityDataClass() = runBlocking { + val result = testVerifiedIdentityDataClass() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestIdentityProviderDataClass() = runBlocking { + val result = testIdentityProviderDataClass() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCredentialSchemaDataClass() = runBlocking { + val result = testCredentialSchemaDataClass() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCredentialSchemaDefaults() = runBlocking { + val result = testCredentialSchemaDefaults() + assertTrue(result.success, "Test failed: ${result.message}") + } + + // CreativeWorkAttestation Tests + @Test + fun runTestCreativeWorkAttestationType() = runBlocking { + val result = testCreativeWorkAttestationType() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCreativeWorkAddAuthor() = runBlocking { + val result = testCreativeWorkAddAuthor() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCreativeWorkAddAuthorWithCredential() = runBlocking { + val result = testCreativeWorkAddAuthorWithCredential() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCreativeWorkAddAuthorWithIdentifier() = runBlocking { + val result = testCreativeWorkAddAuthorWithIdentifier() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCreativeWorkMultipleAuthors() = runBlocking { + val result = testCreativeWorkMultipleAuthors() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCreativeWorkDateCreatedWithDate() = runBlocking { + val result = testCreativeWorkDateCreatedWithDate() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCreativeWorkDateCreatedWithString() = runBlocking { + val result = testCreativeWorkDateCreatedWithString() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCreativeWorkReviewStatus() = runBlocking { + val result = testCreativeWorkReviewStatus() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCreativeWorkEmptyOutput() = runBlocking { + val result = testCreativeWorkEmptyOutput() + assertTrue(result.success, "Test failed: ${result.message}") + } + + // ActionsAttestation Tests + @Test + fun runTestActionsAttestationType() = runBlocking { + val result = testActionsAttestationType() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestActionsAttestationAddAction() = runBlocking { + val result = testActionsAttestationAddAction() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestActionsAttestationAddCreatedAction() = runBlocking { + val result = testActionsAttestationAddCreatedAction() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestActionsAttestationAddEditedAction() = runBlocking { + val result = testActionsAttestationAddEditedAction() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestActionsAttestationAddOpenedAction() = runBlocking { + val result = testActionsAttestationAddOpenedAction() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestActionsAttestationAddPlacedAction() = runBlocking { + val result = testActionsAttestationAddPlacedAction() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestActionsAttestationAddDrawingAction() = runBlocking { + val result = testActionsAttestationAddDrawingAction() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestActionsAttestationAddColorAdjustmentsAction() = runBlocking { + val result = testActionsAttestationAddColorAdjustmentsAction() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestActionsAttestationAddResizedAction() = runBlocking { + val result = testActionsAttestationAddResizedAction() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestActionsAttestationMultipleActions() = runBlocking { + val result = testActionsAttestationMultipleActions() + assertTrue(result.success, "Test failed: ${result.message}") + } + + // AssertionMetadataAttestation Tests + @Test + fun runTestAssertionMetadataType() = runBlocking { + val result = testAssertionMetadataType() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestAssertionMetadataAddMetadata() = runBlocking { + val result = testAssertionMetadataAddMetadata() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestAssertionMetadataDateTime() = runBlocking { + val result = testAssertionMetadataDateTime() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestAssertionMetadataDevice() = runBlocking { + val result = testAssertionMetadataDevice() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestAssertionMetadataLocation() = runBlocking { + val result = testAssertionMetadataLocation() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestAssertionMetadataMultipleFields() = runBlocking { + val result = testAssertionMetadataMultipleFields() + assertTrue(result.success, "Test failed: ${result.message}") + } + + // ThumbnailAttestation Tests + @Test + fun runTestThumbnailAttestationType() = runBlocking { + val result = testThumbnailAttestationType() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestThumbnailAttestationFormat() = runBlocking { + val result = testThumbnailAttestationFormat() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestThumbnailAttestationIdentifier() = runBlocking { + val result = testThumbnailAttestationIdentifier() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestThumbnailAttestationContentType() = runBlocking { + val result = testThumbnailAttestationContentType() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestThumbnailAttestationDefaultContentType() = runBlocking { + val result = testThumbnailAttestationDefaultContentType() + assertTrue(result.success, "Test failed: ${result.message}") + } + + // DataHashAttestation Tests + @Test + fun runTestDataHashAttestationType() = runBlocking { + val result = testDataHashAttestationType() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestDataHashAttestationName() = runBlocking { + val result = testDataHashAttestationName() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestDataHashAttestationDefaultName() = runBlocking { + val result = testDataHashAttestationDefaultName() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestDataHashAttestationPad() = runBlocking { + val result = testDataHashAttestationPad() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestDataHashAttestationExclusions() = runBlocking { + val result = testDataHashAttestationExclusions() + assertTrue(result.success, "Test failed: ${result.message}") + } + + // CAWGIdentityAttestation Tests + @Test + fun runTestCAWGIdentityType() = runBlocking { + val result = testCAWGIdentityType() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCAWGIdentityDefaultContexts() = runBlocking { + val result = testCAWGIdentityDefaultContexts() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCAWGIdentityDefaultTypes() = runBlocking { + val result = testCAWGIdentityDefaultTypes() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCAWGIdentityDefaultIssuer() = runBlocking { + val result = testCAWGIdentityDefaultIssuer() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCAWGIdentityIssuer() = runBlocking { + val result = testCAWGIdentityIssuer() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCAWGIdentityValidFrom() = runBlocking { + val result = testCAWGIdentityValidFrom() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCAWGIdentityValidFromNow() = runBlocking { + val result = testCAWGIdentityValidFromNow() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCAWGIdentityAddContext() = runBlocking { + val result = testCAWGIdentityAddContext() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCAWGIdentityAddContextNoDuplicate() = runBlocking { + val result = testCAWGIdentityAddContextNoDuplicate() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCAWGIdentityAddType() = runBlocking { + val result = testCAWGIdentityAddType() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCAWGIdentityAddVerifiedIdentity() = runBlocking { + val result = testCAWGIdentityAddVerifiedIdentity() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCAWGIdentityAddInstagramIdentity() = runBlocking { + val result = testCAWGIdentityAddInstagramIdentity() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCAWGIdentityAddTwitterIdentity() = runBlocking { + val result = testCAWGIdentityAddTwitterIdentity() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCAWGIdentityAddLinkedInIdentity() = runBlocking { + val result = testCAWGIdentityAddLinkedInIdentity() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCAWGIdentityAddBehanceIdentity() = runBlocking { + val result = testCAWGIdentityAddBehanceIdentity() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCAWGIdentityAddYouTubeIdentity() = runBlocking { + val result = testCAWGIdentityAddYouTubeIdentity() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCAWGIdentityAddGitHubIdentity() = runBlocking { + val result = testCAWGIdentityAddGitHubIdentity() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCAWGIdentityAddCredentialSchema() = runBlocking { + val result = testCAWGIdentityAddCredentialSchema() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCAWGIdentityDefaultCredentialSchema() = runBlocking { + val result = testCAWGIdentityDefaultCredentialSchema() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCAWGIdentityMultipleIdentities() = runBlocking { + val result = testCAWGIdentityMultipleIdentities() + assertTrue(result.success, "Test failed: ${result.message}") + } + + // AttestationBuilder Tests + @Test + fun runTestAttestationBuilderAddCreativeWork() = runBlocking { + val result = testAttestationBuilderAddCreativeWork() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestAttestationBuilderAddActions() = runBlocking { + val result = testAttestationBuilderAddActions() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestAttestationBuilderAddAssertionMetadata() = runBlocking { + val result = testAttestationBuilderAddAssertionMetadata() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestAttestationBuilderAddThumbnail() = runBlocking { + val result = testAttestationBuilderAddThumbnail() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestAttestationBuilderAddDataHash() = runBlocking { + val result = testAttestationBuilderAddDataHash() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestAttestationBuilderAddCAWGIdentity() = runBlocking { + val result = testAttestationBuilderAddCAWGIdentity() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestAttestationBuilderAddCustomAttestation() = runBlocking { + val result = testAttestationBuilderAddCustomAttestation() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestAttestationBuilderBuildForManifest() = runBlocking { + val result = testAttestationBuilderBuildForManifest() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestAttestationBuilderMultipleAttestations() = runBlocking { + val result = testAttestationBuilderMultipleAttestations() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestAttestationBuilderChaining() = runBlocking { + val result = testAttestationBuilderChaining() + assertTrue(result.success, "Test failed: ${result.message}") + } +} diff --git a/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidManifestBuilderUnitTests.kt b/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidManifestBuilderUnitTests.kt new file mode 100644 index 0000000..010fcf6 --- /dev/null +++ b/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidManifestBuilderUnitTests.kt @@ -0,0 +1,287 @@ +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.ManifestBuilderUnitTests +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import kotlin.test.assertTrue + +/** + * Android instrumented tests for ManifestBuilder class. + * These tests cover all ManifestBuilder methods and data classes. + */ +@RunWith(AndroidJUnit4::class) +class AndroidManifestBuilderUnitTests : ManifestBuilderUnitTests() { + + 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) + + // Data Class Tests + @Test + fun runTestClaimGeneratorDataClass() = runBlocking { + val result = testClaimGeneratorDataClass() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestClaimGeneratorWithoutIcon() = runBlocking { + val result = testClaimGeneratorWithoutIcon() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestIngredientDataClass() = runBlocking { + val result = testIngredientDataClass() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestIngredientDefaults() = runBlocking { + val result = testIngredientDefaults() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestThumbnailDataClass() = runBlocking { + val result = testThumbnailDataClass() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestThumbnailDefaultContentType() = runBlocking { + val result = testThumbnailDefaultContentType() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestActionDataClass() = runBlocking { + val result = testActionDataClass() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestActionDefaults() = runBlocking { + val result = testActionDefaults() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestSoftwareAgentDataClass() = runBlocking { + val result = testSoftwareAgentDataClass() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestActionChangeDataClass() = runBlocking { + val result = testActionChangeDataClass() + assertTrue(result.success, "Test failed: ${result.message}") + } + + // ManifestBuilder Method Tests + @Test + fun runTestManifestBuilderChaining() = runBlocking { + val result = testManifestBuilderChaining() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestManifestBuilderClaimGenerator() = runBlocking { + val result = testManifestBuilderClaimGenerator() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestManifestBuilderClaimGeneratorWithoutIcon() = runBlocking { + val result = testManifestBuilderClaimGeneratorWithoutIcon() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestManifestBuilderFormat() = runBlocking { + val result = testManifestBuilderFormat() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestManifestBuilderTitle() = runBlocking { + val result = testManifestBuilderTitle() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestManifestBuilderInstanceId() = runBlocking { + val result = testManifestBuilderInstanceId() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestManifestBuilderAutoInstanceId() = runBlocking { + val result = testManifestBuilderAutoInstanceId() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestManifestBuilderDocumentId() = runBlocking { + val result = testManifestBuilderDocumentId() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestManifestBuilderProducer() = runBlocking { + val result = testManifestBuilderProducer() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestManifestBuilderTimestampAuthority() = runBlocking { + val result = testManifestBuilderTimestampAuthority() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestManifestBuilderAddIngredient() = runBlocking { + val result = testManifestBuilderAddIngredient() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestManifestBuilderMultipleIngredients() = runBlocking { + val result = testManifestBuilderMultipleIngredients() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestManifestBuilderIngredientWithThumbnail() = runBlocking { + val result = testManifestBuilderIngredientWithThumbnail() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestManifestBuilderIngredientWithValidationStatus() = runBlocking { + val result = testManifestBuilderIngredientWithValidationStatus() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestManifestBuilderAddAction() = runBlocking { + val result = testManifestBuilderAddAction() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestManifestBuilderActionWithSoftwareAgent() = runBlocking { + val result = testManifestBuilderActionWithSoftwareAgent() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestManifestBuilderActionWithChanges() = runBlocking { + val result = testManifestBuilderActionWithChanges() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestManifestBuilderActionWithParameters() = runBlocking { + val result = testManifestBuilderActionWithParameters() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestManifestBuilderActionWithDigitalSourceType() = runBlocking { + val result = testManifestBuilderActionWithDigitalSourceType() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestManifestBuilderActionWithReason() = runBlocking { + val result = testManifestBuilderActionWithReason() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestManifestBuilderMultipleActions() = runBlocking { + val result = testManifestBuilderMultipleActions() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestManifestBuilderAddThumbnail() = runBlocking { + val result = testManifestBuilderAddThumbnail() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestManifestBuilderAddAssertionWithJsonObject() = runBlocking { + val result = testManifestBuilderAddAssertionWithJsonObject() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestManifestBuilderAddAssertionWithString() = runBlocking { + val result = testManifestBuilderAddAssertionWithString() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestManifestBuilderAddAssertionWithNumber() = runBlocking { + val result = testManifestBuilderAddAssertionWithNumber() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestManifestBuilderAddAssertionWithBoolean() = runBlocking { + val result = testManifestBuilderAddAssertionWithBoolean() + assertTrue(result.success, "Test failed: ${result.message}") + } + + // Build Output Tests + @Test + fun runTestManifestBuilderBuildClaimVersion() = runBlocking { + val result = testManifestBuilderBuildClaimVersion() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestManifestBuilderBuildJson() = runBlocking { + val result = testManifestBuilderBuildJson() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestManifestBuilderEmptyAssertions() = runBlocking { + val result = testManifestBuilderEmptyAssertions() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestManifestBuilderEmptyIngredients() = runBlocking { + val result = testManifestBuilderEmptyIngredients() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestManifestBuilderOptionalFieldsAbsent() = runBlocking { + val result = testManifestBuilderOptionalFieldsAbsent() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestManifestBuilderCompleteManifest() = runBlocking { + val result = testManifestBuilderCompleteManifest() + assertTrue(result.success, "Test failed: ${result.message}") + } +} diff --git a/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidManifestHelpersUnitTests.kt b/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidManifestHelpersUnitTests.kt new file mode 100644 index 0000000..9feec39 --- /dev/null +++ b/library/src/androidTest/kotlin/org/contentauth/c2pa/AndroidManifestHelpersUnitTests.kt @@ -0,0 +1,272 @@ +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.ManifestHelpersUnitTests +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import kotlin.test.assertTrue + +/** + * Android instrumented tests for ManifestHelpers factory methods. + * These tests cover all helper functions for creating manifest builders. + */ +@RunWith(AndroidJUnit4::class) +class AndroidManifestHelpersUnitTests : ManifestHelpersUnitTests() { + + 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) + + // createBasicImageManifest Tests + @Test + fun runTestCreateBasicImageManifestMinimal() = runBlocking { + val result = testCreateBasicImageManifestMinimal() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCreateBasicImageManifestWithFormat() = runBlocking { + val result = testCreateBasicImageManifestWithFormat() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCreateBasicImageManifestWithClaimGenerator() = runBlocking { + val result = testCreateBasicImageManifestWithClaimGenerator() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCreateBasicImageManifestWithTimestampAuthority() = runBlocking { + val result = testCreateBasicImageManifestWithTimestampAuthority() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCreateBasicImageManifestDefaultClaimGenerator() = runBlocking { + val result = testCreateBasicImageManifestDefaultClaimGenerator() + assertTrue(result.success, "Test failed: ${result.message}") + } + + // createImageEditManifest Tests + @Test + fun runTestCreateImageEditManifestMinimal() = runBlocking { + val result = testCreateImageEditManifestMinimal() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCreateImageEditManifestIngredient() = runBlocking { + val result = testCreateImageEditManifestIngredient() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCreateImageEditManifestAction() = runBlocking { + val result = testCreateImageEditManifestAction() + assertTrue(result.success, "Test failed: ${result.message}") + } + + // createPhotoManifest Tests + @Test + fun runTestCreatePhotoManifestMinimal() = runBlocking { + val result = testCreatePhotoManifestMinimal() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCreatePhotoManifestWithAuthor() = runBlocking { + val result = testCreatePhotoManifestWithAuthor() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCreatePhotoManifestWithLocation() = runBlocking { + val result = testCreatePhotoManifestWithLocation() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCreatePhotoManifestCreatedAction() = runBlocking { + val result = testCreatePhotoManifestCreatedAction() + assertTrue(result.success, "Test failed: ${result.message}") + } + + // createVideoEditManifest Tests + @Test + fun runTestCreateVideoEditManifestMinimal() = runBlocking { + val result = testCreateVideoEditManifestMinimal() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCreateVideoEditManifestWithEditActions() = runBlocking { + val result = testCreateVideoEditManifestWithEditActions() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCreateVideoEditManifestOpenedAction() = runBlocking { + val result = testCreateVideoEditManifestOpenedAction() + assertTrue(result.success, "Test failed: ${result.message}") + } + + // createCompositeManifest Tests + @Test + fun runTestCreateCompositeManifestMinimal() = runBlocking { + val result = testCreateCompositeManifestMinimal() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCreateCompositeManifestActions() = runBlocking { + val result = testCreateCompositeManifestActions() + assertTrue(result.success, "Test failed: ${result.message}") + } + + // createScreenshotManifest Tests + @Test + fun runTestCreateScreenshotManifestMinimal() = runBlocking { + val result = testCreateScreenshotManifestMinimal() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCreateScreenshotManifestWithAppName() = runBlocking { + val result = testCreateScreenshotManifestWithAppName() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCreateScreenshotManifestAction() = runBlocking { + val result = testCreateScreenshotManifestAction() + assertTrue(result.success, "Test failed: ${result.message}") + } + + // createSocialMediaShareManifest Tests + @Test + fun runTestCreateSocialMediaShareManifestMinimal() = runBlocking { + val result = testCreateSocialMediaShareManifestMinimal() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCreateSocialMediaShareManifestActions() = runBlocking { + val result = testCreateSocialMediaShareManifestActions() + assertTrue(result.success, "Test failed: ${result.message}") + } + + // createFilteredImageManifest Tests + @Test + fun runTestCreateFilteredImageManifestMinimal() = runBlocking { + val result = testCreateFilteredImageManifestMinimal() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCreateFilteredImageManifestFilterParameters() = runBlocking { + val result = testCreateFilteredImageManifestFilterParameters() + assertTrue(result.success, "Test failed: ${result.message}") + } + + // createCreatorVerifiedManifest Tests + @Test + fun runTestCreateCreatorVerifiedManifestMinimal() = runBlocking { + val result = testCreateCreatorVerifiedManifestMinimal() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCreateCreatorVerifiedManifestWithIdentities() = runBlocking { + val result = testCreateCreatorVerifiedManifestWithIdentities() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCreateCreatorVerifiedManifestWithAuthorAndDevice() = runBlocking { + val result = testCreateCreatorVerifiedManifestWithAuthorAndDevice() + assertTrue(result.success, "Test failed: ${result.message}") + } + + // createSocialMediaCreatorManifest Tests + @Test + fun runTestCreateSocialMediaCreatorManifestInstagram() = runBlocking { + val result = testCreateSocialMediaCreatorManifestInstagram() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCreateSocialMediaCreatorManifestTwitter() = runBlocking { + val result = testCreateSocialMediaCreatorManifestTwitter() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCreateSocialMediaCreatorManifestX() = runBlocking { + val result = testCreateSocialMediaCreatorManifestX() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCreateSocialMediaCreatorManifestGitHub() = runBlocking { + val result = testCreateSocialMediaCreatorManifestGitHub() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCreateSocialMediaCreatorManifestCustomPlatform() = runBlocking { + val result = testCreateSocialMediaCreatorManifestCustomPlatform() + assertTrue(result.success, "Test failed: ${result.message}") + } + + // Location Helper Tests + @Test + fun runTestCreateLocation() = runBlocking { + val result = testCreateLocation() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCreateLocationWithoutName() = runBlocking { + val result = testCreateLocationWithoutName() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCreateGeoLocation() = runBlocking { + val result = testCreateGeoLocation() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestCreateGeoLocationMinimal() = runBlocking { + val result = testCreateGeoLocationMinimal() + assertTrue(result.success, "Test failed: ${result.message}") + } + + // addStandardThumbnail Tests + @Test + fun runTestAddStandardThumbnail() = runBlocking { + val result = testAddStandardThumbnail() + assertTrue(result.success, "Test failed: ${result.message}") + } + + @Test + fun runTestAddStandardThumbnailCustomIdentifier() = runBlocking { + val result = testAddStandardThumbnailCustomIdentifier() + assertTrue(result.success, "Test failed: ${result.message}") + } +} diff --git a/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/AttestationBuilderUnitTests.kt b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/AttestationBuilderUnitTests.kt new file mode 100644 index 0000000..e0cc164 --- /dev/null +++ b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/AttestationBuilderUnitTests.kt @@ -0,0 +1,1393 @@ +package org.contentauth.c2pa.test.shared + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.contentauth.c2pa.manifest.Action +import org.contentauth.c2pa.manifest.ActionChange +import org.contentauth.c2pa.manifest.ActionsAttestation +import org.contentauth.c2pa.manifest.AssertionMetadataAttestation +import org.contentauth.c2pa.manifest.AttestationBuilder +import org.contentauth.c2pa.manifest.C2PAActions +import org.contentauth.c2pa.manifest.C2PAAssertionTypes +import org.contentauth.c2pa.manifest.C2PAFormats +import org.contentauth.c2pa.manifest.CAWGIdentityAttestation +import org.contentauth.c2pa.manifest.CAWGIdentityTypes +import org.contentauth.c2pa.manifest.CAWGProviders +import org.contentauth.c2pa.manifest.CreativeWorkAttestation +import org.contentauth.c2pa.manifest.CredentialSchema +import org.contentauth.c2pa.manifest.DataHashAttestation +import org.contentauth.c2pa.manifest.IdentityProvider +import org.contentauth.c2pa.manifest.ManifestBuilder +import org.contentauth.c2pa.manifest.SoftwareAgent +import org.contentauth.c2pa.manifest.ThumbnailAttestation +import org.contentauth.c2pa.manifest.VerifiedIdentity +import org.json.JSONObject +import java.util.Date + +/** + * Unit tests for AttestationBuilder class and all attestation types + * Tests all attestation types and JSON output structure + */ +abstract class AttestationBuilderUnitTests : TestBase() { + + // ==================== Data Class Tests ==================== + + suspend fun testVerifiedIdentityDataClass(): TestResult = withContext(Dispatchers.IO) { + runTest("VerifiedIdentity data class") { + val provider = IdentityProvider("https://instagram.com", "instagram") + val identity = VerifiedIdentity( + type = CAWGIdentityTypes.SOCIAL_MEDIA, + username = "testuser", + uri = "https://instagram.com/testuser", + verifiedAt = "2024-01-01T00:00:00Z", + provider = provider + ) + + val success = identity.type == CAWGIdentityTypes.SOCIAL_MEDIA && + identity.username == "testuser" && + identity.uri == "https://instagram.com/testuser" && + identity.verifiedAt == "2024-01-01T00:00:00Z" && + identity.provider.id == "https://instagram.com" && + identity.provider.name == "instagram" + + TestResult( + "VerifiedIdentity data class", + success, + if (success) "VerifiedIdentity properties correct" else "VerifiedIdentity properties incorrect" + ) + } + } + + suspend fun testIdentityProviderDataClass(): TestResult = withContext(Dispatchers.IO) { + runTest("IdentityProvider data class") { + val provider = IdentityProvider( + id = "https://github.com", + name = "github" + ) + + val success = provider.id == "https://github.com" && + provider.name == "github" + + TestResult( + "IdentityProvider data class", + success, + if (success) "IdentityProvider properties correct" else "IdentityProvider properties incorrect" + ) + } + } + + suspend fun testCredentialSchemaDataClass(): TestResult = withContext(Dispatchers.IO) { + runTest("CredentialSchema data class") { + val schema = CredentialSchema( + id = "https://example.com/schema/", + type = "CustomSchema" + ) + + val success = schema.id == "https://example.com/schema/" && + schema.type == "CustomSchema" + + TestResult( + "CredentialSchema data class", + success, + if (success) "CredentialSchema properties correct" else "CredentialSchema properties incorrect" + ) + } + } + + suspend fun testCredentialSchemaDefaults(): TestResult = withContext(Dispatchers.IO) { + runTest("CredentialSchema defaults") { + val schema = CredentialSchema() + + val success = schema.id == "https://cawg.io/identity/1.1/ica/schema/" && + schema.type == "JSONSchema" + + TestResult( + "CredentialSchema defaults", + success, + if (success) "CredentialSchema defaults correct" else "CredentialSchema defaults incorrect" + ) + } + } + + // ==================== CreativeWorkAttestation Tests ==================== + + suspend fun testCreativeWorkAttestationType(): TestResult = withContext(Dispatchers.IO) { + runTest("CreativeWorkAttestation type") { + val attestation = CreativeWorkAttestation() + + val success = attestation.type == C2PAAssertionTypes.CREATIVE_WORK + + TestResult( + "CreativeWorkAttestation type", + success, + if (success) "Type is c2pa.creative_work" else "Type should be c2pa.creative_work" + ) + } + } + + suspend fun testCreativeWorkAddAuthor(): TestResult = withContext(Dispatchers.IO) { + runTest("CreativeWorkAttestation addAuthor") { + val attestation = CreativeWorkAttestation() + .addAuthor("John Doe") + + val json = attestation.toJsonObject() + val authors = json.optJSONArray("author") + val firstAuthor = authors?.optJSONObject(0) + + val success = firstAuthor?.getString("name") == "John Doe" + + TestResult( + "CreativeWorkAttestation addAuthor", + success, + if (success) "Author added correctly" else "Author not added correctly", + json.toString(2) + ) + } + } + + suspend fun testCreativeWorkAddAuthorWithCredential(): TestResult = withContext(Dispatchers.IO) { + runTest("CreativeWorkAttestation addAuthor with credential") { + val attestation = CreativeWorkAttestation() + .addAuthor("Jane Smith", credential = "Professional Photographer") + + val json = attestation.toJsonObject() + val authors = json.optJSONArray("author") + val firstAuthor = authors?.optJSONObject(0) + + val success = firstAuthor?.getString("name") == "Jane Smith" && + firstAuthor?.getString("credential") == "Professional Photographer" + + TestResult( + "CreativeWorkAttestation addAuthor with credential", + success, + if (success) "Author with credential correct" else "Author with credential incorrect" + ) + } + } + + suspend fun testCreativeWorkAddAuthorWithIdentifier(): TestResult = withContext(Dispatchers.IO) { + runTest("CreativeWorkAttestation addAuthor with identifier") { + val attestation = CreativeWorkAttestation() + .addAuthor("Test User", identifier = "https://example.com/users/test") + + val json = attestation.toJsonObject() + val authors = json.optJSONArray("author") + val firstAuthor = authors?.optJSONObject(0) + + val success = firstAuthor?.getString("name") == "Test User" && + firstAuthor?.getString("@id") == "https://example.com/users/test" + + TestResult( + "CreativeWorkAttestation addAuthor with identifier", + success, + if (success) "Author with identifier correct" else "Author with identifier incorrect" + ) + } + } + + suspend fun testCreativeWorkMultipleAuthors(): TestResult = withContext(Dispatchers.IO) { + runTest("CreativeWorkAttestation multiple authors") { + val attestation = CreativeWorkAttestation() + .addAuthor("Author 1") + .addAuthor("Author 2", credential = "Editor") + .addAuthor("Author 3", identifier = "https://example.com/3") + + val json = attestation.toJsonObject() + val authors = json.optJSONArray("author") + + val success = authors?.length() == 3 && + authors.getJSONObject(0).getString("name") == "Author 1" && + authors.getJSONObject(1).getString("name") == "Author 2" && + authors.getJSONObject(2).getString("name") == "Author 3" + + TestResult( + "CreativeWorkAttestation multiple authors", + success, + if (success) "Multiple authors correct" else "Multiple authors incorrect" + ) + } + } + + suspend fun testCreativeWorkDateCreatedWithDate(): TestResult = withContext(Dispatchers.IO) { + runTest("CreativeWorkAttestation dateCreated with Date") { + val date = Date(1704067200000L) // 2024-01-01T00:00:00Z + val attestation = CreativeWorkAttestation() + .dateCreated(date) + + val json = attestation.toJsonObject() + val dateCreated = json.optString("dateCreated") + + // Should be ISO 8601 format + val success = dateCreated.contains("2024-01-01") && dateCreated.endsWith("Z") + + TestResult( + "CreativeWorkAttestation dateCreated with Date", + success, + if (success) "Date formatted correctly" else "Date format incorrect", + "dateCreated: $dateCreated" + ) + } + } + + suspend fun testCreativeWorkDateCreatedWithString(): TestResult = withContext(Dispatchers.IO) { + runTest("CreativeWorkAttestation dateCreated with String") { + val attestation = CreativeWorkAttestation() + .dateCreated("2024-06-15T10:30:00Z") + + val json = attestation.toJsonObject() + + val success = json.getString("dateCreated") == "2024-06-15T10:30:00Z" + + TestResult( + "CreativeWorkAttestation dateCreated with String", + success, + if (success) "Date string set correctly" else "Date string not set correctly" + ) + } + } + + suspend fun testCreativeWorkReviewStatus(): TestResult = withContext(Dispatchers.IO) { + runTest("CreativeWorkAttestation reviewStatus") { + val attestation = CreativeWorkAttestation() + .reviewStatus("approved") + + val json = attestation.toJsonObject() + + val success = json.getString("reviewStatus") == "approved" + + TestResult( + "CreativeWorkAttestation reviewStatus", + success, + if (success) "Review status set correctly" else "Review status not set correctly" + ) + } + } + + suspend fun testCreativeWorkEmptyOutput(): TestResult = withContext(Dispatchers.IO) { + runTest("CreativeWorkAttestation empty output") { + val attestation = CreativeWorkAttestation() + val json = attestation.toJsonObject() + + // Empty attestation should produce empty JSON object + val success = !json.has("author") && !json.has("dateCreated") && !json.has("reviewStatus") + + TestResult( + "CreativeWorkAttestation empty output", + success, + if (success) "Empty attestation produces empty JSON" else "Empty attestation should have no fields" + ) + } + } + + // ==================== ActionsAttestation Tests ==================== + + suspend fun testActionsAttestationType(): TestResult = withContext(Dispatchers.IO) { + runTest("ActionsAttestation type") { + val attestation = ActionsAttestation() + + val success = attestation.type == "c2pa.actions" + + TestResult( + "ActionsAttestation type", + success, + if (success) "Type is c2pa.actions" else "Type should be c2pa.actions" + ) + } + } + + suspend fun testActionsAttestationAddAction(): TestResult = withContext(Dispatchers.IO) { + runTest("ActionsAttestation addAction") { + val action = Action(action = C2PAActions.CREATED) + val attestation = ActionsAttestation() + .addAction(action) + + val json = attestation.toJsonObject() + val actions = json.optJSONArray("actions") + val firstAction = actions?.optJSONObject(0) + + val success = firstAction?.getString("action") == C2PAActions.CREATED + + TestResult( + "ActionsAttestation addAction", + success, + if (success) "Action added correctly" else "Action not added correctly" + ) + } + } + + suspend fun testActionsAttestationAddCreatedAction(): TestResult = withContext(Dispatchers.IO) { + runTest("ActionsAttestation addCreatedAction") { + val agent = SoftwareAgent("TestApp", "1.0", "Android") + val attestation = ActionsAttestation() + .addCreatedAction(softwareAgent = agent, whenTimestamp = "2024-01-01T00:00:00Z") + + val json = attestation.toJsonObject() + val actions = json.optJSONArray("actions") + val firstAction = actions?.optJSONObject(0) + + val success = firstAction?.getString("action") == C2PAActions.CREATED && + firstAction?.getString("when") == "2024-01-01T00:00:00Z" + + TestResult( + "ActionsAttestation addCreatedAction", + success, + if (success) "Created action correct" else "Created action incorrect", + json.toString(2) + ) + } + } + + suspend fun testActionsAttestationAddEditedAction(): TestResult = withContext(Dispatchers.IO) { + runTest("ActionsAttestation addEditedAction") { + val changes = listOf(ActionChange("brightness", "Increased")) + val attestation = ActionsAttestation() + .addEditedAction(changes = changes) + + val json = attestation.toJsonObject() + val actions = json.optJSONArray("actions") + val firstAction = actions?.optJSONObject(0) + val changesArray = firstAction?.optJSONArray("changes") + + val success = firstAction?.getString("action") == C2PAActions.EDITED && + changesArray?.getJSONObject(0)?.getString("field") == "brightness" + + TestResult( + "ActionsAttestation addEditedAction", + success, + if (success) "Edited action correct" else "Edited action incorrect" + ) + } + } + + suspend fun testActionsAttestationAddOpenedAction(): TestResult = withContext(Dispatchers.IO) { + runTest("ActionsAttestation addOpenedAction") { + val attestation = ActionsAttestation() + .addOpenedAction(whenTimestamp = "2024-01-01T00:00:00Z") + + val json = attestation.toJsonObject() + val actions = json.optJSONArray("actions") + val firstAction = actions?.optJSONObject(0) + + val success = firstAction?.getString("action") == C2PAActions.OPENED + + TestResult( + "ActionsAttestation addOpenedAction", + success, + if (success) "Opened action correct" else "Opened action incorrect" + ) + } + } + + suspend fun testActionsAttestationAddPlacedAction(): TestResult = withContext(Dispatchers.IO) { + runTest("ActionsAttestation addPlacedAction") { + val attestation = ActionsAttestation() + .addPlacedAction() + + val json = attestation.toJsonObject() + val actions = json.optJSONArray("actions") + val firstAction = actions?.optJSONObject(0) + + val success = firstAction?.getString("action") == C2PAActions.PLACED + + TestResult( + "ActionsAttestation addPlacedAction", + success, + if (success) "Placed action correct" else "Placed action incorrect" + ) + } + } + + suspend fun testActionsAttestationAddDrawingAction(): TestResult = withContext(Dispatchers.IO) { + runTest("ActionsAttestation addDrawingAction") { + val attestation = ActionsAttestation() + .addDrawingAction() + + val json = attestation.toJsonObject() + val actions = json.optJSONArray("actions") + val firstAction = actions?.optJSONObject(0) + + val success = firstAction?.getString("action") == C2PAActions.DRAWING + + TestResult( + "ActionsAttestation addDrawingAction", + success, + if (success) "Drawing action correct" else "Drawing action incorrect" + ) + } + } + + suspend fun testActionsAttestationAddColorAdjustmentsAction(): TestResult = withContext(Dispatchers.IO) { + runTest("ActionsAttestation addColorAdjustmentsAction") { + val params = mapOf("brightness" to 10, "contrast" to 5) + val attestation = ActionsAttestation() + .addColorAdjustmentsAction(parameters = params) + + val json = attestation.toJsonObject() + val actions = json.optJSONArray("actions") + val firstAction = actions?.optJSONObject(0) + val paramsJson = firstAction?.optJSONObject("parameters") + + val success = firstAction?.getString("action") == C2PAActions.COLOR_ADJUSTMENTS && + paramsJson?.getInt("brightness") == 10 && + paramsJson?.getInt("contrast") == 5 + + TestResult( + "ActionsAttestation addColorAdjustmentsAction", + success, + if (success) "Color adjustments action correct" else "Color adjustments action incorrect" + ) + } + } + + suspend fun testActionsAttestationAddResizedAction(): TestResult = withContext(Dispatchers.IO) { + runTest("ActionsAttestation addResizedAction") { + val attestation = ActionsAttestation() + .addResizedAction() + + val json = attestation.toJsonObject() + val actions = json.optJSONArray("actions") + val firstAction = actions?.optJSONObject(0) + + val success = firstAction?.getString("action") == C2PAActions.RESIZED + + TestResult( + "ActionsAttestation addResizedAction", + success, + if (success) "Resized action correct" else "Resized action incorrect" + ) + } + } + + suspend fun testActionsAttestationMultipleActions(): TestResult = withContext(Dispatchers.IO) { + runTest("ActionsAttestation multiple actions") { + val attestation = ActionsAttestation() + .addOpenedAction() + .addEditedAction() + .addResizedAction() + + val json = attestation.toJsonObject() + val actions = json.optJSONArray("actions") + + val success = actions?.length() == 3 && + actions.getJSONObject(0).getString("action") == C2PAActions.OPENED && + actions.getJSONObject(1).getString("action") == C2PAActions.EDITED && + actions.getJSONObject(2).getString("action") == C2PAActions.RESIZED + + TestResult( + "ActionsAttestation multiple actions", + success, + if (success) "Multiple actions correct" else "Multiple actions incorrect" + ) + } + } + + // ==================== AssertionMetadataAttestation Tests ==================== + + suspend fun testAssertionMetadataType(): TestResult = withContext(Dispatchers.IO) { + runTest("AssertionMetadataAttestation type") { + val attestation = AssertionMetadataAttestation() + + val success = attestation.type == "c2pa.assertion.metadata" + + TestResult( + "AssertionMetadataAttestation type", + success, + if (success) "Type is c2pa.assertion.metadata" else "Type should be c2pa.assertion.metadata" + ) + } + } + + suspend fun testAssertionMetadataAddMetadata(): TestResult = withContext(Dispatchers.IO) { + runTest("AssertionMetadataAttestation addMetadata") { + val attestation = AssertionMetadataAttestation() + .addMetadata("custom_key", "custom_value") + + val json = attestation.toJsonObject() + + val success = json.getString("custom_key") == "custom_value" + + TestResult( + "AssertionMetadataAttestation addMetadata", + success, + if (success) "Metadata added correctly" else "Metadata not added correctly" + ) + } + } + + suspend fun testAssertionMetadataDateTime(): TestResult = withContext(Dispatchers.IO) { + runTest("AssertionMetadataAttestation dateTime") { + val attestation = AssertionMetadataAttestation() + .dateTime("2024-01-01T12:00:00Z") + + val json = attestation.toJsonObject() + + val success = json.getString("dateTime") == "2024-01-01T12:00:00Z" + + TestResult( + "AssertionMetadataAttestation dateTime", + success, + if (success) "DateTime set correctly" else "DateTime not set correctly" + ) + } + } + + suspend fun testAssertionMetadataDevice(): TestResult = withContext(Dispatchers.IO) { + runTest("AssertionMetadataAttestation device") { + val attestation = AssertionMetadataAttestation() + .device("Google Pixel 8") + + val json = attestation.toJsonObject() + + val success = json.getString("device") == "Google Pixel 8" + + TestResult( + "AssertionMetadataAttestation device", + success, + if (success) "Device set correctly" else "Device not set correctly" + ) + } + } + + suspend fun testAssertionMetadataLocation(): TestResult = withContext(Dispatchers.IO) { + runTest("AssertionMetadataAttestation location") { + val locationJson = JSONObject().apply { + put("@type", "Place") + put("latitude", 37.7749) + put("longitude", -122.4194) + } + val attestation = AssertionMetadataAttestation() + .location(locationJson) + + val json = attestation.toJsonObject() + val location = json.optJSONObject("location") + + val success = location?.getString("@type") == "Place" && + location?.getDouble("latitude") == 37.7749 && + location?.getDouble("longitude") == -122.4194 + + TestResult( + "AssertionMetadataAttestation location", + success, + if (success) "Location set correctly" else "Location not set correctly" + ) + } + } + + suspend fun testAssertionMetadataMultipleFields(): TestResult = withContext(Dispatchers.IO) { + runTest("AssertionMetadataAttestation multiple fields") { + val attestation = AssertionMetadataAttestation() + .dateTime("2024-01-01T12:00:00Z") + .device("Test Device") + .addMetadata("custom1", "value1") + .addMetadata("custom2", 42) + .addMetadata("custom3", true) + + val json = attestation.toJsonObject() + + val success = json.getString("dateTime") == "2024-01-01T12:00:00Z" && + json.getString("device") == "Test Device" && + json.getString("custom1") == "value1" && + json.getInt("custom2") == 42 && + json.getBoolean("custom3") == true + + TestResult( + "AssertionMetadataAttestation multiple fields", + success, + if (success) "Multiple fields correct" else "Multiple fields incorrect" + ) + } + } + + // ==================== ThumbnailAttestation Tests ==================== + + suspend fun testThumbnailAttestationType(): TestResult = withContext(Dispatchers.IO) { + runTest("ThumbnailAttestation type") { + val attestation = ThumbnailAttestation() + + val success = attestation.type == "c2pa.thumbnail" + + TestResult( + "ThumbnailAttestation type", + success, + if (success) "Type is c2pa.thumbnail" else "Type should be c2pa.thumbnail" + ) + } + } + + suspend fun testThumbnailAttestationFormat(): TestResult = withContext(Dispatchers.IO) { + runTest("ThumbnailAttestation format") { + val attestation = ThumbnailAttestation() + .format(C2PAFormats.PNG) + + val json = attestation.toJsonObject() + + val success = json.getString("format") == C2PAFormats.PNG + + TestResult( + "ThumbnailAttestation format", + success, + if (success) "Format set correctly" else "Format not set correctly" + ) + } + } + + suspend fun testThumbnailAttestationIdentifier(): TestResult = withContext(Dispatchers.IO) { + runTest("ThumbnailAttestation identifier") { + val attestation = ThumbnailAttestation() + .identifier("thumbnail.jpg") + + val json = attestation.toJsonObject() + + val success = json.getString("identifier") == "thumbnail.jpg" + + TestResult( + "ThumbnailAttestation identifier", + success, + if (success) "Identifier set correctly" else "Identifier not set correctly" + ) + } + } + + suspend fun testThumbnailAttestationContentType(): TestResult = withContext(Dispatchers.IO) { + runTest("ThumbnailAttestation contentType") { + val attestation = ThumbnailAttestation() + .contentType("image/png") + + val json = attestation.toJsonObject() + + val success = json.getString("contentType") == "image/png" + + TestResult( + "ThumbnailAttestation contentType", + success, + if (success) "ContentType set correctly" else "ContentType not set correctly" + ) + } + } + + suspend fun testThumbnailAttestationDefaultContentType(): TestResult = withContext(Dispatchers.IO) { + runTest("ThumbnailAttestation default contentType") { + val attestation = ThumbnailAttestation() + val json = attestation.toJsonObject() + + val success = json.getString("contentType") == "image/jpeg" + + TestResult( + "ThumbnailAttestation default contentType", + success, + if (success) "Default contentType is image/jpeg" else "Default contentType should be image/jpeg" + ) + } + } + + // ==================== DataHashAttestation Tests ==================== + + suspend fun testDataHashAttestationType(): TestResult = withContext(Dispatchers.IO) { + runTest("DataHashAttestation type") { + val attestation = DataHashAttestation() + + val success = attestation.type == "c2pa.data_hash" + + TestResult( + "DataHashAttestation type", + success, + if (success) "Type is c2pa.data_hash" else "Type should be c2pa.data_hash" + ) + } + } + + suspend fun testDataHashAttestationName(): TestResult = withContext(Dispatchers.IO) { + runTest("DataHashAttestation name") { + val attestation = DataHashAttestation() + .name("custom hash name") + + val json = attestation.toJsonObject() + + val success = json.getString("name") == "custom hash name" + + TestResult( + "DataHashAttestation name", + success, + if (success) "Name set correctly" else "Name not set correctly" + ) + } + } + + suspend fun testDataHashAttestationDefaultName(): TestResult = withContext(Dispatchers.IO) { + runTest("DataHashAttestation default name") { + val attestation = DataHashAttestation() + val json = attestation.toJsonObject() + + val success = json.getString("name") == "jumbf manifest" + + TestResult( + "DataHashAttestation default name", + success, + if (success) "Default name is 'jumbf manifest'" else "Default name should be 'jumbf manifest'" + ) + } + } + + suspend fun testDataHashAttestationPad(): TestResult = withContext(Dispatchers.IO) { + runTest("DataHashAttestation pad") { + val attestation = DataHashAttestation() + .pad(1024) + + val json = attestation.toJsonObject() + + val success = json.getInt("pad") == 1024 + + TestResult( + "DataHashAttestation pad", + success, + if (success) "Pad set correctly" else "Pad not set correctly" + ) + } + } + + suspend fun testDataHashAttestationExclusions(): TestResult = withContext(Dispatchers.IO) { + runTest("DataHashAttestation exclusions") { + val exclusions = listOf( + mapOf("start" to 0, "length" to 100), + mapOf("start" to 200, "length" to 50) + ) + val attestation = DataHashAttestation() + .exclusions(exclusions) + + val json = attestation.toJsonObject() + val exclusionsArray = json.optJSONArray("exclusions") + + val success = exclusionsArray?.length() == 2 && + exclusionsArray.getJSONObject(0).getInt("start") == 0 && + exclusionsArray.getJSONObject(0).getInt("length") == 100 && + exclusionsArray.getJSONObject(1).getInt("start") == 200 + + TestResult( + "DataHashAttestation exclusions", + success, + if (success) "Exclusions set correctly" else "Exclusions not set correctly" + ) + } + } + + // ==================== CAWGIdentityAttestation Tests ==================== + + suspend fun testCAWGIdentityType(): TestResult = withContext(Dispatchers.IO) { + runTest("CAWGIdentityAttestation type") { + val attestation = CAWGIdentityAttestation() + + val success = attestation.type == C2PAAssertionTypes.CAWG_IDENTITY + + TestResult( + "CAWGIdentityAttestation type", + success, + if (success) "Type is cawg.identity" else "Type should be cawg.identity" + ) + } + } + + suspend fun testCAWGIdentityDefaultContexts(): TestResult = withContext(Dispatchers.IO) { + runTest("CAWGIdentityAttestation default contexts") { + val attestation = CAWGIdentityAttestation() + val json = attestation.toJsonObject() + val contexts = json.optJSONArray("@context") + + val success = contexts?.length() == 2 && + contexts.getString(0) == "https://www.w3.org/ns/credentials/v2" && + contexts.getString(1) == "https://cawg.io/identity/1.1/ica/context/" + + TestResult( + "CAWGIdentityAttestation default contexts", + success, + if (success) "Default contexts correct" else "Default contexts incorrect" + ) + } + } + + suspend fun testCAWGIdentityDefaultTypes(): TestResult = withContext(Dispatchers.IO) { + runTest("CAWGIdentityAttestation default types") { + val attestation = CAWGIdentityAttestation() + val json = attestation.toJsonObject() + val types = json.optJSONArray("type") + + val success = types?.length() == 2 && + types.getString(0) == "VerifiableCredential" && + types.getString(1) == "IdentityClaimsAggregationCredential" + + TestResult( + "CAWGIdentityAttestation default types", + success, + if (success) "Default types correct" else "Default types incorrect" + ) + } + } + + suspend fun testCAWGIdentityDefaultIssuer(): TestResult = withContext(Dispatchers.IO) { + runTest("CAWGIdentityAttestation default issuer") { + val attestation = CAWGIdentityAttestation() + val json = attestation.toJsonObject() + + val success = json.getString("issuer") == "did:web:connected-identities.identity.adobe.com" + + TestResult( + "CAWGIdentityAttestation default issuer", + success, + if (success) "Default issuer correct" else "Default issuer incorrect" + ) + } + } + + suspend fun testCAWGIdentityIssuer(): TestResult = withContext(Dispatchers.IO) { + runTest("CAWGIdentityAttestation custom issuer") { + val attestation = CAWGIdentityAttestation() + .issuer("did:web:custom.issuer.com") + + val json = attestation.toJsonObject() + + val success = json.getString("issuer") == "did:web:custom.issuer.com" + + TestResult( + "CAWGIdentityAttestation custom issuer", + success, + if (success) "Custom issuer correct" else "Custom issuer incorrect" + ) + } + } + + suspend fun testCAWGIdentityValidFrom(): TestResult = withContext(Dispatchers.IO) { + runTest("CAWGIdentityAttestation validFrom") { + val attestation = CAWGIdentityAttestation() + .validFrom("2024-01-01T00:00:00Z") + + val json = attestation.toJsonObject() + + val success = json.getString("validFrom") == "2024-01-01T00:00:00Z" + + TestResult( + "CAWGIdentityAttestation validFrom", + success, + if (success) "validFrom set correctly" else "validFrom not set correctly" + ) + } + } + + suspend fun testCAWGIdentityValidFromNow(): TestResult = withContext(Dispatchers.IO) { + runTest("CAWGIdentityAttestation validFromNow") { + val attestation = CAWGIdentityAttestation() + .validFromNow() + + val json = attestation.toJsonObject() + val validFrom = json.optString("validFrom") + + // Should be ISO 8601 format and contain current year + val success = validFrom.isNotEmpty() && validFrom.endsWith("Z") + + TestResult( + "CAWGIdentityAttestation validFromNow", + success, + if (success) "validFromNow generates timestamp" else "validFromNow should generate timestamp", + "validFrom: $validFrom" + ) + } + } + + suspend fun testCAWGIdentityAddContext(): TestResult = withContext(Dispatchers.IO) { + runTest("CAWGIdentityAttestation addContext") { + val attestation = CAWGIdentityAttestation() + .addContext("https://custom.context.com/") + + val json = attestation.toJsonObject() + val contexts = json.optJSONArray("@context") + + val success = contexts?.length() == 3 && + contexts.getString(2) == "https://custom.context.com/" + + TestResult( + "CAWGIdentityAttestation addContext", + success, + if (success) "Context added correctly" else "Context not added correctly" + ) + } + } + + suspend fun testCAWGIdentityAddContextNoDuplicate(): TestResult = withContext(Dispatchers.IO) { + runTest("CAWGIdentityAttestation addContext no duplicate") { + val attestation = CAWGIdentityAttestation() + .addContext("https://www.w3.org/ns/credentials/v2") // Already exists + + val json = attestation.toJsonObject() + val contexts = json.optJSONArray("@context") + + val success = contexts?.length() == 2 // Should not add duplicate + + TestResult( + "CAWGIdentityAttestation addContext no duplicate", + success, + if (success) "Duplicate context not added" else "Should not add duplicate context" + ) + } + } + + suspend fun testCAWGIdentityAddType(): TestResult = withContext(Dispatchers.IO) { + runTest("CAWGIdentityAttestation addType") { + val attestation = CAWGIdentityAttestation() + .addType("CustomCredentialType") + + val json = attestation.toJsonObject() + val types = json.optJSONArray("type") + + val success = types?.length() == 3 && + types.getString(2) == "CustomCredentialType" + + TestResult( + "CAWGIdentityAttestation addType", + success, + if (success) "Type added correctly" else "Type not added correctly" + ) + } + } + + suspend fun testCAWGIdentityAddVerifiedIdentity(): TestResult = withContext(Dispatchers.IO) { + runTest("CAWGIdentityAttestation addVerifiedIdentity") { + val attestation = CAWGIdentityAttestation() + .addVerifiedIdentity( + type = CAWGIdentityTypes.SOCIAL_MEDIA, + username = "testuser", + uri = "https://example.com/testuser", + verifiedAt = "2024-01-01T00:00:00Z", + providerId = "https://example.com", + providerName = "example" + ) + + val json = attestation.toJsonObject() + val identities = json.optJSONArray("verifiedIdentities") + val firstIdentity = identities?.optJSONObject(0) + + val success = firstIdentity?.getString("type") == CAWGIdentityTypes.SOCIAL_MEDIA && + firstIdentity?.getString("username") == "testuser" && + firstIdentity?.getString("uri") == "https://example.com/testuser" && + firstIdentity?.getString("verifiedAt") == "2024-01-01T00:00:00Z" && + firstIdentity?.getJSONObject("provider")?.getString("id") == "https://example.com" && + firstIdentity?.getJSONObject("provider")?.getString("name") == "example" + + TestResult( + "CAWGIdentityAttestation addVerifiedIdentity", + success, + if (success) "Verified identity correct" else "Verified identity incorrect", + json.toString(2) + ) + } + } + + suspend fun testCAWGIdentityAddInstagramIdentity(): TestResult = withContext(Dispatchers.IO) { + runTest("CAWGIdentityAttestation addInstagramIdentity") { + val attestation = CAWGIdentityAttestation() + .addInstagramIdentity("instauser", "2024-01-01T00:00:00Z") + + val json = attestation.toJsonObject() + val identities = json.optJSONArray("verifiedIdentities") + val firstIdentity = identities?.optJSONObject(0) + + val success = firstIdentity?.getString("type") == CAWGIdentityTypes.SOCIAL_MEDIA && + firstIdentity?.getString("username") == "instauser" && + firstIdentity?.getString("uri") == "https://www.instagram.com/instauser" && + firstIdentity?.getJSONObject("provider")?.getString("id") == CAWGProviders.INSTAGRAM + + TestResult( + "CAWGIdentityAttestation addInstagramIdentity", + success, + if (success) "Instagram identity correct" else "Instagram identity incorrect" + ) + } + } + + suspend fun testCAWGIdentityAddTwitterIdentity(): TestResult = withContext(Dispatchers.IO) { + runTest("CAWGIdentityAttestation addTwitterIdentity") { + val attestation = CAWGIdentityAttestation() + .addTwitterIdentity("twitteruser", "2024-01-01T00:00:00Z") + + val json = attestation.toJsonObject() + val identities = json.optJSONArray("verifiedIdentities") + val firstIdentity = identities?.optJSONObject(0) + + val success = firstIdentity?.getString("uri") == "https://twitter.com/twitteruser" && + firstIdentity?.getJSONObject("provider")?.getString("id") == CAWGProviders.TWITTER + + TestResult( + "CAWGIdentityAttestation addTwitterIdentity", + success, + if (success) "Twitter identity correct" else "Twitter identity incorrect" + ) + } + } + + suspend fun testCAWGIdentityAddLinkedInIdentity(): TestResult = withContext(Dispatchers.IO) { + runTest("CAWGIdentityAttestation addLinkedInIdentity") { + val attestation = CAWGIdentityAttestation() + .addLinkedInIdentity("John Doe", "https://linkedin.com/in/johndoe", "2024-01-01T00:00:00Z") + + val json = attestation.toJsonObject() + val identities = json.optJSONArray("verifiedIdentities") + val firstIdentity = identities?.optJSONObject(0) + + val success = firstIdentity?.getString("username") == "John Doe" && + firstIdentity?.getString("uri") == "https://linkedin.com/in/johndoe" && + firstIdentity?.getJSONObject("provider")?.getString("id") == CAWGProviders.LINKEDIN + + TestResult( + "CAWGIdentityAttestation addLinkedInIdentity", + success, + if (success) "LinkedIn identity correct" else "LinkedIn identity incorrect" + ) + } + } + + suspend fun testCAWGIdentityAddBehanceIdentity(): TestResult = withContext(Dispatchers.IO) { + runTest("CAWGIdentityAttestation addBehanceIdentity") { + val attestation = CAWGIdentityAttestation() + .addBehanceIdentity("behanceuser", "2024-01-01T00:00:00Z") + + val json = attestation.toJsonObject() + val identities = json.optJSONArray("verifiedIdentities") + val firstIdentity = identities?.optJSONObject(0) + + val success = firstIdentity?.getString("uri") == "https://www.behance.net/behanceuser" && + firstIdentity?.getJSONObject("provider")?.getString("id") == CAWGProviders.BEHANCE + + TestResult( + "CAWGIdentityAttestation addBehanceIdentity", + success, + if (success) "Behance identity correct" else "Behance identity incorrect" + ) + } + } + + suspend fun testCAWGIdentityAddYouTubeIdentity(): TestResult = withContext(Dispatchers.IO) { + runTest("CAWGIdentityAttestation addYouTubeIdentity") { + val attestation = CAWGIdentityAttestation() + .addYouTubeIdentity("My Channel", "https://youtube.com/c/mychannel", "2024-01-01T00:00:00Z") + + val json = attestation.toJsonObject() + val identities = json.optJSONArray("verifiedIdentities") + val firstIdentity = identities?.optJSONObject(0) + + val success = firstIdentity?.getString("username") == "My Channel" && + firstIdentity?.getString("uri") == "https://youtube.com/c/mychannel" && + firstIdentity?.getJSONObject("provider")?.getString("id") == CAWGProviders.YOUTUBE + + TestResult( + "CAWGIdentityAttestation addYouTubeIdentity", + success, + if (success) "YouTube identity correct" else "YouTube identity incorrect" + ) + } + } + + suspend fun testCAWGIdentityAddGitHubIdentity(): TestResult = withContext(Dispatchers.IO) { + runTest("CAWGIdentityAttestation addGitHubIdentity") { + val attestation = CAWGIdentityAttestation() + .addGitHubIdentity("githubuser", "2024-01-01T00:00:00Z") + + val json = attestation.toJsonObject() + val identities = json.optJSONArray("verifiedIdentities") + val firstIdentity = identities?.optJSONObject(0) + + val success = firstIdentity?.getString("uri") == "https://github.com/githubuser" && + firstIdentity?.getJSONObject("provider")?.getString("id") == CAWGProviders.GITHUB + + TestResult( + "CAWGIdentityAttestation addGitHubIdentity", + success, + if (success) "GitHub identity correct" else "GitHub identity incorrect" + ) + } + } + + suspend fun testCAWGIdentityAddCredentialSchema(): TestResult = withContext(Dispatchers.IO) { + runTest("CAWGIdentityAttestation addCredentialSchema") { + val attestation = CAWGIdentityAttestation() + .addCredentialSchema("https://custom.schema.com/", "CustomSchema") + + val json = attestation.toJsonObject() + val schemas = json.optJSONArray("credentialSchema") + + // Should have 2 schemas: default + custom + val success = schemas?.length() == 2 && + schemas.getJSONObject(1).getString("id") == "https://custom.schema.com/" && + schemas.getJSONObject(1).getString("type") == "CustomSchema" + + TestResult( + "CAWGIdentityAttestation addCredentialSchema", + success, + if (success) "Credential schema added correctly" else "Credential schema not added correctly" + ) + } + } + + suspend fun testCAWGIdentityDefaultCredentialSchema(): TestResult = withContext(Dispatchers.IO) { + runTest("CAWGIdentityAttestation default credential schema") { + val attestation = CAWGIdentityAttestation() + val json = attestation.toJsonObject() + val schemas = json.optJSONArray("credentialSchema") + + val success = schemas?.length() == 1 && + schemas.getJSONObject(0).getString("id") == "https://cawg.io/identity/1.1/ica/schema/" && + schemas.getJSONObject(0).getString("type") == "JSONSchema" + + TestResult( + "CAWGIdentityAttestation default credential schema", + success, + if (success) "Default credential schema correct" else "Default credential schema incorrect" + ) + } + } + + suspend fun testCAWGIdentityMultipleIdentities(): TestResult = withContext(Dispatchers.IO) { + runTest("CAWGIdentityAttestation multiple identities") { + val attestation = CAWGIdentityAttestation() + .addInstagramIdentity("insta", "2024-01-01T00:00:00Z") + .addTwitterIdentity("twitter", "2024-01-02T00:00:00Z") + .addGitHubIdentity("github", "2024-01-03T00:00:00Z") + + val json = attestation.toJsonObject() + val identities = json.optJSONArray("verifiedIdentities") + + val success = identities?.length() == 3 + + TestResult( + "CAWGIdentityAttestation multiple identities", + success, + if (success) "Multiple identities correct" else "Multiple identities incorrect" + ) + } + } + + // ==================== AttestationBuilder Tests ==================== + + suspend fun testAttestationBuilderAddCreativeWork(): TestResult = withContext(Dispatchers.IO) { + runTest("AttestationBuilder addCreativeWork") { + val builder = AttestationBuilder() + .addCreativeWork { + addAuthor("Test Author") + dateCreated("2024-01-01T00:00:00Z") + } + + val result = builder.build() + + val success = result.containsKey(C2PAAssertionTypes.CREATIVE_WORK) && + result[C2PAAssertionTypes.CREATIVE_WORK]?.optJSONArray("author")?.length() == 1 + + TestResult( + "AttestationBuilder addCreativeWork", + success, + if (success) "CreativeWork attestation added" else "CreativeWork attestation not added" + ) + } + } + + suspend fun testAttestationBuilderAddActions(): TestResult = withContext(Dispatchers.IO) { + runTest("AttestationBuilder addActions") { + val builder = AttestationBuilder() + .addActions { + addCreatedAction() + addEditedAction() + } + + val result = builder.build() + + val success = result.containsKey("c2pa.actions") && + result["c2pa.actions"]?.optJSONArray("actions")?.length() == 2 + + TestResult( + "AttestationBuilder addActions", + success, + if (success) "Actions attestation added" else "Actions attestation not added" + ) + } + } + + suspend fun testAttestationBuilderAddAssertionMetadata(): TestResult = withContext(Dispatchers.IO) { + runTest("AttestationBuilder addAssertionMetadata") { + val builder = AttestationBuilder() + .addAssertionMetadata { + dateTime("2024-01-01T00:00:00Z") + device("Test Device") + } + + val result = builder.build() + + val success = result.containsKey("c2pa.assertion.metadata") && + result["c2pa.assertion.metadata"]?.getString("device") == "Test Device" + + TestResult( + "AttestationBuilder addAssertionMetadata", + success, + if (success) "AssertionMetadata added" else "AssertionMetadata not added" + ) + } + } + + suspend fun testAttestationBuilderAddThumbnail(): TestResult = withContext(Dispatchers.IO) { + runTest("AttestationBuilder addThumbnail") { + val builder = AttestationBuilder() + .addThumbnail { + format(C2PAFormats.JPEG) + identifier("thumb.jpg") + } + + val result = builder.build() + + val success = result.containsKey("c2pa.thumbnail") && + result["c2pa.thumbnail"]?.getString("format") == C2PAFormats.JPEG + + TestResult( + "AttestationBuilder addThumbnail", + success, + if (success) "Thumbnail attestation added" else "Thumbnail attestation not added" + ) + } + } + + suspend fun testAttestationBuilderAddDataHash(): TestResult = withContext(Dispatchers.IO) { + runTest("AttestationBuilder addDataHash") { + val builder = AttestationBuilder() + .addDataHash { + name("test hash") + pad(512) + } + + val result = builder.build() + + val success = result.containsKey("c2pa.data_hash") && + result["c2pa.data_hash"]?.getString("name") == "test hash" + + TestResult( + "AttestationBuilder addDataHash", + success, + if (success) "DataHash attestation added" else "DataHash attestation not added" + ) + } + } + + suspend fun testAttestationBuilderAddCAWGIdentity(): TestResult = withContext(Dispatchers.IO) { + runTest("AttestationBuilder addCAWGIdentity") { + val builder = AttestationBuilder() + .addCAWGIdentity { + validFromNow() + addInstagramIdentity("testuser", "2024-01-01T00:00:00Z") + } + + val result = builder.build() + + val success = result.containsKey(C2PAAssertionTypes.CAWG_IDENTITY) && + result[C2PAAssertionTypes.CAWG_IDENTITY]?.optJSONArray("verifiedIdentities")?.length() == 1 + + TestResult( + "AttestationBuilder addCAWGIdentity", + success, + if (success) "CAWGIdentity attestation added" else "CAWGIdentity attestation not added" + ) + } + } + + suspend fun testAttestationBuilderAddCustomAttestation(): TestResult = withContext(Dispatchers.IO) { + runTest("AttestationBuilder addCustomAttestation") { + val customData = JSONObject().apply { + put("custom_field", "custom_value") + } + val builder = AttestationBuilder() + .addCustomAttestation("custom.type", customData) + + val result = builder.build() + + val success = result.containsKey("custom.type") && + result["custom.type"]?.getString("custom_field") == "custom_value" + + TestResult( + "AttestationBuilder addCustomAttestation", + success, + if (success) "Custom attestation added" else "Custom attestation not added" + ) + } + } + + suspend fun testAttestationBuilderBuildForManifest(): TestResult = withContext(Dispatchers.IO) { + runTest("AttestationBuilder buildForManifest") { + val manifestBuilder = ManifestBuilder() + .title("Test") + .format(C2PAFormats.JPEG) + + AttestationBuilder() + .addCreativeWork { + addAuthor("Test Author") + } + .addAssertionMetadata { + device("Test Device") + } + .buildForManifest(manifestBuilder) + + val json = manifestBuilder.build() + val assertions = json.optJSONArray("assertions") + + // Should have creative work and assertion metadata + var hasCreativeWork = false + var hasMetadata = false + for (i in 0 until (assertions?.length() ?: 0)) { + val label = assertions?.getJSONObject(i)?.getString("label") + if (label == C2PAAssertionTypes.CREATIVE_WORK) hasCreativeWork = true + if (label == "c2pa.assertion.metadata") hasMetadata = true + } + + val success = hasCreativeWork && hasMetadata + + TestResult( + "AttestationBuilder buildForManifest", + success, + if (success) "Attestations added to manifest" else "Attestations not added to manifest", + json.toString(2) + ) + } + } + + suspend fun testAttestationBuilderMultipleAttestations(): TestResult = withContext(Dispatchers.IO) { + runTest("AttestationBuilder multiple attestations") { + val builder = AttestationBuilder() + .addCreativeWork { addAuthor("Author") } + .addActions { addCreatedAction() } + .addAssertionMetadata { device("Device") } + .addCAWGIdentity { addInstagramIdentity("user", "2024-01-01T00:00:00Z") } + + val result = builder.build() + + val success = result.size == 4 && + result.containsKey(C2PAAssertionTypes.CREATIVE_WORK) && + result.containsKey("c2pa.actions") && + result.containsKey("c2pa.assertion.metadata") && + result.containsKey(C2PAAssertionTypes.CAWG_IDENTITY) + + TestResult( + "AttestationBuilder multiple attestations", + success, + if (success) "All attestations present" else "Missing attestations" + ) + } + } + + suspend fun testAttestationBuilderChaining(): TestResult = withContext(Dispatchers.IO) { + runTest("AttestationBuilder method chaining") { + val builder = AttestationBuilder() + .addCreativeWork { addAuthor("A") } + .addActions { addCreatedAction() } + + // If chaining works correctly, build() should succeed + val result = builder.build() + val success = result.isNotEmpty() + + TestResult( + "AttestationBuilder method chaining", + success, + if (success) "Method chaining works" else "Method chaining broken" + ) + } + } +} diff --git a/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/ManifestBuilderUnitTests.kt b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/ManifestBuilderUnitTests.kt new file mode 100644 index 0000000..47bf055 --- /dev/null +++ b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/ManifestBuilderUnitTests.kt @@ -0,0 +1,1021 @@ +package org.contentauth.c2pa.test.shared + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.contentauth.c2pa.manifest.Action +import org.contentauth.c2pa.manifest.ActionChange +import org.contentauth.c2pa.manifest.C2PAActions +import org.contentauth.c2pa.manifest.C2PAFormats +import org.contentauth.c2pa.manifest.C2PARelationships +import org.contentauth.c2pa.manifest.ClaimGenerator +import org.contentauth.c2pa.manifest.DigitalSourceTypes +import org.contentauth.c2pa.manifest.Ingredient +import org.contentauth.c2pa.manifest.ManifestBuilder +import org.contentauth.c2pa.manifest.SoftwareAgent +import org.contentauth.c2pa.manifest.Thumbnail +import org.json.JSONObject + +/** + * Unit tests for ManifestBuilder class + * Tests all builder methods and JSON output structure + */ +abstract class ManifestBuilderUnitTests : TestBase() { + + // ==================== Data Class Tests ==================== + + suspend fun testClaimGeneratorDataClass(): TestResult = withContext(Dispatchers.IO) { + runTest("ClaimGenerator data class") { + val generator = ClaimGenerator("TestApp", "1.0.0", "icon.png") + + val success = generator.name == "TestApp" && + generator.version == "1.0.0" && + generator.icon == "icon.png" + + TestResult( + "ClaimGenerator data class", + success, + if (success) "ClaimGenerator properties correct" else "ClaimGenerator properties incorrect" + ) + } + } + + suspend fun testClaimGeneratorWithoutIcon(): TestResult = withContext(Dispatchers.IO) { + runTest("ClaimGenerator without icon") { + val generator = ClaimGenerator("TestApp", "2.0.0") + + val success = generator.name == "TestApp" && + generator.version == "2.0.0" && + generator.icon == null + + TestResult( + "ClaimGenerator without icon", + success, + if (success) "ClaimGenerator default icon is null" else "ClaimGenerator default icon should be null" + ) + } + } + + suspend fun testIngredientDataClass(): TestResult = withContext(Dispatchers.IO) { + runTest("Ingredient data class") { + val thumbnail = Thumbnail(C2PAFormats.JPEG, "thumb.jpg") + val ingredient = Ingredient( + title = "Test Image", + format = C2PAFormats.JPEG, + instanceId = "test-instance-id", + documentId = "test-doc-id", + provenance = "test-provenance", + hash = "abc123", + relationship = C2PARelationships.PARENT_OF, + validationStatus = listOf("valid"), + thumbnail = thumbnail + ) + + val success = ingredient.title == "Test Image" && + ingredient.format == C2PAFormats.JPEG && + ingredient.instanceId == "test-instance-id" && + ingredient.documentId == "test-doc-id" && + ingredient.provenance == "test-provenance" && + ingredient.hash == "abc123" && + ingredient.relationship == C2PARelationships.PARENT_OF && + ingredient.validationStatus.contains("valid") && + ingredient.thumbnail == thumbnail + + TestResult( + "Ingredient data class", + success, + if (success) "Ingredient properties correct" else "Ingredient properties incorrect" + ) + } + } + + suspend fun testIngredientDefaults(): TestResult = withContext(Dispatchers.IO) { + runTest("Ingredient defaults") { + val ingredient = Ingredient(format = C2PAFormats.PNG) + + val success = ingredient.title == null && + ingredient.format == C2PAFormats.PNG && + ingredient.instanceId.isNotEmpty() && // UUID auto-generated + ingredient.documentId == null && + ingredient.provenance == null && + ingredient.hash == null && + ingredient.relationship == "parentOf" && + ingredient.validationStatus.isEmpty() && + ingredient.thumbnail == null + + TestResult( + "Ingredient defaults", + success, + if (success) "Ingredient defaults correct" else "Ingredient defaults incorrect" + ) + } + } + + suspend fun testThumbnailDataClass(): TestResult = withContext(Dispatchers.IO) { + runTest("Thumbnail data class") { + val thumbnail = Thumbnail( + format = C2PAFormats.PNG, + identifier = "thumbnail.png", + contentType = "image/png" + ) + + val success = thumbnail.format == C2PAFormats.PNG && + thumbnail.identifier == "thumbnail.png" && + thumbnail.contentType == "image/png" + + TestResult( + "Thumbnail data class", + success, + if (success) "Thumbnail properties correct" else "Thumbnail properties incorrect" + ) + } + } + + suspend fun testThumbnailDefaultContentType(): TestResult = withContext(Dispatchers.IO) { + runTest("Thumbnail default content type") { + val thumbnail = Thumbnail( + format = C2PAFormats.JPEG, + identifier = "thumb.jpg" + ) + + val success = thumbnail.contentType == "image/jpeg" + + TestResult( + "Thumbnail default content type", + success, + if (success) "Default content type is image/jpeg" else "Default content type should be image/jpeg" + ) + } + } + + suspend fun testActionDataClass(): TestResult = withContext(Dispatchers.IO) { + runTest("Action data class") { + val softwareAgent = SoftwareAgent("TestApp", "1.0", "Android") + val changes = listOf(ActionChange("brightness", "Increased by 10%")) + val params = mapOf("intensity" to 10) + + val action = Action( + action = C2PAActions.EDITED, + whenTimestamp = "2024-01-01T00:00:00Z", + softwareAgent = softwareAgent, + changes = changes, + reason = "User edit", + parameters = params, + digitalSourceType = DigitalSourceTypes.HUMAN_EDITS + ) + + val success = action.action == C2PAActions.EDITED && + action.whenTimestamp == "2024-01-01T00:00:00Z" && + action.softwareAgent == softwareAgent && + action.changes == changes && + action.reason == "User edit" && + action.parameters == params && + action.digitalSourceType == DigitalSourceTypes.HUMAN_EDITS + + TestResult( + "Action data class", + success, + if (success) "Action properties correct" else "Action properties incorrect" + ) + } + } + + suspend fun testActionDefaults(): TestResult = withContext(Dispatchers.IO) { + runTest("Action defaults") { + val action = Action(action = C2PAActions.CREATED) + + val success = action.action == C2PAActions.CREATED && + action.whenTimestamp == null && + action.softwareAgent == null && + action.changes.isEmpty() && + action.reason == null && + action.parameters.isEmpty() && + action.digitalSourceType == null + + TestResult( + "Action defaults", + success, + if (success) "Action defaults correct" else "Action defaults incorrect" + ) + } + } + + suspend fun testSoftwareAgentDataClass(): TestResult = withContext(Dispatchers.IO) { + runTest("SoftwareAgent data class") { + val agent = SoftwareAgent( + name = "MyApp", + version = "2.0.1", + operatingSystem = "Android 14" + ) + + val success = agent.name == "MyApp" && + agent.version == "2.0.1" && + agent.operatingSystem == "Android 14" + + TestResult( + "SoftwareAgent data class", + success, + if (success) "SoftwareAgent properties correct" else "SoftwareAgent properties incorrect" + ) + } + } + + suspend fun testActionChangeDataClass(): TestResult = withContext(Dispatchers.IO) { + runTest("ActionChange data class") { + val change = ActionChange( + field = "contrast", + description = "Decreased by 5%" + ) + + val success = change.field == "contrast" && + change.description == "Decreased by 5%" + + TestResult( + "ActionChange data class", + success, + if (success) "ActionChange properties correct" else "ActionChange properties incorrect" + ) + } + } + + // ==================== ManifestBuilder Method Tests ==================== + + suspend fun testManifestBuilderChaining(): TestResult = withContext(Dispatchers.IO) { + runTest("ManifestBuilder method chaining") { + val builder = ManifestBuilder() + .title("Test") + .format(C2PAFormats.JPEG) + .claimGenerator("App", "1.0") + .instanceId("test-id") + .documentId("doc-id") + .producer("Producer") + .timestampAuthorityUrl("http://ts.example.com") + + // If chaining works, builder is returned and build() can be called + val json = builder.build() + val success = json.has("claim_version") + + TestResult( + "ManifestBuilder method chaining", + success, + if (success) "Method chaining works correctly" else "Method chaining failed" + ) + } + } + + suspend fun testManifestBuilderClaimGenerator(): TestResult = withContext(Dispatchers.IO) { + runTest("ManifestBuilder claimGenerator") { + val json = ManifestBuilder() + .claimGenerator("TestApp", "1.0.0", "icon.png") + .build() + + val claimGenArray = json.optJSONArray("claim_generator_info") + val firstGen = claimGenArray?.optJSONObject(0) + + val success = firstGen?.getString("name") == "TestApp" && + firstGen?.getString("version") == "1.0.0" && + firstGen?.getString("icon") == "icon.png" + + TestResult( + "ManifestBuilder claimGenerator", + success, + if (success) "Claim generator with icon correct" else "Claim generator output incorrect", + json.toString(2) + ) + } + } + + suspend fun testManifestBuilderClaimGeneratorWithoutIcon(): TestResult = withContext(Dispatchers.IO) { + runTest("ManifestBuilder claimGenerator without icon") { + val json = ManifestBuilder() + .claimGenerator("TestApp", "2.0.0") + .build() + + val claimGenArray = json.optJSONArray("claim_generator_info") + val firstGen = claimGenArray?.optJSONObject(0) + + val success = firstGen?.getString("name") == "TestApp" && + firstGen?.getString("version") == "2.0.0" && + !firstGen.has("icon") + + TestResult( + "ManifestBuilder claimGenerator without icon", + success, + if (success) "Claim generator without icon correct" else "Should not have icon field", + json.toString(2) + ) + } + } + + suspend fun testManifestBuilderFormat(): TestResult = withContext(Dispatchers.IO) { + runTest("ManifestBuilder format") { + val json = ManifestBuilder() + .format(C2PAFormats.PNG) + .build() + + val success = json.getString("format") == C2PAFormats.PNG + + TestResult( + "ManifestBuilder format", + success, + if (success) "Format set correctly" else "Format not set correctly" + ) + } + } + + suspend fun testManifestBuilderTitle(): TestResult = withContext(Dispatchers.IO) { + runTest("ManifestBuilder title") { + val json = ManifestBuilder() + .title("My Test Image") + .build() + + val success = json.getString("title") == "My Test Image" + + TestResult( + "ManifestBuilder title", + success, + if (success) "Title set correctly" else "Title not set correctly" + ) + } + } + + suspend fun testManifestBuilderInstanceId(): TestResult = withContext(Dispatchers.IO) { + runTest("ManifestBuilder instanceId") { + val customId = "custom-instance-id-12345" + val json = ManifestBuilder() + .instanceId(customId) + .build() + + val success = json.getString("instanceID") == customId + + TestResult( + "ManifestBuilder instanceId", + success, + if (success) "InstanceId set correctly" else "InstanceId not set correctly" + ) + } + } + + suspend fun testManifestBuilderAutoInstanceId(): TestResult = withContext(Dispatchers.IO) { + runTest("ManifestBuilder auto instanceId") { + val json = ManifestBuilder().build() + + val instanceId = json.getString("instanceID") + // UUID format check: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + val uuidPattern = Regex("[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}") + val success = uuidPattern.matches(instanceId) + + TestResult( + "ManifestBuilder auto instanceId", + success, + if (success) "Auto-generated UUID correct" else "Auto-generated UUID format incorrect", + "Generated: $instanceId" + ) + } + } + + suspend fun testManifestBuilderDocumentId(): TestResult = withContext(Dispatchers.IO) { + runTest("ManifestBuilder documentId") { + val json = ManifestBuilder() + .documentId("doc-123") + .build() + + val success = json.getString("documentID") == "doc-123" + + TestResult( + "ManifestBuilder documentId", + success, + if (success) "DocumentId set correctly" else "DocumentId not set correctly" + ) + } + } + + suspend fun testManifestBuilderProducer(): TestResult = withContext(Dispatchers.IO) { + runTest("ManifestBuilder producer") { + val json = ManifestBuilder() + .producer("Test Producer") + .build() + + val success = json.getString("producer") == "Test Producer" + + TestResult( + "ManifestBuilder producer", + success, + if (success) "Producer set correctly" else "Producer not set correctly" + ) + } + } + + suspend fun testManifestBuilderTimestampAuthority(): TestResult = withContext(Dispatchers.IO) { + runTest("ManifestBuilder timestampAuthorityUrl") { + val json = ManifestBuilder() + .timestampAuthorityUrl("http://timestamp.example.com") + .build() + + val success = json.getString("ta_url") == "http://timestamp.example.com" + + TestResult( + "ManifestBuilder timestampAuthorityUrl", + success, + if (success) "TA URL set correctly" else "TA URL not set correctly" + ) + } + } + + suspend fun testManifestBuilderAddIngredient(): TestResult = withContext(Dispatchers.IO) { + runTest("ManifestBuilder addIngredient") { + val ingredient = Ingredient( + title = "Source Image", + format = C2PAFormats.JPEG, + instanceId = "ingredient-id", + documentId = "ingredient-doc", + relationship = C2PARelationships.PARENT_OF + ) + + val json = ManifestBuilder() + .addIngredient(ingredient) + .build() + + val ingredients = json.optJSONArray("ingredients") + val firstIngredient = ingredients?.optJSONObject(0) + + val success = firstIngredient?.getString("title") == "Source Image" && + firstIngredient?.getString("format") == C2PAFormats.JPEG && + firstIngredient?.getString("instanceID") == "ingredient-id" && + firstIngredient?.getString("documentID") == "ingredient-doc" && + firstIngredient?.getString("relationship") == C2PARelationships.PARENT_OF + + TestResult( + "ManifestBuilder addIngredient", + success, + if (success) "Ingredient added correctly" else "Ingredient not added correctly", + json.toString(2) + ) + } + } + + suspend fun testManifestBuilderMultipleIngredients(): TestResult = withContext(Dispatchers.IO) { + runTest("ManifestBuilder multiple ingredients") { + val json = ManifestBuilder() + .addIngredient(Ingredient(title = "Image 1", format = C2PAFormats.JPEG)) + .addIngredient(Ingredient(title = "Image 2", format = C2PAFormats.PNG)) + .addIngredient(Ingredient(title = "Image 3", format = C2PAFormats.WEBP)) + .build() + + val ingredients = json.optJSONArray("ingredients") + val success = ingredients?.length() == 3 && + ingredients.getJSONObject(0).getString("title") == "Image 1" && + ingredients.getJSONObject(1).getString("title") == "Image 2" && + ingredients.getJSONObject(2).getString("title") == "Image 3" + + TestResult( + "ManifestBuilder multiple ingredients", + success, + if (success) "Multiple ingredients added correctly" else "Multiple ingredients not added correctly" + ) + } + } + + suspend fun testManifestBuilderIngredientWithThumbnail(): TestResult = withContext(Dispatchers.IO) { + runTest("ManifestBuilder ingredient with thumbnail") { + val thumbnail = Thumbnail(C2PAFormats.JPEG, "ingredient-thumb.jpg") + val ingredient = Ingredient( + title = "Source", + format = C2PAFormats.JPEG, + thumbnail = thumbnail + ) + + val json = ManifestBuilder() + .addIngredient(ingredient) + .build() + + val ingredients = json.optJSONArray("ingredients") + val thumbJson = ingredients?.optJSONObject(0)?.optJSONObject("thumbnail") + + val success = thumbJson?.getString("format") == C2PAFormats.JPEG && + thumbJson?.getString("identifier") == "ingredient-thumb.jpg" + + TestResult( + "ManifestBuilder ingredient with thumbnail", + success, + if (success) "Ingredient thumbnail correct" else "Ingredient thumbnail incorrect", + json.toString(2) + ) + } + } + + suspend fun testManifestBuilderIngredientWithValidationStatus(): TestResult = withContext(Dispatchers.IO) { + runTest("ManifestBuilder ingredient with validation status") { + val ingredient = Ingredient( + title = "Source", + format = C2PAFormats.JPEG, + validationStatus = listOf("valid", "signed") + ) + + val json = ManifestBuilder() + .addIngredient(ingredient) + .build() + + val ingredients = json.optJSONArray("ingredients") + val statusArray = ingredients?.optJSONObject(0)?.optJSONArray("validationStatus") + + val success = statusArray?.length() == 2 && + statusArray.getString(0) == "valid" && + statusArray.getString(1) == "signed" + + TestResult( + "ManifestBuilder ingredient with validation status", + success, + if (success) "Validation status correct" else "Validation status incorrect" + ) + } + } + + suspend fun testManifestBuilderAddAction(): TestResult = withContext(Dispatchers.IO) { + runTest("ManifestBuilder addAction") { + val action = Action( + action = C2PAActions.CREATED, + whenTimestamp = "2024-01-01T12:00:00Z" + ) + + val json = ManifestBuilder() + .addAction(action) + .build() + + val assertions = json.optJSONArray("assertions") + val actionsAssertion = assertions?.optJSONObject(0) + + val success = actionsAssertion?.getString("label") == "c2pa.actions" && + actionsAssertion?.getJSONObject("data") + ?.getJSONArray("actions") + ?.getJSONObject(0) + ?.getString("action") == C2PAActions.CREATED + + TestResult( + "ManifestBuilder addAction", + success, + if (success) "Action added correctly" else "Action not added correctly", + json.toString(2) + ) + } + } + + suspend fun testManifestBuilderActionWithSoftwareAgent(): TestResult = withContext(Dispatchers.IO) { + runTest("ManifestBuilder action with software agent") { + val agent = SoftwareAgent("TestApp", "1.0", "Android 14") + val action = Action( + action = C2PAActions.EDITED, + whenTimestamp = "2024-01-01T12:00:00Z", + softwareAgent = agent + ) + + val json = ManifestBuilder() + .addAction(action) + .build() + + val assertions = json.optJSONArray("assertions") + val actionJson = assertions?.optJSONObject(0) + ?.getJSONObject("data") + ?.getJSONArray("actions") + ?.getJSONObject(0) + + val agentJson = actionJson?.optJSONObject("softwareAgent") + + val success = agentJson?.getString("name") == "TestApp" && + agentJson?.getString("version") == "1.0" && + agentJson?.getString("operating_system") == "Android 14" + + TestResult( + "ManifestBuilder action with software agent", + success, + if (success) "Software agent correct" else "Software agent incorrect", + json.toString(2) + ) + } + } + + suspend fun testManifestBuilderActionWithChanges(): TestResult = withContext(Dispatchers.IO) { + runTest("ManifestBuilder action with changes") { + val changes = listOf( + ActionChange("brightness", "Increased"), + ActionChange("contrast", "Decreased") + ) + val action = Action( + action = C2PAActions.EDITED, + changes = changes + ) + + val json = ManifestBuilder() + .addAction(action) + .build() + + val actionJson = json.optJSONArray("assertions") + ?.optJSONObject(0) + ?.getJSONObject("data") + ?.getJSONArray("actions") + ?.getJSONObject(0) + + val changesArray = actionJson?.optJSONArray("changes") + + val success = changesArray?.length() == 2 && + changesArray.getJSONObject(0).getString("field") == "brightness" && + changesArray.getJSONObject(1).getString("field") == "contrast" + + TestResult( + "ManifestBuilder action with changes", + success, + if (success) "Action changes correct" else "Action changes incorrect" + ) + } + } + + suspend fun testManifestBuilderActionWithParameters(): TestResult = withContext(Dispatchers.IO) { + runTest("ManifestBuilder action with parameters") { + val params = mapOf( + "filter_name" to "Vintage", + "intensity" to 75 + ) + val action = Action( + action = C2PAActions.FILTERED, + parameters = params + ) + + val json = ManifestBuilder() + .addAction(action) + .build() + + val actionJson = json.optJSONArray("assertions") + ?.optJSONObject(0) + ?.getJSONObject("data") + ?.getJSONArray("actions") + ?.getJSONObject(0) + + val paramsJson = actionJson?.optJSONObject("parameters") + + val success = paramsJson?.getString("filter_name") == "Vintage" && + paramsJson?.getInt("intensity") == 75 + + TestResult( + "ManifestBuilder action with parameters", + success, + if (success) "Action parameters correct" else "Action parameters incorrect" + ) + } + } + + suspend fun testManifestBuilderActionWithDigitalSourceType(): TestResult = withContext(Dispatchers.IO) { + runTest("ManifestBuilder action with digital source type") { + val action = Action( + action = C2PAActions.CREATED, + digitalSourceType = DigitalSourceTypes.DIGITAL_CAPTURE + ) + + val json = ManifestBuilder() + .addAction(action) + .build() + + val actionJson = json.optJSONArray("assertions") + ?.optJSONObject(0) + ?.getJSONObject("data") + ?.getJSONArray("actions") + ?.getJSONObject(0) + + val success = actionJson?.getString("digitalSourceType") == DigitalSourceTypes.DIGITAL_CAPTURE + + TestResult( + "ManifestBuilder action with digital source type", + success, + if (success) "Digital source type correct" else "Digital source type incorrect" + ) + } + } + + suspend fun testManifestBuilderActionWithReason(): TestResult = withContext(Dispatchers.IO) { + runTest("ManifestBuilder action with reason") { + val action = Action( + action = C2PAActions.RECOMPRESSED, + reason = "Social media optimization" + ) + + val json = ManifestBuilder() + .addAction(action) + .build() + + val actionJson = json.optJSONArray("assertions") + ?.optJSONObject(0) + ?.getJSONObject("data") + ?.getJSONArray("actions") + ?.getJSONObject(0) + + val success = actionJson?.getString("reason") == "Social media optimization" + + TestResult( + "ManifestBuilder action with reason", + success, + if (success) "Action reason correct" else "Action reason incorrect" + ) + } + } + + suspend fun testManifestBuilderMultipleActions(): TestResult = withContext(Dispatchers.IO) { + runTest("ManifestBuilder multiple actions") { + val json = ManifestBuilder() + .addAction(Action(action = C2PAActions.OPENED)) + .addAction(Action(action = C2PAActions.EDITED)) + .addAction(Action(action = C2PAActions.RESIZED)) + .build() + + val actionsArray = json.optJSONArray("assertions") + ?.optJSONObject(0) + ?.getJSONObject("data") + ?.getJSONArray("actions") + + val success = actionsArray?.length() == 3 && + actionsArray.getJSONObject(0).getString("action") == C2PAActions.OPENED && + actionsArray.getJSONObject(1).getString("action") == C2PAActions.EDITED && + actionsArray.getJSONObject(2).getString("action") == C2PAActions.RESIZED + + TestResult( + "ManifestBuilder multiple actions", + success, + if (success) "Multiple actions correct" else "Multiple actions incorrect" + ) + } + } + + suspend fun testManifestBuilderAddThumbnail(): TestResult = withContext(Dispatchers.IO) { + runTest("ManifestBuilder addThumbnail") { + val thumbnail = Thumbnail(C2PAFormats.JPEG, "manifest-thumb.jpg") + + val json = ManifestBuilder() + .addThumbnail(thumbnail) + .build() + + val thumbJson = json.optJSONObject("thumbnail") + + val success = thumbJson?.getString("format") == C2PAFormats.JPEG && + thumbJson?.getString("identifier") == "manifest-thumb.jpg" + + TestResult( + "ManifestBuilder addThumbnail", + success, + if (success) "Thumbnail added correctly" else "Thumbnail not added correctly" + ) + } + } + + suspend fun testManifestBuilderAddAssertionWithJsonObject(): TestResult = withContext(Dispatchers.IO) { + runTest("ManifestBuilder addAssertion with JSONObject") { + val customData = JSONObject().apply { + put("custom_field", "custom_value") + put("number_field", 42) + } + + val json = ManifestBuilder() + .addAssertion("custom.assertion", customData) + .build() + + val assertions = json.optJSONArray("assertions") + var foundAssertion: JSONObject? = null + for (i in 0 until (assertions?.length() ?: 0)) { + val assertion = assertions?.getJSONObject(i) + if (assertion?.getString("label") == "custom.assertion") { + foundAssertion = assertion + break + } + } + + val success = foundAssertion?.getJSONObject("data")?.getString("custom_field") == "custom_value" && + foundAssertion?.getJSONObject("data")?.getInt("number_field") == 42 + + TestResult( + "ManifestBuilder addAssertion with JSONObject", + success, + if (success) "JSONObject assertion correct" else "JSONObject assertion incorrect" + ) + } + } + + suspend fun testManifestBuilderAddAssertionWithString(): TestResult = withContext(Dispatchers.IO) { + runTest("ManifestBuilder addAssertion with String") { + val json = ManifestBuilder() + .addAssertion("string.assertion", "test string value") + .build() + + val assertions = json.optJSONArray("assertions") + var foundAssertion: JSONObject? = null + for (i in 0 until (assertions?.length() ?: 0)) { + val assertion = assertions?.getJSONObject(i) + if (assertion?.getString("label") == "string.assertion") { + foundAssertion = assertion + break + } + } + + val success = foundAssertion?.getString("data") == "test string value" + + TestResult( + "ManifestBuilder addAssertion with String", + success, + if (success) "String assertion correct" else "String assertion incorrect" + ) + } + } + + suspend fun testManifestBuilderAddAssertionWithNumber(): TestResult = withContext(Dispatchers.IO) { + runTest("ManifestBuilder addAssertion with Number") { + val json = ManifestBuilder() + .addAssertion("number.assertion", 12345) + .build() + + val assertions = json.optJSONArray("assertions") + var foundAssertion: JSONObject? = null + for (i in 0 until (assertions?.length() ?: 0)) { + val assertion = assertions?.getJSONObject(i) + if (assertion?.getString("label") == "number.assertion") { + foundAssertion = assertion + break + } + } + + val success = foundAssertion?.getInt("data") == 12345 + + TestResult( + "ManifestBuilder addAssertion with Number", + success, + if (success) "Number assertion correct" else "Number assertion incorrect" + ) + } + } + + suspend fun testManifestBuilderAddAssertionWithBoolean(): TestResult = withContext(Dispatchers.IO) { + runTest("ManifestBuilder addAssertion with Boolean") { + val json = ManifestBuilder() + .addAssertion("bool.assertion", true) + .build() + + val assertions = json.optJSONArray("assertions") + var foundAssertion: JSONObject? = null + for (i in 0 until (assertions?.length() ?: 0)) { + val assertion = assertions?.getJSONObject(i) + if (assertion?.getString("label") == "bool.assertion") { + foundAssertion = assertion + break + } + } + + val success = foundAssertion?.getBoolean("data") == true + + TestResult( + "ManifestBuilder addAssertion with Boolean", + success, + if (success) "Boolean assertion correct" else "Boolean assertion incorrect" + ) + } + } + + // ==================== Build Output Tests ==================== + + suspend fun testManifestBuilderBuildClaimVersion(): TestResult = withContext(Dispatchers.IO) { + runTest("ManifestBuilder build claim_version") { + val json = ManifestBuilder().build() + + val success = json.getInt("claim_version") == 1 + + TestResult( + "ManifestBuilder build claim_version", + success, + if (success) "claim_version is 1" else "claim_version should be 1" + ) + } + } + + suspend fun testManifestBuilderBuildJson(): TestResult = withContext(Dispatchers.IO) { + runTest("ManifestBuilder buildJson") { + val builder = ManifestBuilder() + .title("Test") + .format(C2PAFormats.JPEG) + + val jsonString = builder.buildJson() + + // Verify it's valid JSON and formatted (contains newlines) + val success = try { + val parsed = JSONObject(jsonString) + parsed.has("title") && jsonString.contains("\n") + } catch (e: Exception) { + false + } + + TestResult( + "ManifestBuilder buildJson", + success, + if (success) "buildJson produces formatted JSON" else "buildJson output incorrect" + ) + } + } + + suspend fun testManifestBuilderEmptyAssertions(): TestResult = withContext(Dispatchers.IO) { + runTest("ManifestBuilder empty assertions") { + val json = ManifestBuilder() + .title("Test") + .build() + + // Should not have assertions key if no assertions added + val success = !json.has("assertions") + + TestResult( + "ManifestBuilder empty assertions", + success, + if (success) "No assertions array when empty" else "Should not have assertions when empty" + ) + } + } + + suspend fun testManifestBuilderEmptyIngredients(): TestResult = withContext(Dispatchers.IO) { + runTest("ManifestBuilder empty ingredients") { + val json = ManifestBuilder() + .title("Test") + .build() + + // Should not have ingredients key if no ingredients added + val success = !json.has("ingredients") + + TestResult( + "ManifestBuilder empty ingredients", + success, + if (success) "No ingredients array when empty" else "Should not have ingredients when empty" + ) + } + } + + suspend fun testManifestBuilderOptionalFieldsAbsent(): TestResult = withContext(Dispatchers.IO) { + runTest("ManifestBuilder optional fields absent when not set") { + val json = ManifestBuilder().build() + + // These fields should not be present when not set + val success = !json.has("format") && + !json.has("title") && + !json.has("documentID") && + !json.has("producer") && + !json.has("ta_url") && + !json.has("thumbnail") && + !json.has("claim_generator_info") + + TestResult( + "ManifestBuilder optional fields absent when not set", + success, + if (success) "Optional fields correctly absent" else "Optional fields should be absent when not set", + json.toString(2) + ) + } + } + + suspend fun testManifestBuilderCompleteManifest(): TestResult = withContext(Dispatchers.IO) { + runTest("ManifestBuilder complete manifest") { + val agent = SoftwareAgent("TestApp", "1.0", "Android") + val thumbnail = Thumbnail(C2PAFormats.JPEG, "thumb.jpg") + val ingredient = Ingredient( + title = "Source", + format = C2PAFormats.JPEG, + relationship = C2PARelationships.PARENT_OF + ) + val action = Action( + action = C2PAActions.EDITED, + whenTimestamp = "2024-01-01T00:00:00Z", + softwareAgent = agent + ) + + val json = ManifestBuilder() + .title("Complete Test") + .format(C2PAFormats.JPEG) + .claimGenerator("TestApp", "1.0.0") + .instanceId("test-instance") + .documentId("test-document") + .producer("Test Producer") + .timestampAuthorityUrl("http://ts.example.com") + .addThumbnail(thumbnail) + .addIngredient(ingredient) + .addAction(action) + .addAssertion("custom.test", JSONObject().put("test", true)) + .build() + + val success = json.getInt("claim_version") == 1 && + json.getString("title") == "Complete Test" && + json.getString("format") == C2PAFormats.JPEG && + json.getString("instanceID") == "test-instance" && + json.getString("documentID") == "test-document" && + json.getString("producer") == "Test Producer" && + json.getString("ta_url") == "http://ts.example.com" && + json.has("thumbnail") && + json.has("ingredients") && + json.has("assertions") && + json.has("claim_generator_info") + + TestResult( + "ManifestBuilder complete manifest", + success, + if (success) "Complete manifest has all fields" else "Complete manifest missing fields", + json.toString(2) + ) + } + } +} diff --git a/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/ManifestHelpersUnitTests.kt b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/ManifestHelpersUnitTests.kt new file mode 100644 index 0000000..8e4f694 --- /dev/null +++ b/test-shared/src/main/kotlin/org/contentauth/c2pa/test/shared/ManifestHelpersUnitTests.kt @@ -0,0 +1,1015 @@ +package org.contentauth.c2pa.test.shared + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.contentauth.c2pa.manifest.C2PAActions +import org.contentauth.c2pa.manifest.C2PAAssertionTypes +import org.contentauth.c2pa.manifest.C2PAFormats +import org.contentauth.c2pa.manifest.C2PARelationships +import org.contentauth.c2pa.manifest.CAWGIdentityTypes +import org.contentauth.c2pa.manifest.CAWGProviders +import org.contentauth.c2pa.manifest.DigitalSourceTypes +import org.contentauth.c2pa.manifest.IdentityProvider +import org.contentauth.c2pa.manifest.Ingredient +import org.contentauth.c2pa.manifest.ManifestHelpers +import org.contentauth.c2pa.manifest.VerifiedIdentity +import org.json.JSONObject + +/** + * Unit tests for ManifestHelpers factory methods + * Tests all helper functions for creating manifest builders + */ +abstract class ManifestHelpersUnitTests : TestBase() { + + // ==================== createBasicImageManifest Tests ==================== + + suspend fun testCreateBasicImageManifestMinimal(): TestResult = withContext(Dispatchers.IO) { + runTest("createBasicImageManifest minimal") { + val builder = ManifestHelpers.createBasicImageManifest(title = "Test Image") + val json = builder.build() + + val success = json.getString("title") == "Test Image" && + json.getString("format") == C2PAFormats.JPEG && + json.has("claim_generator_info") + + TestResult( + "createBasicImageManifest minimal", + success, + if (success) "Basic image manifest created" else "Basic image manifest incorrect", + json.toString(2) + ) + } + } + + suspend fun testCreateBasicImageManifestWithFormat(): TestResult = withContext(Dispatchers.IO) { + runTest("createBasicImageManifest with format") { + val builder = ManifestHelpers.createBasicImageManifest( + title = "PNG Image", + format = C2PAFormats.PNG + ) + val json = builder.build() + + val success = json.getString("format") == C2PAFormats.PNG + + TestResult( + "createBasicImageManifest with format", + success, + if (success) "Format set correctly" else "Format not set correctly" + ) + } + } + + suspend fun testCreateBasicImageManifestWithClaimGenerator(): TestResult = withContext(Dispatchers.IO) { + runTest("createBasicImageManifest with claim generator") { + val builder = ManifestHelpers.createBasicImageManifest( + title = "Test", + claimGeneratorName = "CustomApp", + claimGeneratorVersion = "2.0.0" + ) + val json = builder.build() + val claimGenInfo = json.optJSONArray("claim_generator_info")?.optJSONObject(0) + + val success = claimGenInfo?.getString("name") == "CustomApp" && + claimGenInfo?.getString("version") == "2.0.0" + + TestResult( + "createBasicImageManifest with claim generator", + success, + if (success) "Claim generator set correctly" else "Claim generator not set correctly" + ) + } + } + + suspend fun testCreateBasicImageManifestWithTimestampAuthority(): TestResult = withContext(Dispatchers.IO) { + runTest("createBasicImageManifest with timestamp authority") { + val builder = ManifestHelpers.createBasicImageManifest( + title = "Test", + timestampAuthorityUrl = "http://timestamp.example.com" + ) + val json = builder.build() + + val success = json.getString("ta_url") == "http://timestamp.example.com" + + TestResult( + "createBasicImageManifest with timestamp authority", + success, + if (success) "Timestamp authority set correctly" else "Timestamp authority not set correctly" + ) + } + } + + suspend fun testCreateBasicImageManifestDefaultClaimGenerator(): TestResult = withContext(Dispatchers.IO) { + runTest("createBasicImageManifest default claim generator") { + val builder = ManifestHelpers.createBasicImageManifest(title = "Test") + val json = builder.build() + val claimGenInfo = json.optJSONArray("claim_generator_info")?.optJSONObject(0) + + val success = claimGenInfo?.getString("name") == "Android C2PA SDK" && + claimGenInfo?.getString("version") == "1.0.0" + + TestResult( + "createBasicImageManifest default claim generator", + success, + if (success) "Default claim generator correct" else "Default claim generator incorrect" + ) + } + } + + // ==================== createImageEditManifest Tests ==================== + + suspend fun testCreateImageEditManifestMinimal(): TestResult = withContext(Dispatchers.IO) { + runTest("createImageEditManifest minimal") { + val builder = ManifestHelpers.createImageEditManifest( + title = "Edited Image", + originalIngredientTitle = "Original.jpg" + ) + val json = builder.build() + + val success = json.getString("title") == "Edited Image" && + json.has("ingredients") && + json.has("assertions") + + TestResult( + "createImageEditManifest minimal", + success, + if (success) "Edit manifest created" else "Edit manifest incorrect", + json.toString(2) + ) + } + } + + suspend fun testCreateImageEditManifestIngredient(): TestResult = withContext(Dispatchers.IO) { + runTest("createImageEditManifest ingredient") { + val builder = ManifestHelpers.createImageEditManifest( + title = "Edited", + originalIngredientTitle = "Original.jpg", + originalFormat = C2PAFormats.PNG + ) + val json = builder.build() + val ingredients = json.optJSONArray("ingredients") + val firstIngredient = ingredients?.optJSONObject(0) + + val success = firstIngredient?.getString("title") == "Original.jpg" && + firstIngredient?.getString("format") == C2PAFormats.PNG && + firstIngredient?.getString("relationship") == C2PARelationships.PARENT_OF + + TestResult( + "createImageEditManifest ingredient", + success, + if (success) "Ingredient set correctly" else "Ingredient incorrect" + ) + } + } + + suspend fun testCreateImageEditManifestAction(): TestResult = withContext(Dispatchers.IO) { + runTest("createImageEditManifest action") { + val builder = ManifestHelpers.createImageEditManifest( + title = "Edited", + originalIngredientTitle = "Original.jpg" + ) + val json = builder.build() + val assertions = json.optJSONArray("assertions") + + var hasEditAction = false + for (i in 0 until (assertions?.length() ?: 0)) { + val assertion = assertions?.getJSONObject(i) + if (assertion?.getString("label") == "c2pa.actions") { + val actions = assertion.getJSONObject("data").getJSONArray("actions") + for (j in 0 until actions.length()) { + if (actions.getJSONObject(j).getString("action") == C2PAActions.EDITED) { + hasEditAction = true + } + } + } + } + + TestResult( + "createImageEditManifest action", + hasEditAction, + if (hasEditAction) "Edit action present" else "Edit action missing" + ) + } + } + + // ==================== createPhotoManifest Tests ==================== + + suspend fun testCreatePhotoManifestMinimal(): TestResult = withContext(Dispatchers.IO) { + runTest("createPhotoManifest minimal") { + val builder = ManifestHelpers.createPhotoManifest( + title = "Photo.jpg", + deviceName = "Pixel 8" + ) + val json = builder.build() + + val success = json.getString("title") == "Photo.jpg" && + json.getString("format") == C2PAFormats.JPEG + + TestResult( + "createPhotoManifest minimal", + success, + if (success) "Photo manifest created" else "Photo manifest incorrect" + ) + } + } + + suspend fun testCreatePhotoManifestWithAuthor(): TestResult = withContext(Dispatchers.IO) { + runTest("createPhotoManifest with author") { + val builder = ManifestHelpers.createPhotoManifest( + title = "Photo.jpg", + authorName = "John Photographer", + deviceName = "Pixel 8" + ) + val json = builder.build() + val assertions = json.optJSONArray("assertions") + + var hasCreativeWork = false + for (i in 0 until (assertions?.length() ?: 0)) { + val assertion = assertions?.getJSONObject(i) + if (assertion?.getString("label") == C2PAAssertionTypes.CREATIVE_WORK) { + val author = assertion.getJSONObject("data").optJSONArray("author")?.optJSONObject(0) + if (author?.getString("name") == "John Photographer") { + hasCreativeWork = true + } + } + } + + TestResult( + "createPhotoManifest with author", + hasCreativeWork, + if (hasCreativeWork) "Author attestation present" else "Author attestation missing" + ) + } + } + + suspend fun testCreatePhotoManifestWithLocation(): TestResult = withContext(Dispatchers.IO) { + runTest("createPhotoManifest with location") { + val location = ManifestHelpers.createLocation(37.7749, -122.4194, "San Francisco") + val builder = ManifestHelpers.createPhotoManifest( + title = "Photo.jpg", + location = location, + deviceName = "Pixel 8" + ) + val json = builder.build() + val assertions = json.optJSONArray("assertions") + + var hasLocation = false + for (i in 0 until (assertions?.length() ?: 0)) { + val assertion = assertions?.getJSONObject(i) + if (assertion?.getString("label") == "c2pa.assertion.metadata") { + val loc = assertion.getJSONObject("data").optJSONObject("location") + if (loc != null && loc.has("latitude")) { + hasLocation = true + } + } + } + + TestResult( + "createPhotoManifest with location", + hasLocation, + if (hasLocation) "Location metadata present" else "Location metadata missing" + ) + } + } + + suspend fun testCreatePhotoManifestCreatedAction(): TestResult = withContext(Dispatchers.IO) { + runTest("createPhotoManifest created action") { + val builder = ManifestHelpers.createPhotoManifest( + title = "Photo.jpg", + deviceName = "Pixel 8" + ) + val json = builder.build() + val assertions = json.optJSONArray("assertions") + + var hasCreatedWithDigitalCapture = false + for (i in 0 until (assertions?.length() ?: 0)) { + val assertion = assertions?.getJSONObject(i) + if (assertion?.getString("label") == "c2pa.actions") { + val actions = assertion.getJSONObject("data").getJSONArray("actions") + for (j in 0 until actions.length()) { + val action = actions.getJSONObject(j) + if (action.getString("action") == C2PAActions.CREATED && + action.optString("digitalSourceType") == DigitalSourceTypes.DIGITAL_CAPTURE) { + hasCreatedWithDigitalCapture = true + } + } + } + } + + TestResult( + "createPhotoManifest created action", + hasCreatedWithDigitalCapture, + if (hasCreatedWithDigitalCapture) "Created action with digital capture" else "Missing created action" + ) + } + } + + // ==================== createVideoEditManifest Tests ==================== + + suspend fun testCreateVideoEditManifestMinimal(): TestResult = withContext(Dispatchers.IO) { + runTest("createVideoEditManifest minimal") { + val builder = ManifestHelpers.createVideoEditManifest( + title = "Edited Video", + originalIngredientTitle = "Original.mp4" + ) + val json = builder.build() + + val success = json.getString("title") == "Edited Video" && + json.getString("format") == C2PAFormats.MP4 && + json.has("ingredients") + + TestResult( + "createVideoEditManifest minimal", + success, + if (success) "Video edit manifest created" else "Video edit manifest incorrect" + ) + } + } + + suspend fun testCreateVideoEditManifestWithEditActions(): TestResult = withContext(Dispatchers.IO) { + runTest("createVideoEditManifest with edit actions") { + val editActions = listOf(C2PAActions.CROPPED, C2PAActions.RESIZED) + val builder = ManifestHelpers.createVideoEditManifest( + title = "Edited Video", + originalIngredientTitle = "Original.mp4", + editActions = editActions + ) + val json = builder.build() + val assertions = json.optJSONArray("assertions") + + var hasCropped = false + var hasResized = false + for (i in 0 until (assertions?.length() ?: 0)) { + val assertion = assertions?.getJSONObject(i) + if (assertion?.getString("label") == "c2pa.actions") { + val actions = assertion.getJSONObject("data").getJSONArray("actions") + for (j in 0 until actions.length()) { + val actionName = actions.getJSONObject(j).getString("action") + if (actionName == C2PAActions.CROPPED) hasCropped = true + if (actionName == C2PAActions.RESIZED) hasResized = true + } + } + } + + val success = hasCropped && hasResized + + TestResult( + "createVideoEditManifest with edit actions", + success, + if (success) "Edit actions present" else "Edit actions missing" + ) + } + } + + suspend fun testCreateVideoEditManifestOpenedAction(): TestResult = withContext(Dispatchers.IO) { + runTest("createVideoEditManifest opened action") { + val builder = ManifestHelpers.createVideoEditManifest( + title = "Edited", + originalIngredientTitle = "Original.mp4" + ) + val json = builder.build() + val assertions = json.optJSONArray("assertions") + + var hasOpened = false + for (i in 0 until (assertions?.length() ?: 0)) { + val assertion = assertions?.getJSONObject(i) + if (assertion?.getString("label") == "c2pa.actions") { + val actions = assertion.getJSONObject("data").getJSONArray("actions") + for (j in 0 until actions.length()) { + if (actions.getJSONObject(j).getString("action") == C2PAActions.OPENED) { + hasOpened = true + } + } + } + } + + TestResult( + "createVideoEditManifest opened action", + hasOpened, + if (hasOpened) "Opened action present" else "Opened action missing" + ) + } + } + + // ==================== createCompositeManifest Tests ==================== + + suspend fun testCreateCompositeManifestMinimal(): TestResult = withContext(Dispatchers.IO) { + runTest("createCompositeManifest minimal") { + val ingredients = listOf( + Ingredient(title = "Image 1", format = C2PAFormats.JPEG), + Ingredient(title = "Image 2", format = C2PAFormats.PNG) + ) + val builder = ManifestHelpers.createCompositeManifest( + title = "Composite", + ingredients = ingredients + ) + val json = builder.build() + + val success = json.getString("title") == "Composite" && + json.optJSONArray("ingredients")?.length() == 2 + + TestResult( + "createCompositeManifest minimal", + success, + if (success) "Composite manifest created" else "Composite manifest incorrect" + ) + } + } + + suspend fun testCreateCompositeManifestActions(): TestResult = withContext(Dispatchers.IO) { + runTest("createCompositeManifest actions") { + val ingredients = listOf( + Ingredient(title = "Image 1", format = C2PAFormats.JPEG), + Ingredient(title = "Image 2", format = C2PAFormats.PNG) + ) + val builder = ManifestHelpers.createCompositeManifest( + title = "Composite", + ingredients = ingredients + ) + val json = builder.build() + val assertions = json.optJSONArray("assertions") + + var createdCount = 0 + var placedCount = 0 + for (i in 0 until (assertions?.length() ?: 0)) { + val assertion = assertions?.getJSONObject(i) + if (assertion?.getString("label") == "c2pa.actions") { + val actions = assertion.getJSONObject("data").getJSONArray("actions") + for (j in 0 until actions.length()) { + val actionName = actions.getJSONObject(j).getString("action") + if (actionName == C2PAActions.CREATED) createdCount++ + if (actionName == C2PAActions.PLACED) placedCount++ + } + } + } + + // Should have 1 created action and 2 placed actions (one per ingredient) + val success = createdCount == 1 && placedCount == 2 + + TestResult( + "createCompositeManifest actions", + success, + if (success) "Composite actions correct" else "Expected 1 created, 2 placed. Got $createdCount created, $placedCount placed" + ) + } + } + + // ==================== createScreenshotManifest Tests ==================== + + suspend fun testCreateScreenshotManifestMinimal(): TestResult = withContext(Dispatchers.IO) { + runTest("createScreenshotManifest minimal") { + val builder = ManifestHelpers.createScreenshotManifest(deviceName = "Pixel 8") + val json = builder.build() + + val success = json.getString("title") == "Screenshot" && + json.getString("format") == C2PAFormats.PNG && + json.getString("producer") == "Pixel 8" + + TestResult( + "createScreenshotManifest minimal", + success, + if (success) "Screenshot manifest created" else "Screenshot manifest incorrect" + ) + } + } + + suspend fun testCreateScreenshotManifestWithAppName(): TestResult = withContext(Dispatchers.IO) { + runTest("createScreenshotManifest with app name") { + val builder = ManifestHelpers.createScreenshotManifest( + deviceName = "Pixel 8", + appName = "Browser" + ) + val json = builder.build() + val assertions = json.optJSONArray("assertions") + + var hasSourceApp = false + for (i in 0 until (assertions?.length() ?: 0)) { + val assertion = assertions?.getJSONObject(i) + if (assertion?.getString("label") == "c2pa.assertion.metadata") { + val data = assertion.getJSONObject("data") + if (data.optString("source_application") == "Browser") { + hasSourceApp = true + } + } + } + + TestResult( + "createScreenshotManifest with app name", + hasSourceApp, + if (hasSourceApp) "Source app present" else "Source app missing" + ) + } + } + + suspend fun testCreateScreenshotManifestAction(): TestResult = withContext(Dispatchers.IO) { + runTest("createScreenshotManifest action") { + val builder = ManifestHelpers.createScreenshotManifest(deviceName = "Pixel 8") + val json = builder.build() + val assertions = json.optJSONArray("assertions") + + var hasScreenCapture = false + for (i in 0 until (assertions?.length() ?: 0)) { + val assertion = assertions?.getJSONObject(i) + if (assertion?.getString("label") == "c2pa.actions") { + val actions = assertion.getJSONObject("data").getJSONArray("actions") + for (j in 0 until actions.length()) { + val action = actions.getJSONObject(j) + if (action.getString("action") == C2PAActions.CREATED && + action.optString("digitalSourceType") == DigitalSourceTypes.SCREEN_CAPTURE) { + hasScreenCapture = true + } + } + } + } + + TestResult( + "createScreenshotManifest action", + hasScreenCapture, + if (hasScreenCapture) "Screen capture action present" else "Screen capture action missing" + ) + } + } + + // ==================== createSocialMediaShareManifest Tests ==================== + + suspend fun testCreateSocialMediaShareManifestMinimal(): TestResult = withContext(Dispatchers.IO) { + runTest("createSocialMediaShareManifest minimal") { + val builder = ManifestHelpers.createSocialMediaShareManifest( + originalTitle = "Original.jpg", + platform = "Instagram" + ) + val json = builder.build() + + val success = json.getString("title") == "Shared on Instagram" && + json.has("ingredients") + + TestResult( + "createSocialMediaShareManifest minimal", + success, + if (success) "Social share manifest created" else "Social share manifest incorrect" + ) + } + } + + suspend fun testCreateSocialMediaShareManifestActions(): TestResult = withContext(Dispatchers.IO) { + runTest("createSocialMediaShareManifest actions") { + val builder = ManifestHelpers.createSocialMediaShareManifest( + originalTitle = "Original.jpg", + platform = "Twitter" + ) + val json = builder.build() + val assertions = json.optJSONArray("assertions") + + var hasRecompressed = false + var hasPublished = false + for (i in 0 until (assertions?.length() ?: 0)) { + val assertion = assertions?.getJSONObject(i) + if (assertion?.getString("label") == "c2pa.actions") { + val actions = assertion.getJSONObject("data").getJSONArray("actions") + for (j in 0 until actions.length()) { + val action = actions.getJSONObject(j) + if (action.getString("action") == C2PAActions.RECOMPRESSED) hasRecompressed = true + if (action.getString("action") == C2PAActions.PUBLISHED) hasPublished = true + } + } + } + + val success = hasRecompressed && hasPublished + + TestResult( + "createSocialMediaShareManifest actions", + success, + if (success) "Share actions present" else "Share actions missing" + ) + } + } + + // ==================== createFilteredImageManifest Tests ==================== + + suspend fun testCreateFilteredImageManifestMinimal(): TestResult = withContext(Dispatchers.IO) { + runTest("createFilteredImageManifest minimal") { + val builder = ManifestHelpers.createFilteredImageManifest( + originalTitle = "Original.jpg", + filterName = "Vintage" + ) + val json = builder.build() + + val success = json.getString("title") == "Original.jpg (Filtered)" && + json.has("ingredients") + + TestResult( + "createFilteredImageManifest minimal", + success, + if (success) "Filtered image manifest created" else "Filtered image manifest incorrect" + ) + } + } + + suspend fun testCreateFilteredImageManifestFilterParameters(): TestResult = withContext(Dispatchers.IO) { + runTest("createFilteredImageManifest filter parameters") { + val builder = ManifestHelpers.createFilteredImageManifest( + originalTitle = "Original.jpg", + filterName = "Sepia" + ) + val json = builder.build() + val assertions = json.optJSONArray("assertions") + + var hasFilterParams = false + for (i in 0 until (assertions?.length() ?: 0)) { + val assertion = assertions?.getJSONObject(i) + if (assertion?.getString("label") == "c2pa.actions") { + val actions = assertion.getJSONObject("data").getJSONArray("actions") + for (j in 0 until actions.length()) { + val action = actions.getJSONObject(j) + if (action.getString("action") == C2PAActions.FILTERED) { + val params = action.optJSONObject("parameters") + if (params?.getString("filter_name") == "Sepia") { + hasFilterParams = true + } + } + } + } + } + + TestResult( + "createFilteredImageManifest filter parameters", + hasFilterParams, + if (hasFilterParams) "Filter parameters present" else "Filter parameters missing" + ) + } + } + + // ==================== createCreatorVerifiedManifest Tests ==================== + + suspend fun testCreateCreatorVerifiedManifestMinimal(): TestResult = withContext(Dispatchers.IO) { + runTest("createCreatorVerifiedManifest minimal") { + val builder = ManifestHelpers.createCreatorVerifiedManifest(title = "Verified.jpg") + val json = builder.build() + + val success = json.getString("title") == "Verified.jpg" && + json.has("assertions") + + TestResult( + "createCreatorVerifiedManifest minimal", + success, + if (success) "Creator verified manifest created" else "Creator verified manifest incorrect" + ) + } + } + + suspend fun testCreateCreatorVerifiedManifestWithIdentities(): TestResult = withContext(Dispatchers.IO) { + runTest("createCreatorVerifiedManifest with identities") { + val identities = listOf( + VerifiedIdentity( + type = CAWGIdentityTypes.SOCIAL_MEDIA, + username = "testuser", + uri = "https://instagram.com/testuser", + verifiedAt = "2024-01-01T00:00:00Z", + provider = IdentityProvider(CAWGProviders.INSTAGRAM, "instagram") + ) + ) + val builder = ManifestHelpers.createCreatorVerifiedManifest( + title = "Verified.jpg", + creatorIdentities = identities + ) + val json = builder.build() + val assertions = json.optJSONArray("assertions") + + var hasCAWGIdentity = false + for (i in 0 until (assertions?.length() ?: 0)) { + val assertion = assertions?.getJSONObject(i) + if (assertion?.getString("label") == C2PAAssertionTypes.CAWG_IDENTITY) { + val identitiesArray = assertion.getJSONObject("data").optJSONArray("verifiedIdentities") + if (identitiesArray?.length() == 1) { + hasCAWGIdentity = true + } + } + } + + TestResult( + "createCreatorVerifiedManifest with identities", + hasCAWGIdentity, + if (hasCAWGIdentity) "CAWG identity present" else "CAWG identity missing" + ) + } + } + + suspend fun testCreateCreatorVerifiedManifestWithAuthorAndDevice(): TestResult = withContext(Dispatchers.IO) { + runTest("createCreatorVerifiedManifest with author and device") { + val builder = ManifestHelpers.createCreatorVerifiedManifest( + title = "Verified.jpg", + authorName = "Test Author", + deviceName = "Pixel 8" + ) + val json = builder.build() + val assertions = json.optJSONArray("assertions") + + var hasAuthor = false + var hasDevice = false + for (i in 0 until (assertions?.length() ?: 0)) { + val assertion = assertions?.getJSONObject(i) + val label = assertion?.getString("label") + if (label == C2PAAssertionTypes.CREATIVE_WORK) { + val authors = assertion.getJSONObject("data").optJSONArray("author") + if (authors?.optJSONObject(0)?.getString("name") == "Test Author") { + hasAuthor = true + } + } + if (label == "c2pa.assertion.metadata") { + if (assertion.getJSONObject("data").optString("device") == "Pixel 8") { + hasDevice = true + } + } + } + + val success = hasAuthor && hasDevice + + TestResult( + "createCreatorVerifiedManifest with author and device", + success, + if (success) "Author and device present" else "Author or device missing" + ) + } + } + + // ==================== createSocialMediaCreatorManifest Tests ==================== + + suspend fun testCreateSocialMediaCreatorManifestInstagram(): TestResult = withContext(Dispatchers.IO) { + runTest("createSocialMediaCreatorManifest Instagram") { + val builder = ManifestHelpers.createSocialMediaCreatorManifest( + title = "Post.jpg", + platform = "instagram", + username = "creator123" + ) + val json = builder.build() + val assertions = json.optJSONArray("assertions") + + var hasInstagramIdentity = false + for (i in 0 until (assertions?.length() ?: 0)) { + val assertion = assertions?.getJSONObject(i) + if (assertion?.getString("label") == C2PAAssertionTypes.CAWG_IDENTITY) { + val identities = assertion.getJSONObject("data").optJSONArray("verifiedIdentities") + if (identities?.length() == 1) { + val identity = identities.getJSONObject(0) + if (identity.getString("uri").contains("instagram.com/creator123")) { + hasInstagramIdentity = true + } + } + } + } + + TestResult( + "createSocialMediaCreatorManifest Instagram", + hasInstagramIdentity, + if (hasInstagramIdentity) "Instagram identity present" else "Instagram identity missing" + ) + } + } + + suspend fun testCreateSocialMediaCreatorManifestTwitter(): TestResult = withContext(Dispatchers.IO) { + runTest("createSocialMediaCreatorManifest Twitter") { + val builder = ManifestHelpers.createSocialMediaCreatorManifest( + title = "Tweet.jpg", + platform = "twitter", + username = "tweeter123" + ) + val json = builder.build() + val assertions = json.optJSONArray("assertions") + + var hasTwitterIdentity = false + for (i in 0 until (assertions?.length() ?: 0)) { + val assertion = assertions?.getJSONObject(i) + if (assertion?.getString("label") == C2PAAssertionTypes.CAWG_IDENTITY) { + val identities = assertion.getJSONObject("data").optJSONArray("verifiedIdentities") + if (identities?.length() == 1) { + val identity = identities.getJSONObject(0) + if (identity.getString("uri").contains("twitter.com/tweeter123")) { + hasTwitterIdentity = true + } + } + } + } + + TestResult( + "createSocialMediaCreatorManifest Twitter", + hasTwitterIdentity, + if (hasTwitterIdentity) "Twitter identity present" else "Twitter identity missing" + ) + } + } + + suspend fun testCreateSocialMediaCreatorManifestX(): TestResult = withContext(Dispatchers.IO) { + runTest("createSocialMediaCreatorManifest X (Twitter)") { + val builder = ManifestHelpers.createSocialMediaCreatorManifest( + title = "Post.jpg", + platform = "x", + username = "xuser123" + ) + val json = builder.build() + val assertions = json.optJSONArray("assertions") + + var hasXIdentity = false + for (i in 0 until (assertions?.length() ?: 0)) { + val assertion = assertions?.getJSONObject(i) + if (assertion?.getString("label") == C2PAAssertionTypes.CAWG_IDENTITY) { + val identities = assertion.getJSONObject("data").optJSONArray("verifiedIdentities") + if (identities?.length() == 1) { + val identity = identities.getJSONObject(0) + if (identity.getString("uri").contains("twitter.com/xuser123")) { + hasXIdentity = true + } + } + } + } + + TestResult( + "createSocialMediaCreatorManifest X (Twitter)", + hasXIdentity, + if (hasXIdentity) "X identity present (using Twitter)" else "X identity missing" + ) + } + } + + suspend fun testCreateSocialMediaCreatorManifestGitHub(): TestResult = withContext(Dispatchers.IO) { + runTest("createSocialMediaCreatorManifest GitHub") { + val builder = ManifestHelpers.createSocialMediaCreatorManifest( + title = "Code.jpg", + platform = "github", + username = "dev123" + ) + val json = builder.build() + val assertions = json.optJSONArray("assertions") + + var hasGitHubIdentity = false + for (i in 0 until (assertions?.length() ?: 0)) { + val assertion = assertions?.getJSONObject(i) + if (assertion?.getString("label") == C2PAAssertionTypes.CAWG_IDENTITY) { + val identities = assertion.getJSONObject("data").optJSONArray("verifiedIdentities") + if (identities?.length() == 1) { + val identity = identities.getJSONObject(0) + if (identity.getString("uri").contains("github.com/dev123")) { + hasGitHubIdentity = true + } + } + } + } + + TestResult( + "createSocialMediaCreatorManifest GitHub", + hasGitHubIdentity, + if (hasGitHubIdentity) "GitHub identity present" else "GitHub identity missing" + ) + } + } + + suspend fun testCreateSocialMediaCreatorManifestCustomPlatform(): TestResult = withContext(Dispatchers.IO) { + runTest("createSocialMediaCreatorManifest custom platform") { + val builder = ManifestHelpers.createSocialMediaCreatorManifest( + title = "Post.jpg", + platform = "mastodon", + username = "user123" + ) + val json = builder.build() + val assertions = json.optJSONArray("assertions") + + var hasCustomIdentity = false + for (i in 0 until (assertions?.length() ?: 0)) { + val assertion = assertions?.getJSONObject(i) + if (assertion?.getString("label") == C2PAAssertionTypes.CAWG_IDENTITY) { + val identities = assertion.getJSONObject("data").optJSONArray("verifiedIdentities") + if (identities?.length() == 1) { + val identity = identities.getJSONObject(0) + if (identity.getString("uri").contains("mastodon.com/user123")) { + hasCustomIdentity = true + } + } + } + } + + TestResult( + "createSocialMediaCreatorManifest custom platform", + hasCustomIdentity, + if (hasCustomIdentity) "Custom platform identity present" else "Custom platform identity missing" + ) + } + } + + // ==================== Location Helper Tests ==================== + + suspend fun testCreateLocation(): TestResult = withContext(Dispatchers.IO) { + runTest("createLocation") { + val location = ManifestHelpers.createLocation(37.7749, -122.4194, "San Francisco") + + val success = location.getString("@type") == "Place" && + location.getDouble("latitude") == 37.7749 && + location.getDouble("longitude") == -122.4194 && + location.getString("name") == "San Francisco" + + TestResult( + "createLocation", + success, + if (success) "Location created correctly" else "Location incorrect" + ) + } + } + + suspend fun testCreateLocationWithoutName(): TestResult = withContext(Dispatchers.IO) { + runTest("createLocation without name") { + val location = ManifestHelpers.createLocation(40.7128, -74.0060) + + val success = location.getString("@type") == "Place" && + location.getDouble("latitude") == 40.7128 && + location.getDouble("longitude") == -74.0060 && + !location.has("name") + + TestResult( + "createLocation without name", + success, + if (success) "Location without name correct" else "Location without name incorrect" + ) + } + } + + suspend fun testCreateGeoLocation(): TestResult = withContext(Dispatchers.IO) { + runTest("createGeoLocation") { + val geoLoc = ManifestHelpers.createGeoLocation( + latitude = 37.7749, + longitude = -122.4194, + altitude = 10.5, + accuracy = 5.0 + ) + + val success = geoLoc.getString("@type") == "GeoCoordinates" && + geoLoc.getDouble("latitude") == 37.7749 && + geoLoc.getDouble("longitude") == -122.4194 && + geoLoc.getDouble("elevation") == 10.5 && + geoLoc.getDouble("accuracy") == 5.0 + + TestResult( + "createGeoLocation", + success, + if (success) "GeoLocation created correctly" else "GeoLocation incorrect" + ) + } + } + + suspend fun testCreateGeoLocationMinimal(): TestResult = withContext(Dispatchers.IO) { + runTest("createGeoLocation minimal") { + val geoLoc = ManifestHelpers.createGeoLocation( + latitude = 37.7749, + longitude = -122.4194 + ) + + val success = geoLoc.getString("@type") == "GeoCoordinates" && + geoLoc.getDouble("latitude") == 37.7749 && + geoLoc.getDouble("longitude") == -122.4194 && + !geoLoc.has("elevation") && + !geoLoc.has("accuracy") + + TestResult( + "createGeoLocation minimal", + success, + if (success) "Minimal GeoLocation correct" else "Minimal GeoLocation incorrect" + ) + } + } + + // ==================== addStandardThumbnail Tests ==================== + + suspend fun testAddStandardThumbnail(): TestResult = withContext(Dispatchers.IO) { + runTest("addStandardThumbnail") { + val builder = ManifestHelpers.createBasicImageManifest(title = "Test") + ManifestHelpers.addStandardThumbnail(builder) + val json = builder.build() + + val thumb = json.optJSONObject("thumbnail") + + val success = thumb?.getString("format") == C2PAFormats.JPEG && + thumb?.getString("identifier") == "thumbnail.jpg" + + TestResult( + "addStandardThumbnail", + success, + if (success) "Standard thumbnail added" else "Standard thumbnail incorrect" + ) + } + } + + suspend fun testAddStandardThumbnailCustomIdentifier(): TestResult = withContext(Dispatchers.IO) { + runTest("addStandardThumbnail custom identifier") { + val builder = ManifestHelpers.createBasicImageManifest(title = "Test") + ManifestHelpers.addStandardThumbnail(builder, thumbnailIdentifier = "custom_thumb.png", format = C2PAFormats.PNG) + val json = builder.build() + + val thumb = json.optJSONObject("thumbnail") + + val success = thumb?.getString("format") == C2PAFormats.PNG && + thumb?.getString("identifier") == "custom_thumb.png" + + TestResult( + "addStandardThumbnail custom identifier", + success, + if (success) "Custom thumbnail added" else "Custom thumbnail incorrect" + ) + } + } +} From b391b36d3733a8e6095edb94436941672d96e684 Mon Sep 17 00:00:00 2001 From: n8fr8 Date: Thu, 11 Dec 2025 15:02:50 -0500 Subject: [PATCH 7/9] remove incorrect Assertion - const val DATA_HASH = "c2pa.data_hash" --- .../src/main/kotlin/org/contentauth/c2pa/manifest/C2PAActions.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/library/src/main/kotlin/org/contentauth/c2pa/manifest/C2PAActions.kt b/library/src/main/kotlin/org/contentauth/c2pa/manifest/C2PAActions.kt index 3e03e34..a02d9af 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/manifest/C2PAActions.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/manifest/C2PAActions.kt @@ -34,7 +34,6 @@ object C2PAAssertionTypes { const val ACTIONS = "c2pa.actions" const val ASSERTION_METADATA = "c2pa.assertion.metadata" const val THUMBNAIL = "c2pa.thumbnail" - const val DATA_HASH = "c2pa.data_hash" const val HASH_DATA = "c2pa.hash.data" const val BMFF_HASH = "c2pa.hash.bmff" const val EXIF = "stds.exif" From 49bf22b85d577f084a004953154220578cc203d4 Mon Sep 17 00:00:00 2001 From: n8fr8 Date: Thu, 11 Dec 2025 15:24:25 -0500 Subject: [PATCH 8/9] you can now specify multiple claim generators --- .../c2pa/manifest/ManifestBuilder.kt | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/library/src/main/kotlin/org/contentauth/c2pa/manifest/ManifestBuilder.kt b/library/src/main/kotlin/org/contentauth/c2pa/manifest/ManifestBuilder.kt index 0b2102b..c12b5a2 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/manifest/ManifestBuilder.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/manifest/ManifestBuilder.kt @@ -50,7 +50,7 @@ data class ActionChange( ) class ManifestBuilder { - private var claimGenerator: ClaimGenerator? = null + private var claimGenerators = mutableListOf() private var format: String? = null private var title: String? = null private var instanceId: String = UUID.randomUUID().toString() @@ -63,7 +63,7 @@ class ManifestBuilder { private var taUrl: String? = null fun claimGenerator(name: String, version: String, icon: String? = null): ManifestBuilder { - this.claimGenerator = ClaimGenerator(name, version, icon) + claimGenerators.add(ClaimGenerator(name, version, icon)) return this } @@ -134,13 +134,17 @@ class ManifestBuilder { producer?.let { manifest.put("producer", it) } // Add claim generator info as array - claimGenerator?.let { generator -> + claimGenerators?.let { generators -> manifest.put("claim_generator_info", JSONArray().apply { - put(JSONObject().apply { - put("name", generator.name) - put("version", generator.version) - generator.icon?.let { put("icon", it) } - }) + generators.forEach { generator -> + put( + JSONObject().apply { + put("name", generator.name) + put("version", generator.version) + generator.icon?.let { put("icon", it) } + } + ) + } }) } From 27dab76ee44d0cfea9df43e1eae495f85d1565d4 Mon Sep 17 00:00:00 2001 From: n8fr8 Date: Thu, 11 Dec 2025 15:24:38 -0500 Subject: [PATCH 9/9] fixes issue with duplicate JNA library conflicts --- library/build.gradle.kts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/library/build.gradle.kts b/library/build.gradle.kts index 2bf5eae..580838c 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -74,6 +74,15 @@ android { kotlinOptions { jvmTarget = "17" } testOptions { unitTests { isIncludeAndroidResources = true } } + + packaging { + resources { + excludes += listOf( + "META-INF/AL2.0", + "META-INF/LGPL2.1" + ) + } + } } // Set the base name for the AAR file @@ -86,7 +95,11 @@ dependencies { implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.serialization.json) implementation(libs.okhttp) - implementation(libs.jna) + implementation(libs.jna) { + artifact { + type = "aar" + } + } // BouncyCastle for CSR generation implementation(libs.bcprov.jdk15to18)