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 <triple>| 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:
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:
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
i64cents. 5000 = $50.00. Carry through KotlinLongand SwiftInt64unmodified. - JSON contract over UniFFI. Endpoints exchange JSON strings at the
FFI boundary —
serde_jsonon the Rust side,kotlinx.serializationon Kotlin (RustApiBridge.json),JSONDecoderwith.convertFromSnakeCaseon Swift. UniFFI-typed structs are reserved for the auth + config surface where the shape is stable. - No
asynctraits 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 onDispatchers.IO; iOS wraps inTask.detached(seeCashlessCoreHttpClientsemaphore pattern). HttpClientis 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.shandapp/build.gradle.ktsboth enumerate four ABIs — they must match, or the APK either crashes on a missing.soor ships unused ABIs. cc-rsignores Cargo linker config. The Android script setsCC_<target>/CXX_<target>/AR_<target>per target because thecccrate (pulled in bylibsqlite3-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 — returnResult/Optionalways. - iOS
HttpClientblocks via semaphore. Call repositories fromTask { ... }or anasyncmethod — 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 asSerializationError.
Releasing¶
There is no published artifact today — the SDK is vendored into each consumer app from the parent monorepo:
- Edit
sdk/cashless-core-rs/oncashless-v2(parent repo). - Run CI locally —
cargo fmt --all,cargo clippy --workspace --all-targets -- -D warnings,cargo test --workspace,bash scripts/coverage.sh. CI inci-cashless-core-rs.ymlruns the same gates on push. - Rebuild the platform binaries:
- Android:
./build-android.sh release→ copyout/android/jniLibs/andout/kotlin/uniffi/cashless_core/cashless_core.ktinto the Android app'sapp/src/main/. - iOS:
./build-ios.sh releaseon macOS → copylibcashless_core.aslices intoapps/user_app_ios/dChip/dChip/CashlessCore/Generated/, plus the regeneratedcashless_core.swift/cashless_coreFFI.h/.modulemap.
- Android:
- Commit in each repo separately. Parent commit (SDK source +
in-tree changes) lands first; submodule commits in
apps/user_app_android_with_sdkandapps/user_app_iosfollow with the regenerated artifacts. - CI artifacts. Every
cashless-v2push tosdk/cashless-core-rs/uploadslibcashless_core-arm64-v8aandcashless_core-kotlin-bindingsas 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.