Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ ANY KIND, either express or implied. See the LICENSE-MIT and LICENSE-APACHE
files for the specific language governing permissions and limitations under
each license.
*/

package org.contentauth.c2pa

import android.content.Context
Expand Down Expand Up @@ -151,4 +152,113 @@ class AndroidManifestTests : ManifestTests() {
val result = testAllDigitalSourceTypes()
assertTrue(result.success, "All Digital Source Types test failed: ${result.message}")
}

@Test
fun runTestManifestValidator() = runBlocking {
val result = testManifestValidator()
assertTrue(result.success, "Manifest Validator test failed: ${result.message}")
}

@Test
fun runTestMixedAssertionTypes() = runBlocking {
val result = testMixedAssertionTypes()
assertTrue(result.success, "Mixed Assertion Types test failed: ${result.message}")
}

@Test
fun runTestDeprecatedAssertionValidation() = runBlocking {
val result = testDeprecatedAssertionValidation()
assertTrue(result.success, "Deprecated Assertion Validation test failed: ${result.message}")
}

@Test
fun runTestAllPredefinedActions() = runBlocking {
val result = testAllPredefinedActions()
assertTrue(result.success, "All Predefined Actions test failed: ${result.message}")
}

@Test
fun runTestAllIngredientRelationships() = runBlocking {
val result = testAllIngredientRelationships()
assertTrue(result.success, "All Ingredient Relationships test failed: ${result.message}")
}

@Test
fun runTestRedactions() = runBlocking {
val result = testRedactions()
assertTrue(result.success, "Redactions test failed: ${result.message}")
}


@Test
fun runTestCawgTrainingMiningAssertion() = runBlocking {
val result = testCawgTrainingMiningAssertion()
assertTrue(result.success, "CAWG Training Mining Assertion test failed: ${result.message}")
}

@Test
fun runTestEditedFactory() = runBlocking {
val result = testEditedFactory()
assertTrue(result.success, "Edited Factory test failed: ${result.message}")
}

@Test
fun runTestAssertionsWithBuilder() = runBlocking {
val result = testAssertionsWithBuilder()
assertTrue(result.success, "Assertions with Builder test failed: ${result.message}")
}

@Test
fun runTestCustomGatheredAssertionWithBuilder() = runBlocking {
val result = testCustomGatheredAssertionWithBuilder()
assertTrue(result.success, "Custom Gathered Assertion with Builder test failed: ${result.message}")
}

@Test
fun runTestManifestValidatorDeprecatedAssertions() = runBlocking {
val result = testManifestValidatorDeprecatedAssertions()
assertTrue(result.success, "Manifest Validator Deprecated Assertions test failed: ${result.message}")
}

@Test
fun runTestDigitalSourceTypeFromIptcUrl() = runBlocking {
val result = testDigitalSourceTypeFromIptcUrl()
assertTrue(result.success, "DigitalSourceType fromIptcUrl test failed: ${result.message}")
}

@Test
fun runTestManifestAssertionLabels() = runBlocking {
val result = testManifestAssertionLabels()
assertTrue(result.success, "ManifestDefinition assertionLabels test failed: ${result.message}")
}

@Test
fun runTestManifestToPrettyJson() = runBlocking {
val result = testManifestToPrettyJson()
assertTrue(result.success, "ManifestDefinition toPrettyJson test failed: ${result.message}")
}

@Test
fun runTestIptcPhotoMetadata() = runBlocking {
val result = testIptcPhotoMetadata()
assertTrue(result.success, "IptcPhotoMetadata test failed: ${result.message}")
}

@Test
fun runTestCustomAssertionLabelValidation() = runBlocking {
val result = testCustomAssertionLabelValidation()
assertTrue(result.success, "Custom Assertion Label Validation test failed: ${result.message}")
}

@Test
fun runTestImageRegionTypeToTypeString() = runBlocking {
val result = testImageRegionTypeToTypeString()
assertTrue(result.success, "ImageRegionType toTypeString test failed: ${result.message}")
}

@Test
fun runTestStandardAssertionLabelSerialNames() = runBlocking {
val result = testStandardAssertionLabelSerialNames()
assertTrue(result.success, "StandardAssertionLabel serialNames test failed: ${result.message}")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,22 @@ ANY KIND, either express or implied. See the LICENSE-MIT and LICENSE-APACHE
files for the specific language governing permissions and limitations under
each license.
*/

package org.contentauth.c2pa

import android.content.Context
import org.contentauth.c2pa.test.shared.TestBase
import java.io.File

/**
* Helper object for loading test resources in Android instrumented tests.
*
* Resolves resource names by trying common file extensions (`.jpg`, `.pem`, `.key`, `.toml`,
* `.json`) against the shared test-resource classpath.
*/
object ResourceTestHelper {

/** Loads a test resource as a [ByteArray], trying common file extensions. */
fun loadResourceAsBytes(resourceName: String): ByteArray {
val sharedResource =
TestBase.loadSharedResourceAsBytes("$resourceName.jpg")
Expand All @@ -28,6 +36,7 @@ object ResourceTestHelper {
return sharedResource ?: throw IllegalArgumentException("Resource not found: $resourceName")
}

/** Loads a test resource as a [String], trying common file extensions. */
fun loadResourceAsString(resourceName: String): String {
val sharedResource =
TestBase.loadSharedResourceAsString("$resourceName.jpg")
Expand All @@ -39,6 +48,7 @@ object ResourceTestHelper {
return sharedResource ?: throw IllegalArgumentException("Resource not found: $resourceName")
}

/** Copies a test resource to a [File] in the given [context]'s files directory. */
fun copyResourceToFile(context: Context, resourceName: String, fileName: String): File {
val file = File(context.filesDir, fileName)
val resourceBytes = loadResourceAsBytes(resourceName)
Expand Down
65 changes: 23 additions & 42 deletions library/src/main/kotlin/org/contentauth/c2pa/Action.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,27 +12,33 @@ each license.

package org.contentauth.c2pa

import org.json.JSONObject
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put

/**
* Represents a C2PA action that describes an operation performed on content.
*
* Actions are used to document the editing history of an asset, such as cropping, filtering, or
* color adjustments.
* color adjustments. This class is used with the imperative [Builder.addAction] API.
*
* In C2PA v2, `softwareAgent` may be either a plain string (v1 format) or a
* `generator-info-map` object (v2 format), represented here as [JsonElement].
*
* @property action The action name. Use [PredefinedAction] values or custom action strings.
* @property digitalSourceType A URL identifying an IPTC digital source type. Use
* [DigitalSourceType] values or custom URLs.
* @property softwareAgent The software or hardware used to perform the action.
* @property softwareAgent The software or hardware used to perform the action (string or object).
* @property parameters Additional information describing the action.
* @see Builder.addAction
* @see PredefinedAction
*/
data class Action(
val action: String,
val digitalSourceType: String? = null,
val softwareAgent: String? = null,
val parameters: Map<String, String>? = null,
val softwareAgent: JsonElement? = null,
val parameters: Map<String, JsonElement>? = null,
) {
/**
* Creates an action using a [PredefinedAction] and [DigitalSourceType].
Expand All @@ -46,11 +52,11 @@ data class Action(
action: PredefinedAction,
digitalSourceType: DigitalSourceType,
softwareAgent: String? = null,
parameters: Map<String, String>? = null,
parameters: Map<String, JsonElement>? = null,
) : this(
action = action.value,
digitalSourceType = digitalSourceType.toIptcUrl(),
softwareAgent = softwareAgent,
softwareAgent = softwareAgent?.let { JsonPrimitive(it) },
parameters = parameters,
)

Expand All @@ -64,47 +70,22 @@ data class Action(
constructor(
action: PredefinedAction,
softwareAgent: String? = null,
parameters: Map<String, String>? = null,
parameters: Map<String, JsonElement>? = null,
) : this(
action = action.value,
digitalSourceType = null,
softwareAgent = softwareAgent,
softwareAgent = softwareAgent?.let { JsonPrimitive(it) },
parameters = parameters,
)

internal fun toJson(): String {
val json = JSONObject()
json.put("action", action)
digitalSourceType?.let { json.put("digitalSourceType", it) }
softwareAgent?.let { json.put("softwareAgent", it) }
internal fun toJson(): String = buildJsonObject {
put("action", action)
digitalSourceType?.let { put("digitalSourceType", it) }
softwareAgent?.let { put("softwareAgent", it) }
parameters?.let { params ->
val paramsJson = JSONObject()
params.forEach { (key, value) -> paramsJson.put(key, value) }
json.put("parameters", paramsJson)
put("parameters", buildJsonObject {
params.forEach { (key, value) -> put(key, value) }
})
}
return json.toString()
}
}.toString()
}

private fun DigitalSourceType.toIptcUrl(): String =
when (this) {
DigitalSourceType.EMPTY -> "http://c2pa.org/digitalsourcetype/empty"
DigitalSourceType.TRAINED_ALGORITHMIC_DATA -> "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicData"
DigitalSourceType.DIGITAL_CAPTURE -> "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture"
DigitalSourceType.COMPUTATIONAL_CAPTURE -> "http://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
DigitalSourceType.NEGATIVE_FILM -> "http://cv.iptc.org/newscodes/digitalsourcetype/negativeFilm"
DigitalSourceType.POSITIVE_FILM -> "http://cv.iptc.org/newscodes/digitalsourcetype/positiveFilm"
DigitalSourceType.PRINT -> "http://cv.iptc.org/newscodes/digitalsourcetype/print"
DigitalSourceType.HUMAN_EDITS -> "http://cv.iptc.org/newscodes/digitalsourcetype/humanEdits"
DigitalSourceType.COMPOSITE_WITH_TRAINED_ALGORITHMIC_MEDIA -> "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia"
DigitalSourceType.ALGORITHMICALLY_ENHANCED -> "http://cv.iptc.org/newscodes/digitalsourcetype/algorithmicallyEnhanced"
DigitalSourceType.DIGITAL_CREATION -> "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"
DigitalSourceType.DATA_DRIVEN_MEDIA -> "http://cv.iptc.org/newscodes/digitalsourcetype/dataDrivenMedia"
DigitalSourceType.TRAINED_ALGORITHMIC_MEDIA -> "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"
DigitalSourceType.ALGORITHMIC_MEDIA -> "http://cv.iptc.org/newscodes/digitalsourcetype/algorithmicMedia"
DigitalSourceType.SCREEN_CAPTURE -> "http://cv.iptc.org/newscodes/digitalsourcetype/screenCapture"
DigitalSourceType.VIRTUAL_RECORDING -> "http://cv.iptc.org/newscodes/digitalsourcetype/virtualRecording"
DigitalSourceType.COMPOSITE -> "http://cv.iptc.org/newscodes/digitalsourcetype/composite"
DigitalSourceType.COMPOSITE_CAPTURE -> "http://cv.iptc.org/newscodes/digitalsourcetype/compositeCapture"
DigitalSourceType.COMPOSITE_SYNTHETIC -> "http://cv.iptc.org/newscodes/digitalsourcetype/compositeSynthetic"
}
37 changes: 25 additions & 12 deletions library/src/main/kotlin/org/contentauth/c2pa/Builder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ each license.
package org.contentauth.c2pa

import java.io.Closeable
import org.contentauth.c2pa.manifest.ManifestValidator

/**
* C2PA Builder for creating and signing manifest stores.
Expand Down Expand Up @@ -116,26 +117,36 @@ class Builder internal constructor(private var ptr: Long) : Closeable {
}

/**
* Default assertion labels that should be placed in `created_assertions`.
* Default assertion labels that are attributed to the signer (created assertions).
*
* These are assertions that are typically generated by the signing application
* and should be attributed to the signer per the C2PA 2.3 specification.
* Override by passing a custom [C2PASettings] to [fromJson(String, C2PASettings)].
* The C2PA 2.3 spec distinguishes between "created" assertions (attributed to the
* signer) and "gathered" assertions (from other workflow components, not attributed
* to the signer). Assertions whose labels match this list are marked as created;
* all others are treated as gathered.
*
* Note: CAWG identity assertions (`cawg.identity`) cannot be added via the manifest
* definition. They are dynamic assertions generated at signing time when a CAWG X.509
* signer is configured in the settings (`cawg_x509_signer` section).
*
* To customize, use [fromJson] with a [C2PASettings] that includes your own
* `builder.created_assertion_labels` setting.
*/
val DEFAULT_CREATED_ASSERTION_LABELS: List<String> = listOf(
"c2pa.actions",
"c2pa.actions.v2",
"c2pa.thumbnail.claim",
"c2pa.thumbnail.ingredient",
"c2pa.ingredient",
"c2pa.ingredient.v3",
)

/**
* Creates a builder from a manifest definition in JSON format.
*
* This method automatically configures the SDK to place common assertions
* (actions, thumbnails, metadata) in `created_assertions` as intended by
* most applications. CAWG identity assertions are correctly placed in
* `gathered_assertions` per the CAWG specification.
* This method automatically configures the SDK with [DEFAULT_CREATED_ASSERTION_LABELS]
* to mark common assertions (actions, thumbnails, ingredients) as created assertions.
* Assertions with labels not in the list are automatically treated as gathered
* assertions.
*
* For full control over settings, use [fromJson(String, C2PASettings)].
*
Expand Down Expand Up @@ -166,8 +177,9 @@ class Builder internal constructor(private var ptr: Long) : Closeable {
@JvmStatic
@Throws(C2PAError::class)
fun fromJson(manifestJSON: String): Builder {
if (manifestJSON.isBlank()) {
throw C2PAError.Api("Manifest JSON must not be empty")
val validation = ManifestValidator.validateJson(manifestJSON, logWarnings = true)
if (validation.hasErrors()) {
throw C2PAError.Api(validation.errors.joinToString("; "))
}

val labelsArray = DEFAULT_CREATED_ASSERTION_LABELS.joinToString(", ") { "\"$it\"" }
Expand Down Expand Up @@ -265,8 +277,9 @@ class Builder internal constructor(private var ptr: Long) : Closeable {
@JvmStatic
@Throws(C2PAError::class)
fun fromJson(manifestJSON: String, settings: C2PASettings): Builder {
if (manifestJSON.isBlank()) {
throw C2PAError.Api("Manifest JSON must not be empty")
val validation = ManifestValidator.validateJson(manifestJSON, logWarnings = true)
if (validation.hasErrors()) {
throw C2PAError.Api(validation.errors.joinToString("; "))
}

val context = C2PAContext.fromSettings(settings)
Expand Down
40 changes: 40 additions & 0 deletions library/src/main/kotlin/org/contentauth/c2pa/Intent.kt
Original file line number Diff line number Diff line change
Expand Up @@ -159,4 +159,44 @@ enum class DigitalSourceType {
COMPOSITE_CAPTURE -> 17
COMPOSITE_SYNTHETIC -> 18
}

/**
* Converts this digital source type to its corresponding IPTC URL.
*
* @return The IPTC URL representing this digital source type.
*/
fun toIptcUrl(): String =
when (this) {
EMPTY -> "http://c2pa.org/digitalsourcetype/empty"
TRAINED_ALGORITHMIC_DATA -> "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicData"
DIGITAL_CAPTURE -> "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture"
COMPUTATIONAL_CAPTURE -> "http://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
NEGATIVE_FILM -> "http://cv.iptc.org/newscodes/digitalsourcetype/negativeFilm"
POSITIVE_FILM -> "http://cv.iptc.org/newscodes/digitalsourcetype/positiveFilm"
PRINT -> "http://cv.iptc.org/newscodes/digitalsourcetype/print"
HUMAN_EDITS -> "http://cv.iptc.org/newscodes/digitalsourcetype/humanEdits"
COMPOSITE_WITH_TRAINED_ALGORITHMIC_MEDIA -> "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia"
ALGORITHMICALLY_ENHANCED -> "http://cv.iptc.org/newscodes/digitalsourcetype/algorithmicallyEnhanced"
DIGITAL_CREATION -> "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"
DATA_DRIVEN_MEDIA -> "http://cv.iptc.org/newscodes/digitalsourcetype/dataDrivenMedia"
TRAINED_ALGORITHMIC_MEDIA -> "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"
ALGORITHMIC_MEDIA -> "http://cv.iptc.org/newscodes/digitalsourcetype/algorithmicMedia"
SCREEN_CAPTURE -> "http://cv.iptc.org/newscodes/digitalsourcetype/screenCapture"
VIRTUAL_RECORDING -> "http://cv.iptc.org/newscodes/digitalsourcetype/virtualRecording"
COMPOSITE -> "http://cv.iptc.org/newscodes/digitalsourcetype/composite"
COMPOSITE_CAPTURE -> "http://cv.iptc.org/newscodes/digitalsourcetype/compositeCapture"
COMPOSITE_SYNTHETIC -> "http://cv.iptc.org/newscodes/digitalsourcetype/compositeSynthetic"
}

companion object {
private val urlToType = entries.associateBy { it.toIptcUrl() }

/**
* Parses an IPTC URL to its corresponding DigitalSourceType.
*
* @param url The IPTC URL to parse.
* @return The corresponding DigitalSourceType, or null if not recognized.
*/
fun fromIptcUrl(url: String): DigitalSourceType? = urlToType[url]
}
}
Loading
Loading