open-standards/openpay/crates/op-ffi-jni/kotlin
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Transaction Science 70ecd6caa0 Initial import — three open standards under one shelf
Transaction Science open standards monorepo. Each subdirectory is a
self-contained reference protocol with its own README and licence.

- openpay/    Apache-2.0  payment-acceptance stack (Rust, 26 crates)
- smart-byte/ CC-BY-4.0   content-addressed value carrier (spec)
- eoc/        CC-BY-4.0   energy-optimised compute (spec)

The wire format and the right to fork are public. The optional hosted
services are the offer; the protocols are not.
2026-05-23 07:58:09 -04:00
..
openpay Initial import — three open standards under one shelf 2026-05-23 07:58:09 -04:00
build.gradle.kts Initial import — three open standards under one shelf 2026-05-23 07:58:09 -04:00
gradle.properties Initial import — three open standards under one shelf 2026-05-23 07:58:09 -04:00
README.md Initial import — three open standards under one shelf 2026-05-23 07:58:09 -04:00
settings.gradle.kts Initial import — three open standards under one shelf 2026-05-23 07:58:09 -04:00

OpenPay Android (dev.openpay)

Kotlin/Java wrapper around the OpenPay Rust core, packaged as a standard AAR via the AGP 9 library plugin.

What you get

  • CardData — Validated PAN + expiration. Luhn / length / date sanity checked on construction. PAN bytes live in Rust memory and are never readable from Kotlin.
  • VaultRef — Opaque token reference. Safe to persist, log, and transmit.
  • Vault — Tokenize / detokenize / exists / delete interface.
  • RustVault — In-memory reference vault for tests.
  • KeystoreVault — Production vault backed by EncryptedSharedPreferences + the Android Keystore.
  • HeuristicScorer — Rule-based fraud scorer.
  • OpenPayException — Sealed-class hierarchy with one subclass per failure mode.
  • TokenizationPolicy — Configurable random / deterministic + reusable / single-use + optional TTL.

Build the native library first

The Kotlin module ships pre-built .so files under src/main/jniLibs/<ABI>/. To regenerate them after a Rust change:

cargo install cargo-ndk
rustup target add aarch64-linux-android armv7-linux-androideabi \
                  x86_64-linux-android i686-linux-android

# From the crate root (`crates/op-ffi-jni/`):
bash scripts/build-android.sh --release

The script invokes cargo ndk for all four standard ABIs and stages the outputs into kotlin/openpay/src/main/jniLibs/<ABI>/.

Consume the library

Option 1 — Gradle subproject

In your Android app's settings.gradle.kts:

include(":openpay")
project(":openpay").projectDir = file("../openpay/crates/op-ffi-jni/kotlin/openpay")

In your app's build.gradle.kts:

dependencies {
    implementation(project(":openpay"))
}

Option 2 — Local Maven publication

cd kotlin
./gradlew :openpay:publishToMavenLocal

Then in your app:

repositories {
    mavenLocal()
}

dependencies {
    implementation("dev.openpay:openpay:0.1.0")
}

Usage example

import dev.openpay.*

class CheckoutViewModel(private val context: Context) {

    // KeystoreVault persists across app launches via the Android
    // Keystore. One instance per app is fine; it's thread-safe.
    private val vault = KeystoreVault(context.applicationContext)

    fun saveCard(pan: String, expMonth: Byte, expYear: Short): String {
        val token = vault.tokenizeFromString(
            pan, expMonth, expYear,
            TokenizationPolicy.cardOnFile(),
        )
        try {
            return token.asString
        } finally {
            token.close()
        }
    }

    fun chargeCard(tokenStr: String) {
        VaultRef.fromString(tokenStr).use { ref ->
            try {
                vault.detokenize(ref).use { card ->
                    // Hand `card.firstSix` / `card.lastFour` to your
                    // analytics layer (safe per PCI 4.0.1 §3.4.1).
                    // The full PAN lives only inside the Rust heap
                    // until `card.close()`.
                    submitToAcquirer(card)
                }
            } catch (e: OpenPayException.VaultLookupFailed) {
                // Unknown/malformed/expired key — collapsed to a
                // single exception type for oracle discipline.
                showError("Card not found")
            } catch (e: OpenPayException.TokenExpired) {
                showError("Token expired. Please re-enter your card.")
            }
        }
    }
}

Backup discipline (critical)

KeystoreVault persists encrypted records to a SharedPreferences file. The encryption key lives in the Android Keystore, which is bound to the device. If Android Auto Backup restores the encrypted file to a different device, decryption silently fails.

Drop this into your app's res/xml/backup_rules.xml:

<full-backup-content>
    <exclude domain="sharedpref" path="openpay-vault.xml" />
</full-backup-content>

And reference it from your manifest:

<application
    android:fullBackupContent="@xml/backup_rules"
    ...>

A working example is shipped at kotlin/openpay/src/main/res/xml/backup_rules.xml.

On Android 12+ (API 31+), also configure android:dataExtractionRules per the AOSP docs.

AndroidX security-crypto deprecation note

androidx.security:security-crypto was deprecated at version 1.1.0-alpha07 in April 2025. The API surface is stable; only future maintenance is uncertain. Two paths forward:

  1. Stay on AndroidX. The 1.1.0-alpha06 release (what this module pins by default) is what most production apps use. The classes continue to work and Tink (the underlying crypto) is still actively maintained by Google.

  2. Switch to the community fork. Replace the dependency:

    // Before:
    implementation("androidx.security:security-crypto:1.1.0-alpha06")
    // After:
    implementation("dev.spght:encryptedprefs-ktx:<latest>")
    

    And in KeystoreVault.kt, change the imports:

    // Before:
    import androidx.security.crypto.EncryptedSharedPreferences
    import androidx.security.crypto.MasterKey
    // After:
    import dev.spght.encryptedprefs.EncryptedSharedPreferences
    import dev.spght.encryptedprefs.MasterKey
    

    The classes have identical APIs.

Min SDK 23 (Android 6.0)

Required by Tink 1.18+, which is pulled in transitively by androidx.security:security-crypto:1.1.x. The fork also targets 23+. Lower min-SDK is not supportable without abandoning the hardware-backed Keystore.

Why doesn't KeystoreVault implement Vault?

Vault.tokenize(card: CardData) requires the implementation to read the PAN bytes out of the CardData handle. But CardData deliberately hides PAN bytes from Kotlin — they live in Rust memory behind a pub(crate) accessor. That's the PCI scope boundary.

KeystoreVault therefore offers tokenizeFromString(pan, ...), which takes the PAN as a Kotlin string and writes it directly into EncryptedSharedPreferences. This is the same pattern PSP SDKs like Stripe and Adyen use in their Android integrations: the encrypted store IS the Keystore, no intermediate cipher.

RustVault does implement Vault because the PAN never leaves Rust in that flow — it stays inside the in-memory vault from tokenize(card) to detokenize(ref).

Testing

Two test suites:

  • src/test/ runs on the host JVM (./gradlew :openpay:test). Covers CardData, VaultRef, RustVault, HeuristicScorer, TokenizationPolicy. Requires the .so on java.library.path — build a host-architecture variant first with cargo build -p op-ffi-jni --release.
  • src/androidTest/ runs on a device/emulator (./gradlew :openpay:connectedAndroidTest). Covers KeystoreVault which needs an Android Context and the Keystore.