Skip to content

Mobile SDK

cashless-core-rs is the only mobile SDK for the platform — a Rust workspace under sdk/cashless-core-rs/ that compiles to native libraries and exposes a typed API to Kotlin (Android) and Swift (iOS) through UniFFI.

The legacy Kotlin Multiplatform SDK (sdk/cashless-sdk) was retired on 2026-05-08 when Wave 6 of the strangler-fig migration completed; the upstream repo is preserved read-only and is no longer a submodule of this monorepo. See .planning/wave6-kmp-to-rust-migration.md for the migration history.

Where the SDK lives

sdk/cashless-core-rs/ is in-tree in the parent monorepo — it is not a submodule. Edits land directly with the parent commit.


What's in the SDK

The workspace is split into seven crates with clear dependency direction (see Cargo.toml). Internal crates stay UniFFI-free; only ffi/ exports symbols to the platforms.

Crate Purpose Gate (line cov.) Source
models Domain types + serde JSON contracts (PA API + admin shapes) tracked crates/models
crypto AES-ECB, AES-CBC, 3DES — pure Rust, no platform deps ≥ 88 % crates/crypto
nfc Chip codec (48-byte bit-packed), operation + result enums ≥ 67 % crates/nfc
network HttpClient trait, ServerConfig, endpoint clients, ApiError tracked crates/network
db rusqlite (bundled SQLite), 6 tables, migrations tracked crates/db
domain Business rules — sync, tip calc, deep links, payment errors ≥ 83 % crates/domain
ffi UniFFI binding crate — emits cdylib / staticlib + bindgen ≥ 0 % crates/ffi

The ffi crate is what consumers actually load. It re-exports the typed API clients (CashlessApiClient, CashlessAuthApi, CashlessPaymentApi, CashlessAdminPosApi, etc. — 30+ surface types) plus the FFI primitives FfiHttpClient, FfiTokenProvider, and FfiApiError.

ffi crate coverage gate is 0 %

The UniFFI surface is exercised by Kotlin and Swift binding tests on the consumer side, not by Rust unit tests. The gate is tracked-only until a Rust-side smoke harness is wired in. Do not ratchet without first adding a Rust-side test harness — see scripts/coverage-thresholds.json.


Build → bind → consume

flowchart LR
    A([Rust workspace]) -->|cargo build -p cashless-ffi<br/>--target &lt;triple&gt;| B[Native libs]
    B --> C{Platform}
    C -->|Android| D[libcashless_core.so<br/>×4 ABIs]
    C -->|iOS| E[libcashless_core.a<br/>×3 slices]
    A -->|uniffi-bindgen generate<br/>--language kotlin| F[uniffi/cashless_core/<br/>cashless_core.kt]
    A -->|uniffi-bindgen generate<br/>--language swift| G[cashless_core.swift<br/>cashless_coreFFI.h<br/>cashless_coreFFI.modulemap]
    D --> H[user_app_android_with_sdk]
    F --> H
    E --> I[user_app_ios / dChip]
    G --> I

UniFFI's metadata is baked into every artifact identically — any built library of the same Rust source produces the same Kotlin / Swift output. That lets the iOS Swift bindings be regenerated on Linux without an Apple toolchain (see build-ios.sh bindings).

Android build

cd sdk/cashless-core-rs
rustup target add aarch64-linux-android armv7-linux-androideabi \
                  x86_64-linux-android i686-linux-android
export ANDROID_NDK_HOME=~/Android/Sdk/ndk/27.x
./build-android.sh release

Output:

out/android/jniLibs/{arm64-v8a,armeabi-v7a,x86,x86_64}/libcashless_core.so
out/kotlin/uniffi/cashless_core/cashless_core.kt

All four ABIs are built unconditionally. Dropping any ABI here will UnsatisfiedLinkError on System.loadLibrary("cashless_core") on the matching devices.

iOS build

cd sdk/cashless-core-rs
rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
./build-ios.sh release          # macOS + Xcode required (full build)
./build-ios.sh bindings         # Linux-friendly: regenerate Swift only

Output:

out/CashlessCore.xcframework   # 3 slices: device-arm64 + sim-arm64 + sim-x86_64
out/swift/cashless_core.swift
out/swift/cashless_coreFFI.h
out/swift/cashless_coreFFI.modulemap

Consumer integration

Android — user_app_android_with_sdk

The Android cardholder app vendors the SDK directly into its source tree rather than consuming a published AAR:

Artifact Lands at
libcashless_core.so (4 ABIs) app/src/main/jniLibs/{abi}/libcashless_core.so
cashless_core.kt (Kotlin bindings) app/src/main/kotlin/uniffi/cashless_core/cashless_core.kt

The Gradle config in app/build.gradle.kts locks the ABI list to match what the SDK build produces:

defaultConfig {
    ndk {
        abiFilters.addAll(listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64"))
    }
}

App code loads the library through Hilt-injected RustSdkModule.kt (System.loadLibrary("cashless_core") with a fail-soft sdkAvailable flag for missing-ABI installs), and wraps every Rust call through the RustApiBridge helper that translates FfiApiException → app-local ApiError (HttpError / NetworkError / Unauthorized / TimeoutError / SerializationError).

iOS — user_app_ios (dChip)

iOS vendors the SDK as checked-in static libraries plus a Clang module map rather than an XCFramework or SPM package:

Artifact Lands at
libcashless_core.a (device arm64) dChip/dChip/CashlessCore/Generated/lib-ios-device-arm64/libcashless_core.a
libcashless_core.a (simulator arm64) dChip/dChip/CashlessCore/Generated/lib-ios-simulator-arm64/libcashless_core.a
cashless_core.swift dChip/dChip/CashlessCore/Generated/cashless_core.swift
cashless_coreFFI.h + .modulemap dChip/dChip/CashlessCore/Generated/

The Xcode project sets LIBRARY_SEARCH_PATHS per-SDK so the right slice links against device vs. simulator builds, and OTHER_SWIFT_FLAGS injects the Clang module map so Swift can see the FFI header. See Generated/BUILD-IOS.md for the exact build settings.

App code talks to the SDK through 17 thin Swift repositories under dChip/dChip/CashlessCore/Repositories/ (async/await, throws CashlessCoreError). A bootstrap call on app launch idempotently builds the Rust ApiClient plus every typed API object:

// AppDelegate.swift
CashlessCore.shared.bootstrap(environment: .production)

iOS scaffold is not yet built

As of 2026-05-12 the iOS scaffold was authored on Linux and has not been compiled on macOS — the two static .a slices in Generated/lib-ios-{device,simulator}-arm64/ are not yet checked in (a DEBT note in BUILD-IOS.md calls this out). The Swift bindings, header, and module map are in the repo. Run ./build-ios.sh release on macOS to produce the missing .a files before first build.


Coverage gate (Track D4)

Per-crate line-coverage thresholds are enforced in CI via cargo llvm-cov and the helper script scripts/coverage.sh. Thresholds live in scripts/coverage-thresholds.json.

# From sdk/cashless-core-rs/
cargo install cargo-llvm-cov --locked
rustup component add llvm-tools-preview

bash scripts/coverage.sh           # run tests + per-crate gate
bash scripts/coverage.sh --html    # also emit target/llvm-cov/html/index.html
bash scripts/coverage.sh --no-gate # report only, do not enforce thresholds

Initial gates were set on 2026-05-10 at measured minus 2 percentage points (floored at 0) so the gate cannot regress from the day it landed. Ratchet upward as crates earn coverage — keep each ratchet step ≤ 2 pp below the new measured value so a single flaky test cannot tank CI.

Crate Measured at landing Gate §D4 target Note
crypto 90.29% 88 % 90 % Already meeting target.
nfc 69.86% 67 % 90 % 20 pp gap — chip_type.rs, constants.rs next.
domain 85.78% 83 % 90 % Covers payment-domain code.
ffi 0.00% 0 % 90 % Tracked-only; exercised through bindings.

CI uploads two artifacts on every run: cashless-core-rs-coverage-html (browsable report) and cashless-core-rs-coverage-json (machine-readable summary). See the coverage job in ci-cashless-core-rs.yml.


Test surface

Layer Where Count
Rust unit tests crates/*/src/**/*.rs ~155
Rust integration crates/network/tests/ — wave smoke tests 12 files
Rust total cargo test --workspace --no-fail-fast ~349
Kotlin binding tests apps/user_app_android_with_sdk/app/src/test/ repo-local
Swift binding tests apps/user_app_ios/dChip/dChipTests/ (CashlessCoreErrorTests.swift etc.) repo-local

Test count drifts upward

The parent monorepo CLAUDE.md quotes ~252 tests — that figure reflects an earlier wave snapshot. The workspace has continued to accrete tests; treat parent CLAUDE.md as a floor, not a current value. Use cargo test --workspace -- --list 2>/dev/null | wc -l locally for the live count.

The crates/network/tests/wave*_smoke_test.rs files are the regression fence for the strangler-fig waves — each wave's port lands with a smoke test asserting the new endpoints round-trip through the FFI.


Cross-language type marshaling

UniFFI generates the Kotlin and Swift type declarations from #[uniffi::export] annotations and *.udl files in the ffi crate. Mappings used in this SDK:

Rust Kotlin Swift
String String String
i64 (cents) Long Int64
Vec<u8> ByteArray Data
Option<T> T? T?
Vec<T> List<T> [T]
Result<T, E> T + checked exception (E) T + throws
#[derive(uniffi::Error)] sealed …Exception class enum: Error with associated data
#[uniffi::export] pub fn top-level fun top-level func
Arc<dyn Trait> interface (callback) protocol (callback)

Key conventions baked into this SDK:

  • Monetary amounts are i64 cents. 5000 = $50.00. Carry through Kotlin Long and Swift Int64 unmodified.
  • JSON contract over UniFFI. Endpoints exchange JSON strings at the FFI boundary — serde_json on the Rust side, kotlinx.serialization on Kotlin (RustApiBridge.json), JSONDecoder with .convertFromSnakeCase on Swift. UniFFI-typed structs are reserved for the auth + config surface where the shape is stable.
  • No async traits across FFI. UniFFI's async support across all platforms is not relied on here; the SDK uses blocking calls. The Android consumer wraps in coroutines on Dispatchers.IO; iOS wraps in Task.detached (see CashlessCoreHttpClient semaphore pattern).
  • HttpClient is a trait. Platforms inject the HTTP implementation — OkHttp on Android (OkHttpFfiClient.kt), URLSession on iOS (CashlessCoreHttpClient.swift). The Rust core never touches TLS or sockets.
  • Error translation pattern. FfiApiException (Kotlin) / FfiApiError (Swift) is mapped to the app-local error type at the bridge layer. Both consumers preserve the four variants: Http(statusCode, body), Network, Unauthorized, Timeout, Serialization.

Common pitfalls

Bindings can drift from the Rust ABI

Regenerated bindings reflect whatever Rust source is on disk at generation time. If you edit a #[uniffi::export] signature or change a serde rename, regenerate the bindings and re-check both consumers in the same commit. A stale cashless_core.kt against a fresh libcashless_core.so will load cleanly and then segfault on the first mismatched call.

  • ABI list locked in two places. build-android.sh and app/build.gradle.kts both enumerate four ABIs — they must match, or the APK either crashes on a missing .so or ships unused ABIs.
  • cc-rs ignores Cargo linker config. The Android script sets CC_<target> / CXX_<target> / AR_<target> per target because the cc crate (pulled in by libsqlite3-sys) does not respect [target.<triple>] linker = ....
  • iOS Swift error matching is by Mirror, not pattern. CashlessCoreError.from(_:) inspects UniFFI Swift exceptions by type-name introspection because the generated enum shape varies between UniFFI versions — re-check on every UniFFI upgrade.
  • No unwrap() in library code. Panics surface across the FFI as generic errors that lose context — return Result / Option always.
  • iOS HttpClient blocks via semaphore. Call repositories from Task { ... } or an async method — calling from the main thread deadlocks URLSession's delegate queue.
  • #[serde(default)] on every model field. The PA API returns partial JSON; missing defaults surface as SerializationError.

Releasing

There is no published artifact today — the SDK is vendored into each consumer app from the parent monorepo:

  1. Edit sdk/cashless-core-rs/ on cashless-v2 (parent repo).
  2. Run CI locallycargo fmt --all, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace, bash scripts/coverage.sh. CI in ci-cashless-core-rs.yml runs the same gates on push.
  3. Rebuild the platform binaries:
    • Android: ./build-android.sh release → copy out/android/jniLibs/ and out/kotlin/uniffi/cashless_core/cashless_core.kt into the Android app's app/src/main/.
    • iOS: ./build-ios.sh release on macOS → copy libcashless_core.a slices into apps/user_app_ios/dChip/dChip/CashlessCore/Generated/, plus the regenerated cashless_core.swift / cashless_coreFFI.h / .modulemap.
  4. Commit in each repo separately. Parent commit (SDK source + in-tree changes) lands first; submodule commits in apps/user_app_android_with_sdk and apps/user_app_ios follow with the regenerated artifacts.
  5. CI artifacts. Every cashless-v2 push to sdk/cashless-core-rs/ uploads libcashless_core-arm64-v8a and cashless_core-kotlin-bindings as 30-day GitHub Actions artifacts — useful for reproducing a specific Rust commit's bindings without a local toolchain.

Version pin

There is no version pin between consumer apps and SDK commits today — the Android and iOS apps consume whatever artifact was last regenerated into their source trees. The Cargo workspace itself is pinned at version = "0.1.0" in Cargo.toml. Track the SDK commit in the parent monorepo's commit log; the consumer-app commits referencing regenerated .so / .a / binding files are the de-facto version markers.

For invariants and the KMP → Rust mapping see sdk/cashless-core-rs/CLAUDE.md. For the iOS scaffold's per-screen migration order see apps/user_app_ios/dChip/dChip/CashlessCore/README.md.