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.*