|
1
|
--- |
|
2
|
name: sdk-kotlin |
|
3
|
description: Build, generate, and publish the Onde Inference Kotlin Multiplatform SDK (Android + JVM). Covers UniFFI Kotlin binding generation, KMP source set layout with shared srcDir pattern, cargo-ndk cross-compilation for Android ABIs, JVM native library bundling for macOS Apple Silicon, Maven Central publishing via Vanniktech plugin, klibs.io discovery requirements, ProGuard consumer rules, and the Jetpack Compose example app. |
|
4
|
allowed-tools: Read, Write, Edit, Glob, Grep, Bash |
|
5
|
user-invocable: true |
|
6
|
--- |
|
7
|
|
|
8
|
# Skill: Kotlin Multiplatform SDK |
|
9
|
|
|
10
|
## What This Skill Covers |
|
11
|
|
|
12
|
Building the `onde-inference` Kotlin Multiplatform library from Rust source |
|
13
|
using UniFFI, distributing it via Maven Central, and getting it listed on |
|
14
|
[klibs.io](https://klibs.io) — the JetBrains-maintained discovery platform for |
|
15
|
KMP libraries. |
|
16
|
|
|
17
|
**Targets:** |
|
18
|
|
|
19
|
| Target | GPU Backend | Default Model | Primary Use Case | |
|
20
|
|--------|-------------|---------------|------------------| |
|
21
|
| `androidTarget()` | CPU (Candle) | Qwen 2.5 1.5B (~941 MB) | Mobile apps | |
|
22
|
| `jvm()` | Metal (macOS) / CPU (Linux) | Qwen 2.5 3B (~1.93 GB) | Desktop / server on Apple Silicon | |
|
23
|
|
|
24
|
**Context:** Onde's tagline is "AI for Apple silicon devices." The primary SDKs |
|
25
|
are **Swift** (iOS, tvOS, macOS) and **Dart** (Flutter, cross-platform). The |
|
26
|
Kotlin SDK extends Onde to Android and JVM developers — particularly those |
|
27
|
building KMP apps that target both Android and macOS, or running inference on |
|
28
|
Apple Silicon Macs from Kotlin/JVM. |
|
29
|
|
|
30
|
--- |
|
31
|
|
|
32
|
## Repository Layout |
|
33
|
|
|
34
|
``` |
|
35
|
onde/ |
|
36
|
├── src/ # Rust source — UniFFI-annotated types and OndeChatEngine |
|
37
|
├── uniffi-bindgen/ # Standalone uniffi-bindgen CLI binary (pinned =0.31.0) |
|
38
|
├── sdk/kotlin/ |
|
39
|
│ ├── settings.gradle.kts # Gradle root — includes :lib and :example |
|
40
|
│ ├── build.gradle.kts # root — plugin declarations (all versions live here) |
|
41
|
│ ├── gradle.properties # GROUP, POM_ARTIFACT_ID, VERSION_NAME, signing/OSSRH |
|
42
|
│ ├── gradle/wrapper/ |
|
43
|
│ │ └── gradle-wrapper.properties # pinned Gradle 8.9 |
|
44
|
│ ├── lib/ |
|
45
|
│ │ ├── build.gradle.kts # KMP library + Vanniktech maven-publish |
|
46
|
│ │ ├── consumer-rules.pro # ProGuard: preserve UniFFI JNI bridge symbols |
|
47
|
│ │ └── src/ |
|
48
|
│ │ ├── commonMain/kotlin/.gitkeep # empty — KMP requires this source set to exist |
|
49
|
│ │ ├── shared/kotlin/ # shared srcDir added to BOTH androidMain and jvmMain |
|
50
|
│ │ │ └── com/ondeinference/onde/ |
|
51
|
│ │ │ ├── PlatformSupport.kt # internal interface — setEnv + ensureNativeLoaded |
|
52
|
│ │ │ ├── OndeInference.kt # engine wrapper (internal constructor) |
|
53
|
│ │ │ └── Convenience.kt # OndeSampling, OndeModels, OndeMessage |
|
54
|
│ │ ├── generated/kotlin/ # UniFFI-generated onde.kt ← GITIGNORED |
|
55
|
│ │ ├── androidMain/ |
|
56
|
│ │ │ ├── AndroidManifest.xml |
|
57
|
│ │ │ ├── jniLibs/ # compiled .so per ABI ← GITIGNORED |
|
58
|
│ │ │ └── kotlin/com/ondeinference/onde/ |
|
59
|
│ │ │ └── Platform.android.kt # AndroidPlatform + factory fun OndeInference(context) |
|
60
|
│ │ └── jvmMain/ |
|
61
|
│ │ ├── kotlin/com/ondeinference/onde/ |
|
62
|
│ │ │ ├── Platform.jvm.kt # JvmPlatform + factory fun OndeInference(dataDir) |
|
63
|
│ │ │ └── NativeLoader.kt # extract libonde from JAR resources |
|
64
|
│ │ └── resources/native/ # bundled .dylib/.so ← GITIGNORED |
|
65
|
│ │ ├── macos-aarch64/libonde.dylib |
|
66
|
│ │ ├── macos-x86_64/libonde.dylib |
|
67
|
│ │ ├── linux-x86_64/libonde.so |
|
68
|
│ │ └── linux-aarch64/libonde.so |
|
69
|
│ ├── example/ |
|
70
|
│ │ ├── build.gradle.kts # com.android.application — no plugin versions here |
|
71
|
│ │ ├── proguard-rules.pro |
|
72
|
│ │ ├── README.md |
|
73
|
│ │ └── src/main/ |
|
74
|
│ │ ├── AndroidManifest.xml # INTERNET permission |
|
75
|
│ │ ├── res/values/ |
|
76
|
│ │ │ ├── strings.xml |
|
77
|
│ │ │ └── themes.xml # Material3 NoActionBar activity theme |
|
78
|
│ │ └── kotlin/com/ondeinference/example/ |
|
79
|
│ │ ├── MainActivity.kt # ComponentActivity — enableEdgeToEdge + setContent |
|
80
|
│ │ ├── ChatViewModel.kt # model lifecycle, streaming, history |
|
81
|
│ │ └── ui/ |
|
82
|
│ │ ├── ChatScreen.kt # full Compose UI |
|
83
|
│ │ └── theme/Theme.kt # dynamic colour + dark mode |
|
84
|
│ └── scripts/ |
|
85
|
│ ├── build-android.sh # Rust .so → androidMain/jniLibs/ via cargo-ndk |
|
86
|
│ ├── build-jvm.sh # Rust .dylib/.so → jvmMain/resources/native/ |
|
87
|
│ └── generate-bindings.sh # uniffi-bindgen → src/generated/kotlin/onde.kt |
|
88
|
└── .github/workflows/ |
|
89
|
└── release-sdk-kotlin.yml # tag push → build → Maven Central publish |
|
90
|
``` |
|
91
|
|
|
92
|
--- |
|
93
|
|
|
94
|
## KMP Architecture |
|
95
|
|
|
96
|
### Why KMP Instead of Android-Only |
|
97
|
|
|
98
|
1. **klibs.io listing** — the JetBrains KMP discovery platform requires |
|
99
|
`kotlin-tooling-metadata.json`, which only the `kotlin.multiplatform` Gradle |
|
100
|
plugin generates. An Android-only AAR will not appear on klibs.io. |
|
101
|
2. **JVM target** — Kotlin developers on macOS Apple Silicon can run inference |
|
102
|
locally from desktop/server apps, Gradle plugins, or CLI tools. |
|
103
|
3. **Future-proof** — adding Kotlin/Native targets (iOS, macOS) later requires |
|
104
|
no structural changes. The Swift SDK already covers Apple platforms natively, |
|
105
|
but KMP apps that share Kotlin across Android + iOS could benefit. |
|
106
|
|
|
107
|
### Source Set Strategy: Shared srcDir |
|
108
|
|
|
109
|
Instead of KMP intermediate source sets (which add Gradle complexity and |
|
110
|
require careful dependency resolution for JNA), the SDK uses a **shared srcDir** |
|
111
|
pattern: |
|
112
|
|
|
113
|
``` |
|
114
|
commonMain/ — empty (KMP requires it) |
|
115
|
shared/ — hand-written wrapper code (compiled for BOTH targets) |
|
116
|
generated/ — UniFFI-generated onde.kt (compiled for BOTH targets) |
|
117
|
androidMain/ — Android-specific: Os.setenv, Context factory |
|
118
|
jvmMain/ — JVM-specific: JNA setenv, NativeLoader, File factory |
|
119
|
``` |
|
120
|
|
|
121
|
In `lib/build.gradle.kts`: |
|
122
|
|
|
123
|
```kotlin |
|
124
|
androidMain.get().kotlin.srcDir("src/shared/kotlin") |
|
125
|
androidMain.get().kotlin.srcDir("src/generated/kotlin") |
|
126
|
jvmMain.get().kotlin.srcDir("src/shared/kotlin") |
|
127
|
jvmMain.get().kotlin.srcDir("src/generated/kotlin") |
|
128
|
``` |
|
129
|
|
|
130
|
Both targets compile the shared code independently with their own dependencies. |
|
131
|
Since both are JVM-based, `java.io.File`, JNA, and coroutines are available in |
|
132
|
the shared code. |
|
133
|
|
|
134
|
### Platform Abstraction: Interface Pattern |
|
135
|
|
|
136
|
The shared `OndeInference` class takes a `PlatformSupport` interface via an |
|
137
|
`internal` constructor. Each target provides a concrete implementation and a |
|
138
|
top-level factory function: |
|
139
|
|
|
140
|
``` |
|
141
|
PlatformSupport (interface — shared/) |
|
142
|
├── AndroidPlatform (object — androidMain/) |
|
143
|
│ └── fun OndeInference(context, dataDir?) → OndeInference |
|
144
|
└── JvmPlatform (object — jvmMain/) |
|
145
|
└── fun OndeInference(dataDir?) → OndeInference |
|
146
|
``` |
|
147
|
|
|
148
|
| Operation | Android | JVM | |
|
149
|
|-----------|---------|-----| |
|
150
|
| `setEnv(key, value)` | `Os.setenv(key, value, true)` | JNA → `libc.setenv(key, value, 1)` | |
|
151
|
| `ensureNativeLoaded()` | No-op (APK jniLibs) | `NativeLoader.ensureLoaded()` | |
|
152
|
| Default `dataDir` | `context.filesDir` | `~/.onde/` | |
|
153
|
|
|
154
|
--- |
|
155
|
|
|
156
|
## Key Design Decisions |
|
157
|
|
|
158
|
| Decision | Rationale | |
|
159
|
|---|---| |
|
160
|
| KMP with `androidTarget()` + `jvm()` | Required for klibs.io listing; enables macOS Apple Silicon JVM users | |
|
161
|
| Shared srcDir instead of intermediate source sets | Avoids KMP dependency resolution complexity with JNA (`@aar` vs JAR) | |
|
162
|
| Interface-based platform abstraction | Simpler than `expect`/`actual` when constructor signatures differ between targets | |
|
163
|
| UniFFI Kotlin bindings — not hand-written JNI | Same Rust source as Swift/Dart, zero API drift | |
|
164
|
| `src/generated/kotlin/` is gitignored | Regenerated from Rust on every release | |
|
165
|
| `cargo-ndk` for Android cross-compilation | Handles NDK toolchain wiring for all 4 ABIs transparently | |
|
166
|
| `NativeLoader` extracts dylib from JAR resources | JVM users don't need to manage `java.library.path` manually | |
|
167
|
| Vanniktech `maven-publish` plugin with `KotlinMultiplatform` | Publishes KMP metadata + per-target artifacts to Maven Central | |
|
168
|
| `minSdk 26` | Required for `android.system.Os.setenv` to set `HF_HOME` | |
|
169
|
| JNA dependency (`net.java.dev.jna:jna:5.14.0`) | UniFFI 0.31.0 generates Kotlin bindings using `com.sun.jna.*`. Android uses `@aar` variant; JVM uses plain JAR. | |
|
170
|
| ProGuard consumer rules bundled in AAR | Prevents R8 from stripping UniFFI JNI bridge in consumer apps | |
|
171
|
| `:example` is a Gradle submodule | Plugin versions declared once in root `build.gradle.kts` and inherited | |
|
172
|
|
|
173
|
--- |
|
174
|
|
|
175
|
## UniFFI Kotlin Type Map |
|
176
|
|
|
177
|
| Rust type | UniFFI derive | Kotlin type | |
|
178
|
|---|---|---| |
|
179
|
| `ChatRole` | `uniffi::Enum` | `sealed class ChatRole` | |
|
180
|
| `ChatMessage` | `uniffi::Record` | `data class ChatMessage` | |
|
181
|
| `SamplingConfig` | `uniffi::Record` | `data class SamplingConfig` | |
|
182
|
| `GgufModelConfig` | `uniffi::Record` | `data class GgufModelConfig` | |
|
183
|
| `InferenceResult` | `uniffi::Record` | `data class InferenceResult` | |
|
184
|
| `StreamChunk` | `uniffi::Record` | `data class StreamChunk` | |
|
185
|
| `EngineStatus` | `uniffi::Enum` | `sealed class EngineStatus` | |
|
186
|
| `EngineInfo` | `uniffi::Record` | `data class EngineInfo` | |
|
187
|
| `InferenceError` | `uniffi::Error` | `sealed class InferenceException : Exception` | |
|
188
|
| `OndeChatEngine` | `uniffi::Object` | `class OndeChatEngine` | |
|
189
|
| `StreamChunkListener` | `callback_interface` | `interface StreamChunkListener` | |
|
190
|
|
|
191
|
The generated Kotlin package is `uniffi.onde.*`. The shared `OndeInference.kt` |
|
192
|
re-exports these as `typealias` under `com.ondeinference.onde.*` so callers only |
|
193
|
need one import. |
|
194
|
|
|
195
|
--- |
|
196
|
|
|
197
|
## Build Targets |
|
198
|
|
|
199
|
### Android ABI Targets |
|
200
|
|
|
201
|
| ABI | Rust triple | Typical device | |
|
202
|
|---|---|---| |
|
203
|
| `arm64-v8a` | `aarch64-linux-android` | Modern Android phones (primary) | |
|
204
|
| `armeabi-v7a` | `armv7-linux-androideabi` | Older 32-bit ARM phones | |
|
205
|
| `x86_64` | `x86_64-linux-android` | Android emulators (Intel/AMD) | |
|
206
|
| `x86` | `i686-linux-android` | 32-bit emulators | |
|
207
|
|
|
208
|
### JVM Native Library Targets |
|
209
|
|
|
210
|
| Platform | Rust triple | Library | Primary Use Case | |
|
211
|
|---|---|---|---| |
|
212
|
| macOS Apple Silicon | `aarch64-apple-darwin` | `libonde.dylib` | **Primary** — Mac development + Metal GPU | |
|
213
|
| macOS Intel | `x86_64-apple-darwin` | `libonde.dylib` | Legacy Mac support | |
|
214
|
| Linux x86_64 | `x86_64-unknown-linux-gnu` | `libonde.so` | CI, servers | |
|
215
|
| Linux ARM64 | `aarch64-unknown-linux-gnu` | `libonde.so` | ARM servers | |
|
216
|
|
|
217
|
--- |
|
218
|
|
|
219
|
## Build Sequence (Manual / CI) |
|
220
|
|
|
221
|
### 1. Prerequisites |
|
222
|
|
|
223
|
```bash |
|
224
|
# Rust toolchain |
|
225
|
rustup toolchain install stable |
|
226
|
|
|
227
|
# Android targets + cargo-ndk |
|
228
|
rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android |
|
229
|
cargo install cargo-ndk |
|
230
|
|
|
231
|
# Android NDK |
|
232
|
export ANDROID_NDK_HOME=$HOME/Library/Android/sdk/ndk/26.1.10909125 |
|
233
|
|
|
234
|
# macOS/JVM target (usually pre-installed on Apple Silicon) |
|
235
|
rustup target add aarch64-apple-darwin |
|
236
|
``` |
|
237
|
|
|
238
|
### 2. Build Android .so files |
|
239
|
|
|
240
|
```bash |
|
241
|
./sdk/kotlin/scripts/build-android.sh # release, all ABIs |
|
242
|
./sdk/kotlin/scripts/build-android.sh --debug # debug build |
|
243
|
FILTER_ABI=arm64-v8a ./sdk/kotlin/scripts/build-android.sh # single ABI |
|
244
|
``` |
|
245
|
|
|
246
|
Outputs land in `sdk/kotlin/lib/src/androidMain/jniLibs/<ABI>/libonde.so`. |
|
247
|
|
|
248
|
### 3. Build JVM native library |
|
249
|
|
|
250
|
```bash |
|
251
|
./sdk/kotlin/scripts/build-jvm.sh # host platform, release |
|
252
|
./sdk/kotlin/scripts/build-jvm.sh --debug # debug build |
|
253
|
ONDE_TARGET_TRIPLE=x86_64-apple-darwin ./sdk/kotlin/scripts/build-jvm.sh # cross-compile |
|
254
|
``` |
|
255
|
|
|
256
|
Outputs land in `sdk/kotlin/lib/src/jvmMain/resources/native/<os-arch>/libonde.<ext>`. |
|
257
|
|
|
258
|
### 4. Build the uniffi-bindgen CLI |
|
259
|
|
|
260
|
```bash |
|
261
|
cargo build --manifest-path uniffi-bindgen/Cargo.toml --release |
|
262
|
# Binary: uniffi-bindgen/target/release/uniffi-bindgen |
|
263
|
``` |
|
264
|
|
|
265
|
### 5. Generate Kotlin UniFFI bindings |
|
266
|
|
|
267
|
```bash |
|
268
|
./sdk/kotlin/scripts/generate-bindings.sh |
|
269
|
``` |
|
270
|
|
|
271
|
Produces `sdk/kotlin/lib/src/generated/kotlin/onde.kt` — the complete |
|
272
|
generated binding including all types, the `OndeChatEngine` class, free |
|
273
|
functions, and `System.loadLibrary("onde")` in a companion object static init. |
|
274
|
|
|
275
|
### 6. Build the library |
|
276
|
|
|
277
|
```bash |
|
278
|
cd sdk/kotlin |
|
279
|
|
|
280
|
# Android AAR |
|
281
|
./gradlew :lib:assembleRelease |
|
282
|
|
|
283
|
# JVM JAR (with bundled native lib) |
|
284
|
./gradlew :lib:jvmJar |
|
285
|
|
|
286
|
# Both |
|
287
|
./gradlew :lib:build |
|
288
|
``` |
|
289
|
|
|
290
|
--- |
|
291
|
|
|
292
|
## JVM Native Library Loading |
|
293
|
|
|
294
|
The JVM target bundles native libraries inside the JAR under |
|
295
|
`/native/<os>-<arch>/libonde.<ext>`. The `NativeLoader` object handles |
|
296
|
extraction and loading at runtime: |
|
297
|
|
|
298
|
``` |
|
299
|
OndeInference() factory (jvmMain) |
|
300
|
└── JvmPlatform.ensureNativeLoaded() |
|
301
|
└── NativeLoader.ensureLoaded() |
|
302
|
├── Detect OS + arch from system properties |
|
303
|
├── Extract /native/<os>-<arch>/libonde.<ext> from JAR resources |
|
304
|
├── Write to temp file: <java.io.tmpdir>/onde-native/libonde.<ext> |
|
305
|
└── System.load(tempFile.absolutePath) |
|
306
|
``` |
|
307
|
|
|
308
|
If the bundled library is not found (e.g. development mode without running |
|
309
|
`build-jvm.sh`), `NativeLoader` falls back to `System.loadLibrary("onde")` |
|
310
|
which searches `java.library.path`. |
|
311
|
|
|
312
|
**Important:** `NativeLoader.ensureLoaded()` must run BEFORE any UniFFI type is |
|
313
|
accessed. The `OndeInference` constructor handles this via the `init` block: |
|
314
|
|
|
315
|
```kotlin |
|
316
|
class OndeInference internal constructor(...) : AutoCloseable { |
|
317
|
init { platform.ensureNativeLoaded() } // runs before engine = OndeChatEngine() |
|
318
|
private val engine = uniffi.onde.OndeChatEngine() |
|
319
|
} |
|
320
|
``` |
|
321
|
|
|
322
|
--- |
|
323
|
|
|
324
|
## Android Filesystem Sandbox Setup |
|
325
|
|
|
326
|
On Android, `dirs::home_dir()` (used by `hf-hub` under the hood) panics because |
|
327
|
there is no home directory in the Android sandbox. The Rust engine requires |
|
328
|
`HF_HOME` to be set **before** any model load on Android. |
|
329
|
|
|
330
|
The `OndeInference` wrapper handles this automatically in `setup()`: |
|
331
|
|
|
332
|
```kotlin |
|
333
|
fun setup() { |
|
334
|
if (configured) return |
|
335
|
|
|
336
|
val hfHome = File(dataDir, "models").also { it.mkdirs() } |
|
337
|
val hfHubCache = File(hfHome, "hub").also { it.mkdirs() } |
|
338
|
val tmpDir = File(dataDir, "tmp").also { it.mkdirs() } |
|
339
|
|
|
340
|
platform.setEnv("HF_HOME", hfHome.absolutePath) |
|
341
|
platform.setEnv("HF_HUB_CACHE", hfHubCache.absolutePath) |
|
342
|
platform.setEnv("HUGGINGFACE_HUB_CACHE", hfHubCache.absolutePath) |
|
343
|
platform.setEnv("TMPDIR", tmpDir.absolutePath) |
|
344
|
|
|
345
|
configured = true |
|
346
|
} |
|
347
|
``` |
|
348
|
|
|
349
|
`setup()` is called automatically by `loadDefaultModel()` and `loadModel()`. |
|
350
|
|
|
351
|
### Filesystem layout |
|
352
|
|
|
353
|
``` |
|
354
|
# Android (context.filesDir) |
|
355
|
<filesDir>/ |
|
356
|
├── models/ ← HF_HOME |
|
357
|
│ └── hub/ ← HF_HUB_CACHE |
|
358
|
└── tmp/ ← TMPDIR |
|
359
|
|
|
360
|
# JVM (defaults to ~/.onde/) |
|
361
|
~/.onde/ |
|
362
|
├── models/ ← HF_HOME |
|
363
|
│ └── hub/ ← HF_HUB_CACHE |
|
364
|
└── tmp/ ← TMPDIR |
|
365
|
``` |
|
366
|
|
|
367
|
### Cross-platform model cache comparison |
|
368
|
|
|
369
|
| Platform | SDK | Cache Location | Shared across apps? | |
|
370
|
|---|---|---|---| |
|
371
|
| iOS / tvOS / macOS | Swift | App Group container | Yes (same team + entitlement) | |
|
372
|
| macOS (JVM) | Kotlin | `~/.onde/models/hub/` | Yes (all JVM apps on same user) | |
|
373
|
| Android | Kotlin | `<filesDir>/models/hub/` | No (per-app sandbox) | |
|
374
|
| Flutter | Dart | Platform-dependent | Follows platform conventions | |
|
375
|
|
|
376
|
### Why Android can't share models across apps |
|
377
|
|
|
378
|
Android's security model assigns each app a unique Linux UID at install time. |
|
379
|
The kernel enforces file permissions — app A cannot open a file descriptor |
|
380
|
inside app B's `/data/data/com.B/files/`. There is no platform primitive to |
|
381
|
override this the way iOS App Groups do. The approaches that don't work: |
|
382
|
|
|
383
|
- **`android:sharedUserId`** — deprecated API 29, removed API 33 |
|
384
|
- **`MANAGE_EXTERNAL_STORAGE`** — Google Play rejects unless file manager |
|
385
|
- **SAF (Storage Access Framework)** — requires user interaction every time |
|
386
|
|
|
387
|
Each Onde-powered Android app downloads its own copy (~941 MB). This matches |
|
388
|
how TensorFlow Lite, MediaPipe, and ML Kit handle model caching. The SDK caches |
|
389
|
after first download — bandwidth cost is one-time per app. |
|
390
|
|
|
391
|
--- |
|
392
|
|
|
393
|
## Kotlin Public API |
|
394
|
|
|
395
|
### `OndeInference` — primary entry point |
|
396
|
|
|
397
|
```kotlin |
|
398
|
import com.ondeinference.onde.OndeInference |
|
399
|
import com.ondeinference.onde.OndeSampling |
|
400
|
import com.ondeinference.onde.OndeModels |
|
401
|
import com.ondeinference.onde.OndeMessage |
|
402
|
|
|
403
|
// ── Android ────────────────────────────────────────────────── |
|
404
|
val onde = OndeInference(context) // pass applicationContext |
|
405
|
|
|
406
|
// ── JVM (macOS / Linux) ────────────────────────────────────── |
|
407
|
val onde = OndeInference() // uses ~/.onde/ |
|
408
|
val onde = OndeInference(File("/custom")) // custom cache dir |
|
409
|
|
|
410
|
// ── Shared API (both platforms) ────────────────────────────── |
|
411
|
|
|
412
|
// Load (downloads from HuggingFace Hub on first run) |
|
413
|
val elapsed: Double = onde.loadDefaultModel( |
|
414
|
systemPrompt = "You are helpful.", |
|
415
|
sampling = OndeSampling.mobile() |
|
416
|
) |
|
417
|
|
|
418
|
// Chat (suspend) |
|
419
|
val result = onde.chat("What is Rust?") |
|
420
|
println(result.text) |
|
421
|
|
|
422
|
// Streaming (Flow) |
|
423
|
onde.stream("Tell me a story.").collect { chunk -> |
|
424
|
print(chunk.delta) |
|
425
|
} |
|
426
|
|
|
427
|
// One-shot (no history side-effects) |
|
428
|
val summary = onde.generate( |
|
429
|
messages = listOf( |
|
430
|
OndeMessage.system("Summarise this."), |
|
431
|
OndeMessage.user(longText) |
|
432
|
) |
|
433
|
) |
|
434
|
|
|
435
|
// Status |
|
436
|
val info = onde.info() // EngineInfo |
|
437
|
|
|
438
|
// History |
|
439
|
onde.clearHistory() |
|
440
|
onde.pushHistory(OndeMessage.user("Injected context")) |
|
441
|
|
|
442
|
// Cleanup |
|
443
|
onde.unload() |
|
444
|
onde.close() // decrements Rust Arc refcount |
|
445
|
``` |
|
446
|
|
|
447
|
### `OndeSampling` — convenience constructors |
|
448
|
|
|
449
|
```kotlin |
|
450
|
OndeSampling.default() // temp=0.7, top_p=0.95, max=512 tokens |
|
451
|
OndeSampling.deterministic() // temp=0.0, greedy |
|
452
|
OndeSampling.mobile() // temp=0.7, max=128 tokens |
|
453
|
``` |
|
454
|
|
|
455
|
### `OndeModels` — model config constructors |
|
456
|
|
|
457
|
```kotlin |
|
458
|
OndeModels.default() // platform default (1.5B on Android, 3B on macOS JVM) |
|
459
|
OndeModels.qwen25_1_5b() // ~941 MB |
|
460
|
OndeModels.qwen25_3b() // ~1.93 GB |
|
461
|
``` |
|
462
|
|
|
463
|
### `OndeMessage` — message constructors |
|
464
|
|
|
465
|
```kotlin |
|
466
|
OndeMessage.system("You are helpful.") |
|
467
|
OndeMessage.user("Hello!") |
|
468
|
OndeMessage.assistant("Hi there!") |
|
469
|
``` |
|
470
|
|
|
471
|
--- |
|
472
|
|
|
473
|
## lib/build.gradle.kts Key Sections |
|
474
|
|
|
475
|
```kotlin |
|
476
|
import com.vanniktech.maven.publish.KotlinMultiplatform |
|
477
|
import com.vanniktech.maven.publish.JavadocJar |
|
478
|
import com.vanniktech.maven.publish.SonatypeHost |
|
479
|
|
|
480
|
plugins { |
|
481
|
id("org.jetbrains.kotlin.multiplatform") |
|
482
|
id("com.android.library") |
|
483
|
id("com.vanniktech.maven.publish") |
|
484
|
} |
|
485
|
|
|
486
|
kotlin { |
|
487
|
androidTarget { |
|
488
|
publishLibraryVariants("release") |
|
489
|
compilations.all { kotlinOptions { jvmTarget = "17" } } |
|
490
|
} |
|
491
|
jvm { |
|
492
|
compilations.all { kotlinOptions { jvmTarget = "17" } } |
|
493
|
} |
|
494
|
|
|
495
|
sourceSets { |
|
496
|
androidMain.dependencies { |
|
497
|
implementation("net.java.dev.jna:jna:5.14.0@aar") |
|
498
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") |
|
499
|
} |
|
500
|
jvmMain.dependencies { |
|
501
|
implementation("net.java.dev.jna:jna:5.14.0") |
|
502
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") |
|
503
|
} |
|
504
|
|
|
505
|
// Shared srcDirs — compiled independently for each target |
|
506
|
androidMain.get().kotlin.srcDir("src/shared/kotlin") |
|
507
|
androidMain.get().kotlin.srcDir("src/generated/kotlin") |
|
508
|
jvmMain.get().kotlin.srcDir("src/shared/kotlin") |
|
509
|
jvmMain.get().kotlin.srcDir("src/generated/kotlin") |
|
510
|
} |
|
511
|
} |
|
512
|
|
|
513
|
mavenPublishing { |
|
514
|
configure(KotlinMultiplatform( |
|
515
|
javadocJar = JavadocJar.Empty(), |
|
516
|
sourcesJar = true, |
|
517
|
)) |
|
518
|
publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) |
|
519
|
signAllPublications() |
|
520
|
coordinates("com.ondeinference", "onde-inference", "<version>") |
|
521
|
} |
|
522
|
``` |
|
523
|
|
|
524
|
The Vanniktech plugin reads signing and OSSRH credentials from environment |
|
525
|
variables prefixed with `ORG_GRADLE_PROJECT_`: |
|
526
|
|
|
527
|
| Gradle property | CI env var | |
|
528
|
|---|---| |
|
529
|
| `mavenCentralUsername` | `ORG_GRADLE_PROJECT_mavenCentralUsername` | |
|
530
|
| `mavenCentralPassword` | `ORG_GRADLE_PROJECT_mavenCentralPassword` | |
|
531
|
| `signingKeyId` | `ORG_GRADLE_PROJECT_signingKeyId` | |
|
532
|
| `signingKey` | `ORG_GRADLE_PROJECT_signingKey` | |
|
533
|
| `signingPassword` | `ORG_GRADLE_PROJECT_signingPassword` | |
|
534
|
|
|
535
|
--- |
|
536
|
|
|
537
|
## ProGuard Consumer Rules |
|
538
|
|
|
539
|
The `consumer-rules.pro` bundled in the AAR instructs R8/ProGuard in consumer |
|
540
|
apps to preserve: |
|
541
|
|
|
542
|
- `uniffi.onde.**` — all generated binding classes |
|
543
|
- `com.ondeinference.onde.**` — the public wrapper |
|
544
|
- `uniffi.onde.StreamChunkListener` — callback interface called from Rust |
|
545
|
- Native method declarations (`keepclasseswithmembernames`) |
|
546
|
- Static initializers in `uniffi.onde.*` (contains `System.loadLibrary("onde")`) |
|
547
|
|
|
548
|
Always update `consumer-rules.pro` when adding new top-level UniFFI-exported |
|
549
|
types or callback interfaces. |
|
550
|
|
|
551
|
--- |
|
552
|
|
|
553
|
## klibs.io Discovery |
|
554
|
|
|
555
|
[klibs.io](https://klibs.io) is a JetBrains-maintained search platform for |
|
556
|
Kotlin Multiplatform libraries. It auto-indexes from Maven Central. |
|
557
|
|
|
558
|
### Requirements for listing |
|
559
|
|
|
560
|
| Requirement | How we satisfy it | |
|
561
|
|---|---| |
|
562
|
| Open source on GitHub | `ondeinference/onde` is public | |
|
563
|
| At least one artifact on Maven Central | Published via Vanniktech plugin | |
|
564
|
| Artifact has `kotlin-tooling-metadata.json` | KMP plugin generates this automatically | |
|
565
|
| POM has valid GitHub `url` or `scm.url` | `scm { url.set("https://github.com/ondeinference/onde") }` | |
|
566
|
|
|
567
|
### Timeline |
|
568
|
|
|
569
|
- New libraries appear **within one month** of first Maven Central publish |
|
570
|
(public index update frequency). |
|
571
|
- New versions of already-indexed libraries appear **the next day**. |
|
572
|
- No manual submission needed — discovery is fully automatic. |
|
573
|
|
|
574
|
--- |
|
575
|
|
|
576
|
## Maven Central Publishing (CI) |
|
577
|
|
|
578
|
Trigger: push a semver tag matching `[0-9]+.[0-9]+.[0-9]+`. |
|
579
|
|
|
580
|
```bash |
|
581
|
# 1. Bump VERSION_NAME in sdk/kotlin/gradle.properties |
|
582
|
# 2. Bump version in Cargo.toml [package] |
|
583
|
# 3. Commit, tag, push |
|
584
|
git tag 1.0.1 && git push origin 1.0.1 |
|
585
|
``` |
|
586
|
|
|
587
|
CI (`release-sdk-kotlin.yml`) steps: |
|
588
|
1. Install Rust stable with all 4 Android targets + host target |
|
589
|
2. Cache NDK 26.1.10909125 |
|
590
|
3. Install `cargo-ndk` |
|
591
|
4. `build-android.sh --release` → `.so` files |
|
592
|
5. `build-jvm.sh --release` → `.dylib` / `.so` |
|
593
|
6. Build `uniffi-bindgen` |
|
594
|
7. `generate-bindings.sh` → `onde.kt` |
|
595
|
8. Validate tag == `Cargo.toml` version |
|
596
|
9. `./gradlew :lib:publishAndReleaseToMavenCentral` |
|
597
|
10. Upload artifacts as GitHub Release assets |
|
598
|
|
|
599
|
--- |
|
600
|
|
|
601
|
## Version Synchronisation Rule |
|
602
|
|
|
603
|
`VERSION_NAME` in `sdk/kotlin/gradle.properties` MUST match `version` in the |
|
604
|
root `Cargo.toml`. The CI workflow enforces this and fails fast on mismatch. |
|
605
|
Never bump one without the other. |
|
606
|
|
|
607
|
--- |
|
608
|
|
|
609
|
## Common Pitfalls |
|
610
|
|
|
611
|
| Pitfall | Fix | |
|
612
|
|---|---| |
|
613
|
| `dirs::home_dir()` panics on Android | `setup()` must be called before any Rust model load. `OndeInference.loadDefaultModel()` calls it automatically. | |
|
614
|
| Generated `onde.kt` not found at compile time | Run `scripts/generate-bindings.sh`. The file is gitignored and must be regenerated after every Rust API change. | |
|
615
|
| `.so` files missing from AAR | Run `scripts/build-android.sh`. The `androidMain/jniLibs/` directory is gitignored. | |
|
616
|
| JVM `UnsatisfiedLinkError` at runtime | Run `scripts/build-jvm.sh` to bundle the native lib in JAR resources, or add `libonde.dylib` to `java.library.path`. | |
|
617
|
| UniFFI version mismatch | Keep `uniffi = "=0.31.0"` identical in `Cargo.toml`, `build-deps`, and `uniffi-bindgen/Cargo.toml`. | |
|
618
|
| R8 strips UniFFI bridge at runtime | Ensure `consumer-rules.pro` is present in the AAR and includes `-keep class uniffi.onde.**`. | |
|
619
|
| `System.loadLibrary("onde")` fails | The `.so` files must match the ABI of the device. Check that all 4 ABIs are built and included in `androidMain/jniLibs/`. | |
|
620
|
| `Os.setenv` not available | `minSdk` must be 26+. `android.system.Os` is available from API 21 but `setenv` specifically needs 26. | |
|
621
|
| Gradle fails with `VERSION_NAME` missing | Ensure `gradle.properties` contains `VERSION_NAME=x.y.z`. | |
|
622
|
| `cargo-ndk` not found in CI | Add `cargo install cargo-ndk --locked` step before the build step. | |
|
623
|
| `ANDROID_NDK_HOME` not set | Set it explicitly: `echo "ANDROID_NDK_HOME=$ANDROID_SDK_ROOT/ndk/26.1.10909125" >> $GITHUB_ENV` | |
|
624
|
| `build-android.sh: line XX: arm64: unbound variable` on macOS | Do not use Bash associative arrays. The script uses portable indexed arrays for ABI → Rust target mapping. | |
|
625
|
| `Unresolved reference 'sun'` / `Unresolved reference 'Structure'` in `onde.kt` | UniFFI 0.31.0 depends on JNA (`com.sun.jna.*`). Add `implementation("net.java.dev.jna:jna:5.14.0@aar")` for Android or `implementation("net.java.dev.jna:jna:5.14.0")` for JVM. | |
|
626
|
| HuggingFace download fails on first load | The app must declare `<uses-permission android:name="android.permission.INTERNET" />`. | |
|
627
|
| `Plugin [id: 'com.android.application'] was not found` in `:example` | Open `sdk/kotlin/` (not `sdk/kotlin/example/`) in Android Studio. Plugin versions are inherited from the root `build.gradle.kts`. | |
|
628
|
| `Using singleVariant publishing DSL multiple times` | This error means the old `AndroidSingleVariantLibrary` config is still present. Use `KotlinMultiplatform(...)` instead — the KMP plugin handles variant publishing internally. | |
|
629
|
| Convenience functions (`OndeSampling.default()` etc.) crash before `OndeInference()` created | These functions call into the native library via UniFFI. Always create an `OndeInference` instance first — its `init` block ensures the native library is loaded. | |
|
630
|
|
|
631
|
--- |
|
632
|
|
|
633
|
## Testing |
|
634
|
|
|
635
|
### Unit tests (host JVM — no Android device needed) |
|
636
|
|
|
637
|
Pure Kotlin logic tests that don't call into Rust: |
|
638
|
|
|
639
|
```bash |
|
640
|
cd sdk/kotlin |
|
641
|
./gradlew :lib:test # all JVM unit tests |
|
642
|
./gradlew :lib:jvmTest # JVM target only |
|
643
|
``` |
|
644
|
|
|
645
|
### Instrumented tests (requires Android device or emulator) |
|
646
|
|
|
647
|
Tests that call into Rust via the generated bindings: |
|
648
|
|
|
649
|
```bash |
|
650
|
./gradlew :lib:connectedAndroidTest |
|
651
|
``` |
|
652
|
|
|
653
|
For CI, use an emulator (`x86_64` ABI) with `reactivecircus/android-emulator-runner`. |
|
654
|
|
|
655
|
### JVM integration tests |
|
656
|
|
|
657
|
Tests that load the native library and run inference: |
|
658
|
|
|
659
|
```bash |
|
660
|
# Build the native lib first |
|
661
|
./sdk/kotlin/scripts/build-jvm.sh |
|
662
|
|
|
663
|
# Run JVM tests |
|
664
|
cd sdk/kotlin && ./gradlew :lib:jvmTest |
|
665
|
``` |
|
666
|
|
|
667
|
--- |
|
668
|
|
|
669
|
## Dependency Graph |
|
670
|
|
|
671
|
``` |
|
672
|
OndeInference.kt (shared) |
|
673
|
├── PlatformSupport (interface, shared) |
|
674
|
│ ├── AndroidPlatform (androidMain) |
|
675
|
│ │ └── android.system.Os.setenv |
|
676
|
│ └── JvmPlatform (jvmMain) |
|
677
|
│ ├── JNA → libc.setenv |
|
678
|
│ └── NativeLoader → extracts libonde from JAR resources |
|
679
|
├── uniffi.onde.OndeChatEngine (generated onde.kt) |
|
680
|
├── uniffi.onde.StreamChunkListener (generated onde.kt) |
|
681
|
├── uniffi.onde.*Config / *Message (generated onde.kt) |
|
682
|
│ └── com.sun.jna.Native (JNA) |
|
683
|
│ ├── libonde.so (Android, per ABI) |
|
684
|
│ └── libonde.dylib (macOS, from JAR resources) |
|
685
|
│ └── mistralrs (Metal on macOS, Candle CPU on Android) |
|
686
|
│ └── hf-hub (model download) |
|
687
|
└── Convenience.kt (shared) |
|
688
|
└── OndeSampling, OndeModels, OndeMessage |
|
689
|
``` |
|
690
|
|
|
691
|
--- |
|
692
|
|
|
693
|
## Distribution Registry |
|
694
|
|
|
695
|
| Registry | Artifact | Import | Discovery | |
|
696
|
|---|---|---|---| |
|
697
|
| Maven Central | `com.ondeinference:onde-inference` | `implementation("com.ondeinference:onde-inference:1.0.0")` | [search.maven.org](https://search.maven.org) | |
|
698
|
| klibs.io | `onde-inference` | Same Gradle coordinate | [klibs.io](https://klibs.io) (auto-indexed) | |
|
699
|
| GitHub Releases | `onde-inference-<version>.aar` + `.jar` | Direct download | [github.com/ondeinference/onde/releases](https://github.com/ondeinference/onde/releases) | |
|
700
|
|
|
701
|
### End-game for Kotlin developers |
|
702
|
|
|
703
|
One line in `build.gradle.kts`: |
|
704
|
|
|
705
|
```kotlin |
|
706
|
implementation("com.ondeinference:onde-inference:1.0.0") |
|
707
|
``` |
|
708
|
|
|
709
|
--- |
|
710
|
|
|
711
|
## Example App |
|
712
|
|
|
713
|
A working Jetpack Compose chat app lives at `sdk/kotlin/example/`. It is the |
|
714
|
canonical way to see the SDK in action and the first thing to open when |
|
715
|
verifying a new build. |
|
716
|
|
|
717
|
### Opening in Android Studio |
|
718
|
|
|
719
|
Always open **`sdk/kotlin/`** — the Gradle root. Do not open |
|
720
|
`sdk/kotlin/example/` directly. Android Studio will see both `:lib` and |
|
721
|
`:example` as modules and sync correctly. |
|
722
|
|
|
723
|
### What the example covers |
|
724
|
|
|
725
|
- Loading the default model with `loadDefaultModel()` and showing load time |
|
726
|
- Streaming replies token-by-token via `onde.stream()` and `Flow<StreamChunk>` |
|
727
|
- Multi-turn conversation history maintained in the Rust engine |
|
728
|
- Clearing history without reloading the model |
|
729
|
- Material3 dynamic colour theme with dark mode support |
|
730
|
- Proper IME / navigation-bar inset handling |
|
731
|
|
|
732
|
### Gradle submodule rules |
|
733
|
|
|
734
|
1. **No `settings.gradle.kts` inside `example/`** — Gradle would treat it as |
|
735
|
standalone, breaking plugin resolution and `project(":lib")`. |
|
736
|
2. **No plugin versions in `example/build.gradle.kts`** — inherited from root. |
|
737
|
3. **`include(":example")` in root `settings.gradle.kts`** — required for Gradle. |
|
738
|
4. **`project(":lib")` as the SDK dependency** — builds against local source. |
|
739
|
|
|
740
|
The root `build.gradle.kts` declares all plugins used by any submodule: |
|
741
|
|
|
742
|
```kotlin |
|
743
|
plugins { |
|
744
|
id("com.android.library") version "8.5.2" apply false |
|
745
|
id("com.android.application") version "8.5.2" apply false |
|
746
|
id("org.jetbrains.kotlin.android") version "2.0.21" apply false |
|
747
|
id("org.jetbrains.kotlin.multiplatform") version "2.0.21" apply false |
|
748
|
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false |
|
749
|
id("com.vanniktech.maven.publish") version "0.28.0" apply false |
|
750
|
} |
|
751
|
``` |
|
752
|
|
|
753
|
### Streaming pattern |
|
754
|
|
|
755
|
```kotlin |
|
756
|
val placeholderId = System.nanoTime() |
|
757
|
_uiState.update { it.copy(messages = it.messages + UiMessage(id = placeholderId, ...)) } |
|
758
|
|
|
759
|
val buffer = StringBuilder() |
|
760
|
onde.stream(text).collect { chunk -> |
|
761
|
buffer.append(chunk.delta) |
|
762
|
_uiState.update { state -> |
|
763
|
state.copy(messages = state.messages.replaceLast( |
|
764
|
UiMessage(id = placeholderId, content = buffer.toString(), isStreaming = !chunk.done) |
|
765
|
)) |
|
766
|
} |
|
767
|
} |
|
768
|
``` |
|
769
|
|
|
770
|
### Running the example |
|
771
|
|
|
772
|
```bash |
|
773
|
cd sdk/kotlin |
|
774
|
|
|
775
|
# 1. Build the .so files |
|
776
|
./scripts/build-android.sh |
|
777
|
|
|
778
|
# 2. Generate the Kotlin bindings |
|
779
|
./scripts/generate-bindings.sh |
|
780
|
|
|
781
|
# 3. Install on a connected device |
|
782
|
./gradlew :example:installDebug |
|
783
|
``` |
|
784
|
|
|
785
|
Or open `sdk/kotlin/` in Android Studio and hit Run on the `:example` configuration. |
|
786
|
|
|
787
|
--- |
|
788
|
|
|
789
|
## Cross-Platform SDK Comparison |
|
790
|
|
|
791
|
Onde ships SDKs for four platforms. The Kotlin SDK is one piece of the |
|
792
|
distribution strategy: |
|
793
|
|
|
794
|
| SDK | Primary Platform | GPU Backend | Package Manager | Discovery | |
|
795
|
|-----|-----------------|-------------|-----------------|-----------| |
|
796
|
| **Swift** | iOS, tvOS, macOS | Metal | Swift Package Manager | Swift Package Index | |
|
797
|
| **Dart** | Android, iOS (Flutter) | Platform-dependent | pub.dev | pub.dev search | |
|
798
|
| **Kotlin** | Android, JVM (macOS) | Metal (JVM) / CPU (Android) | Maven Central | klibs.io | |
|
799
|
| **React Native** | Android, iOS | Platform-dependent | npm | npmjs.com | |
|
800
|
|
|
801
|
The Swift SDK is the primary distribution — it runs natively on Apple silicon |
|
802
|
with Metal acceleration. The Kotlin JVM target provides the same Metal-backed |
|
803
|
inference for Kotlin developers on macOS, while the Android target extends |
|
804
|
reach to the Android ecosystem. |
|
805
|
|
|
806
|
--- |
|
807
|
|
|
808
|
*Update this skill when adding KMP targets (e.g. Kotlin/Native for iOS), |
|
809
|
changing the source set structure, or modifying the Maven Central publishing |
|
810
|
pipeline.* |