diff --git a/.idea/copilot.data.migration.ask2agent.xml b/.idea/copilot.data.migration.ask2agent.xml new file mode 100644 index 0000000..1f2ea11 --- /dev/null +++ b/.idea/copilot.data.migration.ask2agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index 620ba8f..b268ef3 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -5,24 +5,6 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml index 171d7dc..70ff638 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -14,6 +14,7 @@ diff --git a/.idea/markdown.xml b/.idea/markdown.xml new file mode 100644 index 0000000..c61ea33 --- /dev/null +++ b/.idea/markdown.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index b2c751a..9ae1f4a 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,6 +1,7 @@ + - + diff --git a/core/src/main/java/com/altude/core/Programs/AttestationProgram.kt b/core/src/main/java/com/altude/core/Programs/AttestationProgram.kt new file mode 100644 index 0000000..66c44c6 --- /dev/null +++ b/core/src/main/java/com/altude/core/Programs/AttestationProgram.kt @@ -0,0 +1,187 @@ +package com.altude.core.Programs + +import com.altude.core.Programs.Utility.SYSTEM_PROGRAM_ID +import foundation.metaplex.solana.transactions.AccountMeta +import foundation.metaplex.solana.transactions.TransactionInstruction +import foundation.metaplex.solanapublickeys.PublicKey +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.charset.StandardCharsets + +/** + * Solana Attestation Service (SAS) Program instructions. + * + * Program ID: 22zoJMtdu5rJOmEMCqaYzDVLEqJa4FcFBGmrnEcUhbCr + * Reference: https://github.com/solana-attestation-service + * + * SAS uses an Anchor 8-byte discriminator derived from the instruction name. + * Discriminators are precomputed sha256("global:")[0..8]. + */ +object AttestationProgram { + + /** On-chain program address for the Solana Attestation Service. */ + val PROGRAM_ID = PublicKey("22zoJMtdu5rJOmEMCqaYzDVLEqJa4FcFBGmrnEcUhbCr") + + // ── Anchor discriminators (sha256("global:")[0..8] little-endian) ─── + // Pre-computed from the SAS IDL. + private val DISC_CREATE_SCHEMA = byteArrayOf(102.toByte(), 82.toByte(), 205.toByte(), 109.toByte(), 56.toByte(), 206.toByte(), 253.toByte(), 189.toByte()) + private val DISC_CREATE_ATTESTATION = byteArrayOf( 71.toByte(), 149.toByte(), 77.toByte(), 206.toByte(), 166.toByte(), 86.toByte(), 252.toByte(), 185.toByte()) + private val DISC_REVOKE_ATTESTATION = byteArrayOf(243.toByte(), 175.toByte(), 179.toByte(), 26.toByte(), 67.toByte(), 213.toByte(), 154.toByte(), 97.toByte()) + + // ── PDA seeds ───────────────────────────────────────────────────────────── + + /** Derives the Schema PDA: seeds = ["schema", authority, name] */ + suspend fun deriveSchemaAddress(authority: PublicKey, name: String): PublicKey { + val seeds = listOf( + "schema".toByteArray(StandardCharsets.UTF_8), + authority.toByteArray(), + name.toByteArray(StandardCharsets.UTF_8) + ) + return PublicKey.findProgramAddress(seeds, PROGRAM_ID).address + } + + /** + * Derives the Attestation PDA. + * seeds = ["attestation", schema, attester, recipient, nonce] + */ + suspend fun deriveAttestationAddress( + schema: PublicKey, + attester: PublicKey, + recipient: PublicKey, + nonce: String = "" + ): PublicKey { + val seeds = mutableListOf( + "attestation".toByteArray(StandardCharsets.UTF_8), + schema.toByteArray(), + attester.toByteArray(), + recipient.toByteArray() + ) + if (nonce.isNotBlank()) seeds += nonce.toByteArray(StandardCharsets.UTF_8) + return PublicKey.findProgramAddress(seeds, PROGRAM_ID).address + } + + // ── Instruction builders ────────────────────────────────────────────────── + + /** + * Creates a `createSchema` instruction. + * + * Accounts (order matches SAS IDL): + * 0. schema [writable] — PDA, derived by [deriveSchemaAddress] + * 1. authority [writable, signer] — Schema authority + * 2. feePayer [writable, signer] — Rent / fee payer + * 3. systemProgram [read-only] — 11111… + */ + suspend fun createSchema( + authority: PublicKey, + feePayer: PublicKey, + name: String, + description: String, + fieldNames: List, + isRevocable: Boolean + ): TransactionInstruction { + val schemaPda = deriveSchemaAddress(authority, name) + + val nameBytes = name.toByteArray(StandardCharsets.UTF_8) + val descBytes = description.toByteArray(StandardCharsets.UTF_8) + val fieldNamesBytes = fieldNames.map { it.toByteArray(StandardCharsets.UTF_8) } + + // Layout: discriminator(8) + name_len(4) + name + desc_len(4) + desc + // + field_count(4) + [field_len(4) + field]* + is_revocable(1) + val fieldSection = fieldNamesBytes.fold(ByteArray(4).also { + ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN).putInt(fieldNamesBytes.size) + }) { acc, fb -> + val lenBuf = ByteArray(4).also { b -> ByteBuffer.wrap(b).order(ByteOrder.LITTLE_ENDIAN).putInt(fb.size) } + acc + lenBuf + fb + } + + val data = DISC_CREATE_SCHEMA + + encode4(nameBytes.size) + nameBytes + + encode4(descBytes.size) + descBytes + + fieldSection + + byteArrayOf(if (isRevocable) 1 else 0) + + val accounts = listOf( + AccountMeta(schemaPda, isSigner = false, isWritable = true), + AccountMeta(authority, isSigner = true, isWritable = true), + AccountMeta(feePayer, isSigner = true, isWritable = true), + AccountMeta(SYSTEM_PROGRAM_ID, isSigner = false, isWritable = false) + ) + return TransactionInstruction(programId = PROGRAM_ID, keys = accounts, data = data) + } + + /** + * Creates an `attest` instruction. + * + * Accounts: + * 0. attestation [writable] — PDA, derived by [deriveAttestationAddress] + * 1. schema [read-only] — Schema PDA + * 2. attester [writable, signer] + * 3. recipient [read-only] + * 4. feePayer [writable, signer] + * 5. systemProgram + */ + suspend fun createAttestation( + attester: PublicKey, + feePayer: PublicKey, + schemaPda: PublicKey, + recipient: PublicKey, + attestationData: ByteArray, + expireAt: Long = 0L, + nonce: String = "" + ): TransactionInstruction { + val attestationPda = deriveAttestationAddress(schemaPda, attester, recipient, nonce) + + // Layout: discriminator(8) + data_len(4) + data + expire_at(8) + nonce_len(4) + nonce + val nonceBytes = nonce.toByteArray(StandardCharsets.UTF_8) + val data = DISC_CREATE_ATTESTATION + + encode4(attestationData.size) + attestationData + + encode8(expireAt) + + encode4(nonceBytes.size) + nonceBytes + + val accounts = listOf( + AccountMeta(attestationPda, isSigner = false, isWritable = true), + AccountMeta(schemaPda, isSigner = false, isWritable = false), + AccountMeta(attester, isSigner = true, isWritable = true), + AccountMeta(recipient, isSigner = false, isWritable = false), + AccountMeta(feePayer, isSigner = true, isWritable = true), + AccountMeta(SYSTEM_PROGRAM_ID, isSigner = false, isWritable = false) + ) + return TransactionInstruction(programId = PROGRAM_ID, keys = accounts, data = data) + } + + /** + * Creates a `revokeAttestation` instruction. + * + * Accounts: + * 0. attestation [writable] — The Attestation PDA to revoke + * 1. attester [writable, signer] + * 2. feePayer [writable, signer] + * 3. systemProgram + */ + fun revokeAttestation( + attester: PublicKey, + feePayer: PublicKey, + attestationPda: PublicKey + ): TransactionInstruction { + val accounts = listOf( + AccountMeta(attestationPda, isSigner = false, isWritable = true), + AccountMeta(attester, isSigner = true, isWritable = true), + AccountMeta(feePayer, isSigner = true, isWritable = true), + AccountMeta(SYSTEM_PROGRAM_ID, isSigner = false, isWritable = false) + ) + return TransactionInstruction( + programId = PROGRAM_ID, + keys = accounts, + data = DISC_REVOKE_ATTESTATION + ) + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private fun encode4(value: Int): ByteArray = + ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(value).array() + + private fun encode8(value: Long): ByteArray = + ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(value).array() +} + diff --git a/core/src/main/java/com/altude/core/api/TransactionService.kt b/core/src/main/java/com/altude/core/api/TransactionService.kt index 1acabce..a14eca2 100644 --- a/core/src/main/java/com/altude/core/api/TransactionService.kt +++ b/core/src/main/java/com/altude/core/api/TransactionService.kt @@ -1,7 +1,10 @@ package com.altude.core.api +import com.altude.core.data.AttestRequest import com.altude.core.data.BatchTransactionRequest +import com.altude.core.data.CreateSchemaRequest import com.altude.core.data.MintData +import com.altude.core.data.RevokeAttestationRequest import com.altude.core.data.SendTransactionRequest import com.altude.core.data.SwapTransactionRequest import kotlinx.serialization.Contextual @@ -111,6 +114,23 @@ interface TransactionService { @Body body: ISendTransactionRequest ): Call + // ── Solana Attestation Service (SAS) ───────────────────────────────────── + + @POST("api/attestation/createSchema") + fun createSchema( + @Body body: CreateSchemaRequest + ): Call + + @POST("api/attestation/attest") + fun attest( + @Body body: AttestRequest + ): Call + + @POST("api/attestation/revoke") + fun revokeAttestation( + @Body body: RevokeAttestationRequest + ): Call + @GET("api/transaction/config") fun getConfig(): Call } diff --git a/core/src/main/java/com/altude/core/data/AttestationRequest.kt b/core/src/main/java/com/altude/core/data/AttestationRequest.kt new file mode 100644 index 0000000..fb20fa9 --- /dev/null +++ b/core/src/main/java/com/altude/core/data/AttestationRequest.kt @@ -0,0 +1,28 @@ +package com.altude.core.data + +import kotlinx.serialization.Serializable + +/** + * Request body for POST api/attestation/createSchema + */ +@Serializable +data class CreateSchemaRequest( + val SignedTransaction: String +) + +/** + * Request body for POST api/attestation/attest + */ +@Serializable +data class AttestRequest( + val SignedTransaction: String +) + +/** + * Request body for POST api/attestation/revoke + */ +@Serializable +data class RevokeAttestationRequest( + val SignedTransaction: String +) + diff --git a/core/src/main/java/com/altude/core/service/StorageService.kt b/core/src/main/java/com/altude/core/service/StorageService.kt index 052793a..b53e9ac 100644 --- a/core/src/main/java/com/altude/core/service/StorageService.kt +++ b/core/src/main/java/com/altude/core/service/StorageService.kt @@ -48,6 +48,13 @@ object StorageService { fun init(context : Context) { appContext = context.applicationContext // prevents memory leaks } + + /** + * Exposes the application [Context] to other SDK modules (e.g. provenance). + * Safe to call after [init] has been invoked. + */ + fun getContext(): Context = appContext + private val ANDROID_KEYSTORE = "AndroidKeyStore" private fun getKeyAlias(): String = "altude_store" diff --git a/gasstation/src/main/java/com/altude/gasstation/Altude.kt b/gasstation/src/main/java/com/altude/gasstation/Altude.kt index eee5fee..92dc9d7 100644 --- a/gasstation/src/main/java/com/altude/gasstation/Altude.kt +++ b/gasstation/src/main/java/com/altude/gasstation/Altude.kt @@ -244,6 +244,7 @@ object Altude { } } + suspend fun generateKeyPair(): SolanaKeypair { val keypair = KeyPair.generate() return SolanaKeypair(keypair.publicKey,keypair.secretKey) diff --git a/gasstation/src/main/java/com/altude/gasstation/GaslessManager.kt b/gasstation/src/main/java/com/altude/gasstation/GaslessManager.kt index 77698b8..b1bc1b2 100644 --- a/gasstation/src/main/java/com/altude/gasstation/GaslessManager.kt +++ b/gasstation/src/main/java/com/altude/gasstation/GaslessManager.kt @@ -559,4 +559,5 @@ object GaslessManager { } + } \ No newline at end of file diff --git a/gasstation/src/main/java/com/altude/gasstation/data/AttestationOption.kt b/gasstation/src/main/java/com/altude/gasstation/data/AttestationOption.kt new file mode 100644 index 0000000..f71521d --- /dev/null +++ b/gasstation/src/main/java/com/altude/gasstation/data/AttestationOption.kt @@ -0,0 +1,94 @@ +package com.altude.gasstation.data + +/** + * Options for creating an on-chain attestation using the + * Solana Attestation Service (SAS) program. + * + * Usage: + * ```kotlin + * Altude.attest( + * AttestationOption( + * schemaId = "schema_pubkey_base58", + * recipient = "recipient_wallet_base58", + * data = """{"kyc": true, "level": 2}""".encodeToByteArray() + * ) + * ) + * ``` + * + * @param account Attester wallet. Leave blank to use the current signer. + * @param schemaId The SAS Schema account pubkey (Base58) that describes this attestation. + * @param recipient The account being attested (Base58 pubkey). Defaults to attester if blank. + * @param data Arbitrary bytes to store in the attestation (e.g. JSON-encoded claim data). + * Must not exceed the schema's declared max size. + * @param expireAt Unix timestamp (seconds) when the attestation expires. 0 = no expiry. + * @param nonce Optional unique nonce to allow multiple attestations per (attester, schema, recipient). + * @param commitment Finality commitment to wait for. + */ +data class AttestationOption( + val account: String = "", + val schemaId: String, + val recipient: String = "", + val data: ByteArray = ByteArray(0), + val expireAt: Long = 0L, + val nonce: String = "", + val commitment: Commitment = Commitment.finalized +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is AttestationOption) return false + return account == other.account && + schemaId == other.schemaId && + recipient == other.recipient && + data.contentEquals(other.data) && + expireAt == other.expireAt && + nonce == other.nonce && + commitment == other.commitment + } + + override fun hashCode(): Int { + var result = account.hashCode() + result = 31 * result + schemaId.hashCode() + result = 31 * result + recipient.hashCode() + result = 31 * result + data.contentHashCode() + result = 31 * result + expireAt.hashCode() + result = 31 * result + nonce.hashCode() + result = 31 * result + commitment.hashCode() + return result + } +} + +/** + * Options for revoking a previously created on-chain attestation. + * + * @param account Attester wallet that originally created the attestation. Blank = current signer. + * @param attestationId The on-chain Attestation account pubkey (Base58) to revoke. + * @param commitment Finality commitment to wait for. + */ +data class RevokeAttestationOption( + val account: String = "", + val attestationId: String, + val commitment: Commitment = Commitment.finalized +) + +/** + * Options for creating a new SAS Schema. + * + * A Schema defines the structure (and optional validation rules) of attestation data. + * Create a schema once; reuse its pubkey in every [AttestationOption]. + * + * @param account Schema authority wallet. Blank = current signer. + * @param name Short human-readable schema name (max 32 chars). + * @param description Description of what this schema attests. + * @param fieldNames Ordered list of field names matching the encoded data fields. + * @param isRevocable Whether attestations using this schema can be revoked. + * @param commitment Finality commitment. + */ +data class CreateSchemaOption( + val account: String = "", + val name: String, + val description: String = "", + val fieldNames: List = emptyList(), + val isRevocable: Boolean = true, + val commitment: Commitment = Commitment.finalized +) + diff --git a/gasstation/src/main/java/com/altude/gasstation/data/AttestationResponse.kt b/gasstation/src/main/java/com/altude/gasstation/data/AttestationResponse.kt new file mode 100644 index 0000000..21fdf25 --- /dev/null +++ b/gasstation/src/main/java/com/altude/gasstation/data/AttestationResponse.kt @@ -0,0 +1,20 @@ +package com.altude.gasstation.data + +import kotlinx.serialization.Serializable + +/** + * Response returned after a successful attestation operation + * (create, revoke, or schema creation). + */ +@Serializable +data class AttestationResponse( + /** "success" or "error" */ + val Status: String, + /** Human-readable result message. */ + val Message: String, + /** Transaction signature on Solana. */ + val Signature: String, + /** The on-chain Attestation / Schema account pubkey (Base58). Empty for revoke. */ + val attestationId: String = "" +) + diff --git a/provenance/.gitignore b/provenance/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/provenance/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/provenance/build.gradle.kts b/provenance/build.gradle.kts new file mode 100644 index 0000000..03b6993 --- /dev/null +++ b/provenance/build.gradle.kts @@ -0,0 +1,65 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + kotlin("plugin.serialization") version "2.2.0" +} + +android { + namespace = "com.altude.provenance" + compileSdk = 36 + + defaultConfig { + minSdk = 21 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } + } +} + +dependencies { + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + implementation(project(":core")) + + // Networking + implementation(libs.retrofit) + implementation(libs.retrofit.gson) + implementation(libs.okhttp) + implementation(libs.okhttp.logging) + + // Solana & Metaplex + api(libs.solana) { + exclude(group = "com.ditchoom") + exclude(group = "io.github.funkatronics", module = "kborsh") + } + + // Serialization + implementation(libs.serialization.json) { + exclude(group = "com.ditchoom") + } + + // JPEG XMP metadata embedding (C2PA manifest) + implementation("androidx.exifinterface:exifinterface:1.3.7") +} + diff --git a/provenance/consumer-rules.pro b/provenance/consumer-rules.pro new file mode 100644 index 0000000..da2873d --- /dev/null +++ b/provenance/consumer-rules.pro @@ -0,0 +1,3 @@ +# Add consumer ProGuard rules here. +-keep class com.altude.provenance.** { *; } + diff --git a/provenance/proguard-rules.pro b/provenance/proguard-rules.pro new file mode 100644 index 0000000..4fa8113 --- /dev/null +++ b/provenance/proguard-rules.pro @@ -0,0 +1,3 @@ +# Add project specific ProGuard rules here. +-keep class com.altude.provenance.** { *; } + diff --git a/provenance/src/main/java/com/altude/provenance/Provenance.kt b/provenance/src/main/java/com/altude/provenance/Provenance.kt new file mode 100644 index 0000000..ef0cc4b --- /dev/null +++ b/provenance/src/main/java/com/altude/provenance/Provenance.kt @@ -0,0 +1,290 @@ +package com.altude.provenance + +import com.altude.core.config.SdkConfig +import com.altude.core.service.StorageService +import com.altude.provenance.data.AttestationResult +import com.altude.provenance.data.C2paManifest +import com.altude.provenance.data.ImageHashPayload +import com.altude.provenance.data.ImageHashRequest +import com.altude.provenance.data.ImageHashResponse +import com.altude.provenance.data.ManifestOption +import com.altude.provenance.data.ProvenancePrefs +import com.altude.provenance.data.ProvenanceResult +import com.altude.provenance.interfaces.ProvenanceService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.withContext +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.decodeFromJsonElement +import retrofit2.await + +/** + * Public entry point for the Provenance module. + * + * Attests image data on-chain using the Solana Attestation Service (SAS) program. + * Schema is created once per wallet and cached — subsequent calls skip it entirely. + * + * ── Where does filePath come from? ─────────────────────────────────────────── + * + * 1. Camera capture (saves to a File you control): + * ```kotlin + * val file = File(context.filesDir, "photo.jpg") + * val uri = FileProvider.getUriForFile(context, "${context.packageName}.provider", file) + * // pass uri to camera intent via MediaStore.EXTRA_OUTPUT + * // after capture: + * val payload = ImageHashPayload.create(filePath = file.absolutePath, account = wallet) + * ``` + * + * 2. Gallery / document picker (returns a content:// URI): + * ```kotlin + * // in onActivityResult / ActivityResultCallback: + * val uri: Uri = result.data?.data ?: return + * val payload = ImageHashPayload.create(uri = uri, contentResolver = contentResolver, account = wallet) + * ``` + * + * 3. File already on disk (downloads, app storage): + * ```kotlin + * val file = File(context.filesDir, "image.png") + * val payload = ImageHashPayload.create(filePath = file.absolutePath, account = wallet) + * ``` + * + * Single image: + * ```kotlin + * val result = Provenance.attestImageHash(payload) + * val id = result.getOrThrow().attestationId + * ``` + * + * Bulk images (1 schema tx, N attestation txs, sequential): + * ```kotlin + * viewModelScope.launch { + * Provenance.attestBatch(payloads).collect { item -> + * // item.index, item.name, item.hash, item.result + * } + * } + * ``` + */ +object Provenance { + + @OptIn(ExperimentalSerializationApi::class) + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = true + explicitNulls = false + } + + private fun service(): ProvenanceService = + SdkConfig.createService(ProvenanceService::class.java) + + // ── Single image ────────────────────────────────────────────────────────── + + /** + * Attests a single image on-chain. + * + * @param payload Built via [ImageHashPayload.create]. + * @param manifestOption How to save the manifest after attestation. Default: [ManifestOption.SidecarFile]. + * + * Options: + * - [ManifestOption.SidecarFile] — saves `{name}.c2pa.json` in `filesDir/provenance_manifests/` + * - [ManifestOption.EmbedInImage] — embeds manifest into JPEG XMP or PNG tEXt chunk + * - [ManifestOption.Both] — saves sidecar file AND embeds in image + * - [ManifestOption.None] — no file saved; manifest still in [ProvenanceResult.manifest] + */ + @OptIn(ExperimentalCoroutinesApi::class) + suspend fun attestImageHash( + payload: ImageHashPayload, + manifestOption: ManifestOption = ManifestOption.SidecarFile + ): Result = withContext(Dispatchers.IO) { + try { + // 1. Ensure schema + val schemaResult = ProvenanceManager.ensureSchema( + account = payload.account, + commitment = payload.commitment.name + ) + if (schemaResult.isFailure) + return@withContext Result.failure(schemaResult.exceptionOrNull()!!) + + // 2. Derive Schema PDA + val schemaPda = ProvenanceManager.deriveSchemaAddress(payload.account) + + // 3. Sign attestation tx offline + val attestResult = ProvenanceManager.attest(payload, schemaPda) + if (attestResult.isFailure) + return@withContext Result.failure(attestResult.exceptionOrNull()!!) + + // 4. Submit + val request = ImageHashRequest( + type = payload.type, + hash = payload.hash, + mime = payload.mime, + name = payload.name, + timestamp = payload.timestamp, + account = payload.account, + recipient = payload.recipient, + expireAt = payload.expireAt, + manifest = payload.manifest, + signedSchemaTx = schemaResult.getOrNull(), + signedAttestationTx = attestResult.getOrThrow() + ) + val res = service().attestImageHash(request).await() + val response = decodeJson(res) + + // 5. Mark schema confirmed ONLY after backend success + if (response.Status == "success") { + val walletKey = ProvenanceManager.getKeyPair(payload.account).publicKey.toBase58() + ProvenancePrefs.markSchemaCreated(walletKey) + } + + // 6. Apply chosen manifest option + val (manifestFile, embeddedImageFile) = + applyManifestOption(payload.c2paManifest, manifestOption) + + Result.success(ProvenanceResult( + response = response, + manifest = payload.c2paManifest, + manifestFile = manifestFile, + embeddedImageFile = embeddedImageFile + )) + } catch (e: Throwable) { + Result.failure(Exception(e.message ?: e.javaClass.simpleName, e)) + } + } + + // ── Bulk images ─────────────────────────────────────────────────────────── + + /** + * Attests a list of images sequentially — 1 schema tx (first call only per wallet), + * N attestation txs submitted one by one in order. + * + * @param payloads List built via [ImageHashPayload.create]. + * @param manifestOption How to save each manifest. Default: [ManifestOption.SidecarFile]. + * + * ```kotlin + * viewModelScope.launch { + * Provenance.attestBatch(payloads, ManifestOption.EmbedInImage(filePath)).collect { item -> + * val embedded = item.result.getOrNull()?.embeddedImageFile + * val sidecar = item.result.getOrNull()?.manifestFile + * } + * } + * ``` + */ + fun attestBatch( + payloads: List, + manifestOption: ManifestOption = ManifestOption.SidecarFile + ): Flow = flow { + if (payloads.isEmpty()) return@flow + + val first = payloads.first() + + // ── 1. Ensure schema ONCE for the whole batch ───────────────────────── + val schemaResult = ProvenanceManager.ensureSchema( + account = first.account, + commitment = first.commitment.name + ) + if (schemaResult.isFailure) { + payloads.forEachIndexed { i, p -> + emit(AttestationResult(i, p.name, p.hash, + Result.failure(schemaResult.exceptionOrNull()!!))) + } + return@flow + } + + // ── 2. Derive Schema PDA ONCE ───────────────────────────────────────── + val schemaPda = ProvenanceManager.deriveSchemaAddress(first.account) + val walletKey = ProvenanceManager.getKeyPair(first.account).publicKey.toBase58() + + var schemaMarked = ProvenancePrefs.isSchemaCreated(walletKey) + + // ── 3. Sequential loop — sign offline → submit → emit ───────────────── + payloads.forEachIndexed { index, payload -> + val itemResult = runCatching { + val attestedTx = ProvenanceManager.attest(payload, schemaPda).getOrThrow() + + val request = ImageHashRequest( + type = payload.type, + hash = payload.hash, + mime = payload.mime, + name = payload.name, + timestamp = payload.timestamp, + account = payload.account, + recipient = payload.recipient, + expireAt = payload.expireAt, + manifest = payload.manifest, + signedSchemaTx = if (index == 0) schemaResult.getOrNull() else null, + signedAttestationTx = attestedTx + ) + val res = service().attestImageHash(request).await() + val response = decodeJson(res) + + if (!schemaMarked && index == 0 && response.Status == "success") { + ProvenancePrefs.markSchemaCreated(walletKey) + schemaMarked = true + } + + // Apply chosen manifest option per image + val (manifestFile, embeddedImageFile) = + applyManifestOption(payload.c2paManifest, manifestOption) + + ProvenanceResult( + response = response, + manifest = payload.c2paManifest, + manifestFile = manifestFile, + embeddedImageFile = embeddedImageFile + ) + } + emit(AttestationResult(index, payload.name, payload.hash, itemResult)) + } + }.flowOn(Dispatchers.IO) + + // ── Session ─────────────────────────────────────────────────────────────── + + /** + * Clears the schema state (SharedPreferences + in-memory PDA cache) for [account]. + * Call on wallet switch or logout so the next [attestImageHash] re-creates the schema. + */ + suspend fun resetSession(account: String = "") = + ProvenanceManager.resetSchema(account) + + // ── Manifest option helper ──────────────────────────────────────────────── + + /** + * Applies [option] after attestation. Returns (sidecarFile, embeddedImageFile). + * Errors are swallowed to null so a save failure never crashes the attestation. + */ + private fun applyManifestOption( + manifest: C2paManifest, + option: ManifestOption + ): Pair { + val manifestsDir = java.io.File(StorageService.getContext().filesDir, "provenance_manifests") + return when (option) { + is ManifestOption.SidecarFile -> { + val file = runCatching { manifest.saveTo(manifestsDir) }.getOrNull() + Pair(file, null) + } + is ManifestOption.EmbedInImage -> { + val embedded = runCatching { + manifest.embedInto(java.io.File(option.sourceFilePath)) + }.getOrNull() + Pair(null, embedded) + } + is ManifestOption.Both -> { + val sidecar = runCatching { manifest.saveTo(manifestsDir) }.getOrNull() + val embedded = runCatching { + manifest.embedInto(java.io.File(option.sourceFilePath)) + }.getOrNull() + Pair(sidecar, embedded) + } + else -> Pair(null, null) // ManifestOption.None + } + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private inline fun decodeJson(element: JsonElement): T = + json.decodeFromJsonElement(element) +} diff --git a/provenance/src/main/java/com/altude/provenance/ProvenanceManager.kt b/provenance/src/main/java/com/altude/provenance/ProvenanceManager.kt new file mode 100644 index 0000000..d2b7ae9 --- /dev/null +++ b/provenance/src/main/java/com/altude/provenance/ProvenanceManager.kt @@ -0,0 +1,197 @@ +package com.altude.provenance + +import android.util.Base64 +import com.altude.core.Programs.AttestationProgram +import com.altude.core.config.SdkConfig +import com.altude.core.helper.Mnemonic +import com.altude.core.model.AltudeTransactionBuilder +import com.altude.core.model.HotSigner +import com.altude.core.network.AltudeRpc +import com.altude.core.service.StorageService +import com.altude.provenance.data.ImageHashPayload +import com.altude.provenance.data.ProvenancePrefs +import foundation.metaplex.solana.transactions.SerializeConfig +import foundation.metaplex.solanaeddsa.Keypair +import foundation.metaplex.solanaeddsa.SolanaEddsa +import foundation.metaplex.solanapublickeys.PublicKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Internal transaction builder for image-hash provenance operations. + * + * Builds and partially signs both the `createSchema` and `createAttestation` + * transactions, then returns Base64-encoded serialized transactions to be + * forwarded to the backend. + */ +internal object ProvenanceManager { + + /** Fixed schema name used for all image-hash attestations. */ + const val SCHEMA_NAME = "image_hash" + + /** + * Session-level PDA cache: walletBase58 → Schema PDA. + * Avoids re-deriving the PDA on every call within the same app session. + * SharedPreferences ([ProvenancePrefs]) handles persistence across restarts. + */ + private val schemaPdaCache = mutableMapOf() + + private val rpc get() = AltudeRpc(SdkConfig.apiConfig.RpcUrl) + private val feePayerPubKey get() = PublicKey(SdkConfig.apiConfig.FeePayer) + + // ── Keypair resolution ──────────────────────────────────────────────────── + + suspend fun getKeyPair(account: String = ""): Keypair { + val seedData = StorageService.getDecryptedSeed(account) + if (seedData != null) { + if (seedData.type == "mnemonic") return Mnemonic(seedData.mnemonic).getKeyPair() + return if (seedData.privateKey != null) + SolanaEddsa.createKeypairFromSecretKey(seedData.privateKey!!.copyOfRange(0, 32)) + else throw Error("No seed found in storage") + } else throw Error("Please set seed first") + } + + // ── PDA helpers ─────────────────────────────────────────────────────────── + + /** + * Derives the Schema PDA for the fixed [SCHEMA_NAME] and the given [account]. + * Caches the result in [schemaPdaCache] — computed once per session per wallet. + * Keeps [AttestationProgram] fully hidden from callers outside this class. + */ + internal suspend fun deriveSchemaAddress(account: String = ""): PublicKey { + val keypair = getKeyPair(account) + val walletKey = keypair.publicKey.toBase58() + return schemaPdaCache.getOrPut(walletKey) { + AttestationProgram.deriveSchemaAddress( + authority = keypair.publicKey, + name = SCHEMA_NAME + ) + } + } + + // ── Schema — once per wallet ────────────────────────────────────────────── + + /** + * Builds + signs a `createSchema` tx ONLY if not yet confirmed for this wallet. + * + * What `createSchema` does: + * Registers a reusable template on Solana that defines what fields an image + * attestation contains (type, hash, mime, name, timestamp). Created ONCE per + * wallet ever — every image attestation then just references it. Without it, + * the on-chain program rejects all attestations. + * + * Flow: + * - [ProvenancePrefs.isSchemaCreated] == true → return `Result.success(null)` (skip) + * - [ProvenancePrefs.isSchemaCreated] == false → build + sign tx, return `Result.success(signedTx)` + * + * [Provenance] calls [ProvenancePrefs.markSchemaCreated] ONLY after backend confirms success. + * + * @return `Result` — `null` = schema already exists; `String` = signed tx to send. + */ + suspend fun ensureSchema(account: String, commitment: String): Result = + withContext(Dispatchers.IO) { + try { + val keypair = getKeyPair(account) + val walletKey = keypair.publicKey.toBase58() + + // Already confirmed on-chain — skip building the tx entirely + if (ProvenancePrefs.isSchemaCreated(walletKey)) + return@withContext Result.success(null) + + val hotSigner = HotSigner(keypair) + val instruction = AttestationProgram.createSchema( + authority = keypair.publicKey, + feePayer = feePayerPubKey, + name = SCHEMA_NAME, + description = "Stores SHA-256 hash of images", + fieldNames = listOf("type", "hash", "mime", "name", "timestamp"), + isRevocable = true + ) + val blockhash = rpc.getLatestBlockhash(commitment = commitment).blockhash + val tx = AltudeTransactionBuilder() + .setFeePayer(feePayerPubKey) + .setRecentBlockHash(blockhash) + .addInstruction(instruction) + .setSigners(listOf(hotSigner)) + .build() + val serialized = Base64.encodeToString( + tx.serialize(SerializeConfig(requireAllSignatures = false)), + Base64.NO_WRAP + ) + Result.success(serialized) + } catch (e: Throwable) { + Result.failure(Exception(e.message ?: e.javaClass.simpleName, e)) + } + } + + /** + * Clears schema state for [account] — both in-memory cache and SharedPreferences. + * Call via [Provenance.resetSession] on wallet switch or logout. + */ + suspend fun resetSchema(account: String = "") { + runCatching { + val walletKey = getKeyPair(account).publicKey.toBase58() + ProvenancePrefs.reset(walletKey) + schemaPdaCache.remove(walletKey) + } + } + + // ── Attestation ─────────────────────────────────────────────────────────── + + /** + * Builds and signs a `createAttestation` transaction for an [ImageHashPayload]. + * The [schemaPda] must be derived beforehand via [AttestationProgram.deriveSchemaAddress]. + * Returns the Base64-encoded serialized transaction. + */ + suspend fun attest( + payload: ImageHashPayload, + schemaPda: PublicKey + ): Result = withContext(Dispatchers.IO) { + try { + val keypair = getKeyPair(payload.account) + val attester = keypair.publicKey + val hotSigner = HotSigner(keypair) + val recipient = if (payload.recipient.isBlank()) attester + else PublicKey(payload.recipient) + + // Structured JSON payload stored in the on-chain attestation + val payloadJson = """ + { + "type": "${payload.type}", + "hash": "${payload.hash}", + "mime": "${payload.mime}", + "name": "${payload.name}", + "timestamp": ${payload.timestamp} + } + """.trimIndent() + + val instruction = AttestationProgram.createAttestation( + attester = attester, + feePayer = feePayerPubKey, + schemaPda = schemaPda, + recipient = recipient, + attestationData = payloadJson.toByteArray(), + expireAt = payload.expireAt + ) + + val blockhash = rpc.getLatestBlockhash( + commitment = payload.commitment.name + ).blockhash + + val tx = AltudeTransactionBuilder() + .setFeePayer(feePayerPubKey) + .setRecentBlockHash(blockhash) + .addInstruction(instruction) + .setSigners(listOf(hotSigner)) + .build() + + val serialized = Base64.encodeToString( + tx.serialize(SerializeConfig(requireAllSignatures = false)), + Base64.NO_WRAP + ) + Result.success(serialized) + } catch (e: Throwable) { + Result.failure(Exception(e.message ?: e.javaClass.simpleName, e)) + } + } +} diff --git a/provenance/src/main/java/com/altude/provenance/data/AttestationOption.kt b/provenance/src/main/java/com/altude/provenance/data/AttestationOption.kt new file mode 100644 index 0000000..e519f49 --- /dev/null +++ b/provenance/src/main/java/com/altude/provenance/data/AttestationOption.kt @@ -0,0 +1,6 @@ +package com.altude.provenance.data + +/** + * Commitment levels for on-chain confirmation. + */ +enum class Commitment { processed, confirmed, finalized } diff --git a/provenance/src/main/java/com/altude/provenance/data/AttestationResponse.kt b/provenance/src/main/java/com/altude/provenance/data/AttestationResponse.kt new file mode 100644 index 0000000..1b4af18 --- /dev/null +++ b/provenance/src/main/java/com/altude/provenance/data/AttestationResponse.kt @@ -0,0 +1,20 @@ +package com.altude.provenance.data + +import kotlinx.serialization.Serializable + +/** + * Response returned after a successful provenance / attestation operation + * (create schema, attest, or revoke). + */ +@Serializable +data class AttestationResponse( + /** "success" or "error" */ + val Status: String, + /** Human-readable result message. */ + val Message: String, + /** Transaction signature on Solana. */ + val Signature: String, + /** The on-chain Attestation / Schema account pubkey (Base58). Empty for revoke. */ + val attestationId: String = "" +) + diff --git a/provenance/src/main/java/com/altude/provenance/data/C2paManifest.kt b/provenance/src/main/java/com/altude/provenance/data/C2paManifest.kt new file mode 100644 index 0000000..5518e70 --- /dev/null +++ b/provenance/src/main/java/com/altude/provenance/data/C2paManifest.kt @@ -0,0 +1,264 @@ +package com.altude.provenance.data + +import android.graphics.BitmapFactory +import androidx.exifinterface.media.ExifInterface +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.io.ByteArrayOutputStream +import java.io.File +import java.security.MessageDigest + +/** + * A lightweight C2PA-style content credential manifest (POC). + * + * Mirrors the C2PA "claim" structure: + * https://c2pa.org/specifications/specifications/2.0/specs/C2PA_Specification.html + * + * No external C2PA library required — uses only [MessageDigest] + kotlinx.serialization. + * + * The [manifestHash] is the value stored on-chain via `Provenance.attestImageHash`. + * The full [toJson] claim is sent to the backend for off-chain verification. + * + * How it works: + * ``` + * imageFile bytes → SHA-256 → assetHash + * claim JSON (without manifestHash) → SHA-256 → manifestHash ← stored on-chain + * ``` + * + * Usage: + * ```kotlin + * val manifest = C2paManifest.build( + * filePath = "/data/user/0/com.example/files/photo.png", + * mimeType = "image/png", + * producer = walletAddress + * ) + * val payload = ImageHashPayload.fromManifest(manifest) + * val result = Provenance.attestImageHash(payload) + * ``` + */ +@Serializable +data class C2paManifest( + /** C2PA claim type — always "c2pa.hash.data" for this POC. */ + val claimType: String = "c2pa.hash.data", + /** SHA-256 hex of the raw image file bytes. */ + val assetHash: String, + /** MIME type of the asset (e.g. "image/png"). */ + val mimeType: String, + /** Original filename, derived from the file path. */ + val filename: String = "", + /** Who produced/captured this asset (e.g. wallet address or app name). */ + val producer: String = "", + /** Software agent that created this manifest. */ + val softwareAgent: String = "altude-provenance-sdk", + /** Unix epoch (seconds) when the manifest was created. */ + val timestamp: Long = System.currentTimeMillis() / 1000, + /** + * SHA-256 hex of the canonical manifest JSON (without this field). + * This is the value stored on-chain — a tamper-evident hash of the full claim. + */ + val manifestHash: String = "" +) { + companion object { + + private val canonicalJson = Json { + encodeDefaults = true + explicitNulls = false + } + + /** + * Builds a [C2paManifest] from a file path. + * + * Steps: + * 1. Read file bytes from [filePath] + * 2. SHA-256 the raw bytes → [assetHash] + * 3. Build canonical claim JSON (without [manifestHash]) + * 4. SHA-256 the claim JSON → [manifestHash] ← stored on-chain + * + * @param filePath Absolute path to the image file. + * @param mimeType MIME type e.g. `"image/png"`, `"image/jpeg"`. + * @param producer Wallet address or producer identifier. + * @param softwareAgent SDK identifier (default: `"altude-provenance-sdk"`). + * @throws IllegalArgumentException if the file does not exist or cannot be read. + */ + fun build( + filePath: String, + mimeType: String = "image/png", + producer: String = "", + softwareAgent: String = "altude-provenance-sdk" + ): C2paManifest { + val file = File(filePath) + require(file.exists()) { "C2PA: file not found at $filePath" } + require(file.canRead()) { "C2PA: cannot read file at $filePath" } + + val imageBytes = file.readBytes() + + // Step 1 — hash the raw image file bytes + val assetHash = sha256Hex(imageBytes) + + // Step 2 — build the draft claim without manifestHash + val draft = C2paManifest( + assetHash = assetHash, + mimeType = mimeType, + filename = file.name, + producer = producer, + softwareAgent = softwareAgent, + timestamp = System.currentTimeMillis() / 1000 + ) + + // Step 3 — hash the canonical claim JSON → tamper-evident on-chain value + val claimJson = canonicalJson.encodeToString(draft) + val manifestHash = sha256Hex(claimJson.toByteArray(Charsets.UTF_8)) + + return draft.copy(manifestHash = manifestHash) + } + + /** + * Builds a [C2paManifest] from raw image bytes (e.g. from a camera capture buffer). + * Use [build] when the image is already saved to disk. + * + * @param imageBytes Raw bytes of the image. + * @param mimeType MIME type e.g. `"image/png"`. + * @param filename Optional filename for the claim. + * @param producer Wallet address or producer identifier. + */ + fun buildFromBytes( + imageBytes: ByteArray, + mimeType: String = "image/png", + filename: String = "", + producer: String = "", + softwareAgent: String = "altude-provenance-sdk" + ): C2paManifest { + val assetHash = sha256Hex(imageBytes) + val draft = C2paManifest( + assetHash = assetHash, + mimeType = mimeType, + filename = filename, + producer = producer, + softwareAgent = softwareAgent, + timestamp = System.currentTimeMillis() / 1000 + ) + val claimJson = canonicalJson.encodeToString(draft) + val manifestHash = sha256Hex(claimJson.toByteArray(Charsets.UTF_8)) + return draft.copy(manifestHash = manifestHash) + } + + private fun sha256Hex(bytes: ByteArray): String = + MessageDigest.getInstance("SHA-256") + .digest(bytes) + .joinToString("") { "%02x".format(it) } + } + + /** Returns the canonical JSON of this manifest for backend storage / verification. */ + fun toJson(): String = canonicalJson.encodeToString(this) + + /** + * Saves the manifest JSON as a sidecar `.c2pa.json` file. + * Named `{filename}.c2pa.json` (e.g. `photo.png.c2pa.json`). + * + * @param directory Directory to write into (e.g. `context.filesDir`). + * @return The written [File]. + */ + fun saveTo(directory: File): File { + directory.mkdirs() + val stem = if (filename.isNotBlank()) filename else manifestHash.take(16) + val out = File(directory, "$stem.c2pa.json") + out.writeText(toJson(), Charsets.UTF_8) + return out + } + + /** + * Embeds the manifest JSON directly into the image file metadata. + * + * - **JPEG** → written to XMP metadata via `ExifInterface` (`TAG_XMP`). + * The client only needs the image — no sidecar file required. + * - **PNG** → injected as a `tEXt` chunk with keyword `C2PA` before `IEND`. + * - **Other formats** → throws [UnsupportedOperationException]; use [saveTo] instead. + * + * The file at [imageFile] is modified in-place. + * + * ```kotlin + * result.manifest.embedInto(File(filePath)) + * // imageFile now contains the C2PA manifest in its metadata + * ``` + * + * @param imageFile The image file to embed into (must be JPEG or PNG). + * @return The modified [imageFile] for chaining. + */ + fun embedInto(imageFile: File): File { + require(imageFile.exists()) { "C2PA embed: file not found at ${imageFile.absolutePath}" } + val json = toJson() + return when { + mimeType.contains("jpeg", ignoreCase = true) || + mimeType.contains("jpg", ignoreCase = true) || + imageFile.extension.lowercase() in listOf("jpg", "jpeg") -> embedJpeg(imageFile, json) + + mimeType.contains("png", ignoreCase = true) || + imageFile.extension.lowercase() == "png" -> embedPng(imageFile, json) + + else -> throw UnsupportedOperationException( + "C2PA embed: unsupported format '${imageFile.extension}'. Use saveTo() for a sidecar file." + ) + } + } + + // ── Embed helpers ───────────────────────────────────────────────────────── + + private fun embedJpeg(imageFile: File, json: String): File { + // ExifInterface writes XMP in-place — standard Adobe/C2PA approach for JPEG + val xmp = """ + + + + + ${json.replace("<", "<").replace(">", ">")} + + + + + """.trimIndent() + ExifInterface(imageFile.absolutePath).apply { + setAttribute(ExifInterface.TAG_XMP, xmp) + saveAttributes() + } + return imageFile + } + + private fun embedPng(imageFile: File, json: String): File { + // Inject a tEXt chunk with keyword "C2PA" before the IEND chunk + val original = imageFile.readBytes() + val keyword = "C2PA" + val text = json.toByteArray(Charsets.UTF_8) + val nullByte = byteArrayOf(0x00) + val chunkData = keyword.toByteArray(Charsets.UTF_8) + nullByte + text + + // CRC covers chunk type + chunk data + val chunkType = "tEXt".toByteArray(Charsets.UTF_8) + val crcInput = chunkType + chunkData + val crc = java.util.zip.CRC32().also { it.update(crcInput) }.value + + val chunk = ByteArrayOutputStream().apply { + write(intToBytes(chunkData.size)) // length (4 bytes, big-endian) + write(chunkType) // type (4 bytes) + write(chunkData) // data + write(intToBytes(crc.toInt())) // CRC (4 bytes, big-endian) + }.toByteArray() + + // Find IEND chunk offset (last 12 bytes of a valid PNG) + val iendOffset = original.size - 12 + val patched = original.copyOfRange(0, iendOffset) + + chunk + + original.copyOfRange(iendOffset, original.size) + + imageFile.writeBytes(patched) + return imageFile + } + + private fun intToBytes(value: Int): ByteArray = byteArrayOf( + (value shr 24 and 0xFF).toByte(), + (value shr 16 and 0xFF).toByte(), + (value shr 8 and 0xFF).toByte(), + (value and 0xFF).toByte() + ) +} + diff --git a/provenance/src/main/java/com/altude/provenance/data/ImageHashPayload.kt b/provenance/src/main/java/com/altude/provenance/data/ImageHashPayload.kt new file mode 100644 index 0000000..ef7ac11 --- /dev/null +++ b/provenance/src/main/java/com/altude/provenance/data/ImageHashPayload.kt @@ -0,0 +1,314 @@ +package com.altude.provenance.data + +import android.content.ContentResolver +import android.net.Uri +import kotlinx.serialization.Serializable + +/** + * Structured payload for an image-hash attestation. + * + * SDK users should NOT construct this directly — use the factory: + * ```kotlin + * val payload = ImageHashPayload.create( + * filePath = file.absolutePath, + * mime = "image/png", + * producer = walletAddress, + * account = walletAddress + * ) + * val result = Provenance.attestImageHash(payload) + * ``` + * + * Internally, [create] builds a [C2paManifest] from the file path — the hash + * and manifest JSON are computed automatically. The user never touches raw bytes + * or SHA-256 directly. + */ +@ConsistentCopyVisibility +data class ImageHashPayload internal constructor( + /** Always `"image_hash"`. */ + val type: String, + /** + * [C2paManifest.manifestHash] — SHA-256 of the canonical C2PA claim JSON. + * Auto-computed by [create]; never set manually. + */ + val hash: String, + /** MIME type of the image (e.g. `"image/png"`). */ + val mime: String, + /** Original filename derived from the file path. */ + val name: String, + /** Full C2PA claim JSON — forwarded to backend for off-chain verification. */ + val manifest: String, + /** Original [C2paManifest] object — returned to SDK user via [ProvenanceResult]. */ + internal val c2paManifest: C2paManifest, + /** Unix epoch (seconds) when the manifest was created. */ + val timestamp: Long, + /** Attester wallet address (Base58). Blank = stored default wallet. */ + val account: String, + /** Recipient wallet (Base58). Defaults to attester if blank. */ + val recipient: String, + /** Unix timestamp (seconds) when attestation expires. 0 = no expiry. */ + val expireAt: Long, + /** Finality commitment to wait for. */ + val commitment: Commitment +) { + companion object { + + /** + * Creates an [ImageHashPayload] from a file path. + * + * **Where does `filePath` come from?** + * + * 1. **Camera capture** — save output to a File you control, pass its path: + * ```kotlin + * val file = File(context.filesDir, "photo.jpg") + * val uri = FileProvider.getUriForFile(context, "${packageName}.provider", file) + * // pass uri to camera intent via MediaStore.EXTRA_OUTPUT, then after capture: + * val payload = ImageHashPayload.create(filePath = file.absolutePath, account = wallet) + * ``` + * + * 2. **Gallery / document picker** — use the [Uri] overload instead: + * ```kotlin + * val payload = ImageHashPayload.create(uri = pickedUri, contentResolver = contentResolver, account = wallet) + * ``` + * + * 3. **File already on disk** (downloads, app storage): + * ```kotlin + * val payload = ImageHashPayload.create(filePath = File(filesDir, "image.png").absolutePath, account = wallet) + * ``` + * + * Internally builds a [C2paManifest]: + * 1. Reads bytes from [filePath] + * 2. SHA-256(bytes) → [C2paManifest.assetHash] + * 3. Builds canonical C2PA claim JSON + * 4. SHA-256(claimJson) → [C2paManifest.manifestHash] ← stored on-chain + * + * @param filePath Absolute path to the image file on device. + * @param mime MIME type e.g. `"image/png"`, `"image/jpeg"`. + * @param producer Wallet address or app name embedded in the C2PA claim. + * @param account Attester wallet (Base58). Blank = stored default wallet. + */ + fun create( + filePath: String, + mime: String = "image/png", + producer: String = "", + account: String = "", + recipient: String = "", + expireAt: Long = 0L, + commitment: Commitment = Commitment.finalized + ): ImageHashPayload { + val manifest = C2paManifest.build( + filePath = filePath, + mimeType = mime, + producer = producer + ) + return ImageHashPayload( + type = "image_hash", + hash = manifest.manifestHash, + mime = manifest.mimeType, + name = manifest.filename, + manifest = manifest.toJson(), + c2paManifest = manifest, + timestamp = manifest.timestamp, + account = account, + recipient = recipient, + expireAt = expireAt, + commitment = commitment + ) + } + + /** + * Creates an [ImageHashPayload] from a **gallery / document picker URI** (`content://`). + * + * Use this when the user picks an image from the gallery or files app — + * Android returns a `content://` URI that cannot be used as a file path directly. + * This overload reads the bytes via [ContentResolver] and builds the C2PA manifest. + * + * ```kotlin + * // In ActivityResultCallback from photo picker / ACTION_GET_CONTENT: + * val payload = ImageHashPayload.create( + * uri = pickedUri, + * contentResolver = contentResolver, + * mime = contentResolver.getType(pickedUri) ?: "image/jpeg", + * account = walletAddress + * ) + * val result = Provenance.attestImageHash(payload) + * ``` + * + * @param uri Content URI returned by gallery / document picker. + * @param contentResolver From `Activity.contentResolver` or `Context.contentResolver`. + * @param mime MIME type — use `contentResolver.getType(uri)` if unsure. + * @param producer Wallet address or app name embedded in the C2PA claim. + * @param account Attester wallet (Base58). Blank = stored default wallet. + */ + fun create( + uri: Uri, + contentResolver: ContentResolver, + mime: String = "image/jpeg", + producer: String = "", + account: String = "", + recipient: String = "", + expireAt: Long = 0L, + commitment: Commitment = Commitment.finalized + ): ImageHashPayload { + val imageBytes = contentResolver.openInputStream(uri)?.use { it.readBytes() } + ?: throw IllegalArgumentException("C2PA: cannot open URI $uri") + + // Derive a best-effort filename from the URI's last path segment + val filename = uri.lastPathSegment?.substringAfterLast('/') ?: "image" + + val manifest = C2paManifest.buildFromBytes( + imageBytes = imageBytes, + mimeType = mime, + filename = filename, + producer = producer + ) + return ImageHashPayload( + type = "image_hash", + hash = manifest.manifestHash, + mime = manifest.mimeType, + name = manifest.filename, + manifest = manifest.toJson(), + c2paManifest = manifest, + timestamp = manifest.timestamp, + account = account, + recipient = recipient, + expireAt = expireAt, + commitment = commitment + ) + } + + /** + * Creates a payload from an already-built [C2paManifest]. + * Use this when you want to inspect manifest fields before attesting. + * + * ```kotlin + * val manifest = C2paManifest.build(filePath = file.absolutePath, producer = wallet) + * println("assetHash: ${manifest.assetHash}") + * println("manifestHash: ${manifest.manifestHash}") + * val payload = ImageHashPayload.fromManifest(manifest, account = wallet) + * ``` + */ + fun fromManifest( + manifest: C2paManifest, + account: String = "", + recipient: String = "", + expireAt: Long = 0L, + commitment: Commitment = Commitment.finalized + ): ImageHashPayload = ImageHashPayload( + type = "image_hash", + hash = manifest.manifestHash, + mime = manifest.mimeType, + name = manifest.filename, + manifest = manifest.toJson(), + c2paManifest = manifest, + timestamp = manifest.timestamp, + account = account, + recipient = recipient, + expireAt = expireAt, + commitment = commitment + ) + } +} + +// ── Wire types ──────────────────────────────────────────────────────────────── + +/** + * JSON body sent to `POST api/provenance/attestImageHash`. + */ +@Serializable +data class ImageHashRequest( + val type: String, + /** [C2paManifest.manifestHash] — stored on-chain. */ + val hash: String, + val mime: String, + val name: String = "", + val timestamp: Long, + val account: String = "", + val recipient: String = "", + val expireAt: Long = 0L, + /** Full C2PA claim JSON — stored off-chain by backend for verification. */ + val manifest: String = "", + /** + * Base64-encoded signed `createSchema` tx. + * `null` when schema already confirmed for this wallet — backend skips it. + */ + val signedSchemaTx: String? = null, + /** Base64-encoded signed `createAttestation` tx. */ + val signedAttestationTx: String +) + +/** + * Response from `POST api/provenance/attestImageHash`. + */ +@Serializable +data class ImageHashResponse( + val Status: String, + val Message: String, + val Signature: String = "", + /** On-chain Attestation PDA (Base58). */ + val attestationId: String = "" +) + +/** + * Returned to the SDK user after a successful attestation. + * + * Contains: + * - [response] — the on-chain result (attestation PDA, tx signature) + * - [manifest] — the C2PA manifest object (assetHash, manifestHash, filename, etc.) + * - [manifestFile] — sidecar `.c2pa.json` file saved on device (if [ManifestOption.SidecarFile] or [ManifestOption.Both]) + * - [embeddedImageFile] — the image file with manifest embedded in metadata (if [ManifestOption.EmbedInImage] or [ManifestOption.Both]) + * + * ```kotlin + * val pr = Provenance.attestImageHash(payload, ManifestOption.Both(filePath)).getOrThrow() + * + * // On-chain + * pr.response.attestationId // Solana Attestation PDA + * pr.response.Signature // tx signature + * + * // Manifest fields + * pr.manifest.assetHash // SHA-256 of raw image bytes + * pr.manifest.manifestHash // SHA-256 of claim JSON — what's on-chain + * pr.manifest.filename // "photo.png" + * + * // Sidecar file (ManifestOption.SidecarFile or Both) + * pr.manifestFile?.absolutePath // ".../provenance_manifests/photo.png.c2pa.json" + * + * // Embedded image (ManifestOption.EmbedInImage or Both) + * pr.embeddedImageFile?.absolutePath // the image file now carries the manifest in its metadata + * ``` + */ +data class ProvenanceResult( + /** On-chain attestation result from the backend. */ + val response: ImageHashResponse, + /** The C2PA manifest built from the image. */ + val manifest: C2paManifest, + /** + * Sidecar `.c2pa.json` file saved on device. + * Non-null when [ManifestOption.SidecarFile] or [ManifestOption.Both] was used. + */ + val manifestFile: java.io.File? = null, + /** + * The image file with the C2PA manifest embedded in its metadata. + * Non-null when [ManifestOption.EmbedInImage] or [ManifestOption.Both] was used. + * JPEG: embedded in XMP. PNG: embedded as a tEXt chunk. + */ + val embeddedImageFile: java.io.File? = null +) + +/** + * Per-image result emitted by [com.altude.provenance.Provenance.attestBatch]. + * + * @param index Zero-based position in the original list passed to `attestBatch`. + * @param name Original filename — use to match back to your UI list. + * @param hash The [C2paManifest.manifestHash] stored on-chain. + * @param result Success carries [ProvenanceResult] (manifest + response); failure carries the error. + */ +data class AttestationResult( + val index: Int, + val name: String, + val hash: String, + val result: Result +) + + + + diff --git a/provenance/src/main/java/com/altude/provenance/data/ManifestOption.kt b/provenance/src/main/java/com/altude/provenance/data/ManifestOption.kt new file mode 100644 index 0000000..a643d1c --- /dev/null +++ b/provenance/src/main/java/com/altude/provenance/data/ManifestOption.kt @@ -0,0 +1,62 @@ +package com.altude.provenance.data + +/** + * Controls how the C2PA manifest is stored after a successful attestation. + * + * Pass as the `manifestOption` parameter to [com.altude.provenance.Provenance.attestImageHash] + * or [com.altude.provenance.Provenance.attestBatch]. + * + * ```kotlin + * // Default — sidecar .c2pa.json file next to the image + * Provenance.attestImageHash(payload, ManifestOption.SidecarFile) + * + * // Embed the manifest directly into the image file (JPEG XMP / PNG tEXt) + * Provenance.attestImageHash(payload, ManifestOption.EmbedInImage(sourceFilePath)) + * + * // Save sidecar AND embed in image + * Provenance.attestImageHash(payload, ManifestOption.Both(sourceFilePath)) + * + * // Skip saving entirely (manifest still returned in ProvenanceResult.manifest) + * Provenance.attestImageHash(payload, ManifestOption.None) + * ``` + */ +sealed class ManifestOption { + + /** + * Save manifest as a `.c2pa.json` sidecar file in + * `filesDir/provenance_manifests/{filename}.c2pa.json`. + * + * The client receives the image + the sidecar file. + * Use this when you display/share images alongside a downloadable manifest. + */ + object SidecarFile : ManifestOption() + + /** + * Embed the manifest JSON directly into the image file: + * - **JPEG** → written to XMP metadata (`TAG_XMP`) via `ExifInterface` + * - **PNG** → written as a `tEXt` chunk with keyword `C2PA` + * - **Other formats** → falls back to [SidecarFile] silently + * + * The image file at [sourceFilePath] is modified in-place. + * The client only needs the single image file — no sidecar needed. + * + * @param sourceFilePath Absolute path to the original image file to embed into. + */ + data class EmbedInImage(val sourceFilePath: String) : ManifestOption() + + /** + * Save sidecar `.c2pa.json` file AND embed manifest into the image. + * Gives the client both options for verification. + * + * @param sourceFilePath Absolute path to the original image file to embed into. + */ + data class Both(val sourceFilePath: String) : ManifestOption() + + /** + * Do not save the manifest to disk. + * The manifest is still available in [com.altude.provenance.data.ProvenanceResult.manifest] + * for in-memory use. + */ + object None : ManifestOption() +} + diff --git a/provenance/src/main/java/com/altude/provenance/data/ProvenancePrefs.kt b/provenance/src/main/java/com/altude/provenance/data/ProvenancePrefs.kt new file mode 100644 index 0000000..0456ee8 --- /dev/null +++ b/provenance/src/main/java/com/altude/provenance/data/ProvenancePrefs.kt @@ -0,0 +1,45 @@ +package com.altude.provenance.data + +import android.content.Context +import androidx.core.content.edit +import com.altude.core.service.StorageService + +/** + * Persists per-wallet schema-creation state across app restarts using SharedPreferences. + * + * Schema is a one-time on-chain setup per wallet — once confirmed it never needs + * to be created again. This class remembers that across app restarts so we never + * waste a transaction build or blockhash fetch for a schema that already exists. + * + * Uses [StorageService.getContext] — same context pattern as the rest of the SDK. + */ +internal object ProvenancePrefs { + + private const val PREFS_NAME = "altude_provenance" + private const val SCHEMA_PREFIX = "schema_created_" + + private fun prefs() = StorageService.getContext() + .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + /** + * Returns `true` if [walletAddress] schema has already been confirmed on-chain. + * When true, `ensureSchema` in ProvenanceManager skips building the tx entirely. + */ + fun isSchemaCreated(walletAddress: String): Boolean = + prefs().getBoolean("$SCHEMA_PREFIX$walletAddress", false) + + /** + * Marks the schema as confirmed for [walletAddress]. + * Must be called ONLY after the backend returns Status == "success". + */ + fun markSchemaCreated(walletAddress: String) = + prefs().edit { putBoolean("$SCHEMA_PREFIX$walletAddress", true) } + + /** + * Clears the schema flag for [walletAddress]. + * Call on wallet switch or logout via Provenance.resetSession(). + */ + fun reset(walletAddress: String) = + prefs().edit { remove("$SCHEMA_PREFIX$walletAddress") } +} + diff --git a/provenance/src/main/java/com/altude/provenance/data/ProvenanceRequests.kt b/provenance/src/main/java/com/altude/provenance/data/ProvenanceRequests.kt new file mode 100644 index 0000000..f85dd6c --- /dev/null +++ b/provenance/src/main/java/com/altude/provenance/data/ProvenanceRequests.kt @@ -0,0 +1 @@ +package com.altude.provenance.data diff --git a/provenance/src/main/java/com/altude/provenance/data/TransactionResponse.kt b/provenance/src/main/java/com/altude/provenance/data/TransactionResponse.kt new file mode 100644 index 0000000..b449368 --- /dev/null +++ b/provenance/src/main/java/com/altude/provenance/data/TransactionResponse.kt @@ -0,0 +1,15 @@ +package com.altude.provenance.data + +import com.altude.provenance.interfaces.ITransactionResponse +import kotlinx.serialization.Serializable + +/** + * Generic transaction response from the Altude backend. + */ +@Serializable +data class TransactionResponse( + override val Status: String = "", + override val Message: String = "", + override val Signature: String = "" +) : ITransactionResponse + diff --git a/provenance/src/main/java/com/altude/provenance/interfaces/ITransactionResponse.kt b/provenance/src/main/java/com/altude/provenance/interfaces/ITransactionResponse.kt new file mode 100644 index 0000000..c71e65c --- /dev/null +++ b/provenance/src/main/java/com/altude/provenance/interfaces/ITransactionResponse.kt @@ -0,0 +1,11 @@ +package com.altude.provenance.interfaces + +/** + * Common contract for all transaction responses from the Altude backend. + */ +interface ITransactionResponse { + val Status: String + val Message: String + val Signature: String +} + diff --git a/provenance/src/main/java/com/altude/provenance/interfaces/ProvenanceService.kt b/provenance/src/main/java/com/altude/provenance/interfaces/ProvenanceService.kt new file mode 100644 index 0000000..ed48bfe --- /dev/null +++ b/provenance/src/main/java/com/altude/provenance/interfaces/ProvenanceService.kt @@ -0,0 +1,23 @@ +package com.altude.provenance.interfaces + +import com.altude.provenance.data.ImageHashRequest +import kotlinx.serialization.json.JsonElement +import retrofit2.Call +import retrofit2.http.Body +import retrofit2.http.POST + +/** + * Retrofit service for the Provenance backend endpoints. + */ +interface ProvenanceService { + + /** + * Submits both the signed `createSchema` and `createAttestation` transactions + * along with the structured image-hash metadata to the backend. + * The backend broadcasts both transactions and returns the attestation result. + */ + @POST("api/provenance/attestImageHash") + fun attestImageHash( + @Body body: ImageHashRequest + ): Call +} diff --git a/settings.gradle.kts b/settings.gradle.kts index aa9102c..bb9a76d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,3 +26,4 @@ include(":smart-account") include(":nft") include(":gasstation") include(":core") +include(":provenance")