Enable Tokio multi-thread runtime for UniFFI async exports
- Switch `tokio` dependency to use `rt-multi-thread` feature - Add `async_runtime = "tokio"` to UniFFI export macros in `ffi.rs` - Update Kotlin SDK skill doc with Android model sharing notes and ContentProvider plan
keypair34 committed
Apr 10, 2026 at 15:26 UTC
efe935c71fc2db3642aaee9fd84a20f563d44c3a
3 files changed
+72
-3
.agents/skills/sdk-kotlin/SKILL.md
+69
@@ -217,6 +217,75 @@ On iOS/macOS, the equivalent paths are inside the App Group container at
217
`<group.com.ondeinference.apps>/models/hub/`. On Android, there is no app group
218
mechanism, so each app gets its own `filesDir`.
219
220
+### Cross-platform model sharing comparison
221
+
222
+| Platform | Mechanism | Shared across apps? | How it works |
223
+|---|---|---|---|
224
+| iOS / tvOS / macOS | App Groups (`group.com.ondeinference.apps`) | Yes — all apps signed by the same team that declare the same App Group entitlement share a single container directory | OS provides a shared `containerURL(forSecurityApplicationGroupIdentifier:)` path; all Onde apps read and write the same `models/hub/` within it |
225
+| Android | Per-app `filesDir` sandbox | No — each app gets its own isolated copy | Linux UID isolation prevents one app from reading another's internal storage; no entitlement or manifest flag can opt in to sharing |
226
+
227
+### Why Android can't do App Groups
228
+
229
+Android's security model assigns each app a unique Linux UID at install time.
230
+The kernel enforces file permissions — app A literally cannot open a file
231
+descriptor inside app B's `/data/data/com.B/files/`. There is no platform
232
+primitive to override this the way iOS App Groups do. The approaches that
233
+_don't_ work:
234
+
235
+- **`android:sharedUserId`** — deprecated in API 29, removed in API 33. Dead end.
236
+- **`MANAGE_EXTERNAL_STORAGE`** — lets you write to `/sdcard/onde/models/` but Google Play rejects apps that use it unless they are file managers or antivirus tools.
237
+- **SAF (Storage Access Framework)** — requires the user to manually pick a directory every time. Not viable for a background model load.
238
+
239
+### What we do today
240
+
241
+Each Onde-powered Android app downloads its own copy of the model (~941 MB for
242
+Qwen 2.5 1.5B). This is what every major Android ML SDK does (TensorFlow Lite,
243
+MediaPipe, ML Kit). Android devices typically ship with 128–256 GB storage, so
244
+1 GB per app is tolerable. The SDK caches after first download, so the user only
245
+pays the bandwidth cost once per app.
246
+
247
+### Future: ContentProvider model manager
248
+
249
+The Android-idiomatic way to share data across apps without special permissions
250
+is a `ContentProvider`. The plan (not yet implemented):
251
+
252
+1. The first Onde-powered app installed acts as the **model host**. It downloads
253
+ models into its own `filesDir` as it does today.
254
+2. It registers a `ContentProvider` at a well-known authority
255
+ (e.g. `com.ondeinference.models`) that serves model files via
256
+ `ParcelFileDescriptor`.
257
+3. Other Onde-powered apps query the provider first. If a model is already
258
+ cached by any sibling app, they get a read-only file descriptor — no
259
+ download, no copy.
260
+4. If the provider is not installed (no sibling app on the device), the app
261
+ falls back to downloading its own copy.
262
+
263
+```kotlin
264
+// In the host app — exposes cached models to sibling apps
265
+class OndeModelProvider : ContentProvider() {
266
+ override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
267
+ // uri = content://com.ondeinference.models/qwen25-1.5b
268
+ val modelFile = File(context!!.filesDir, "models/hub/${uri.lastPathSegment}")
269
+ if (!modelFile.exists()) return null
270
+ return ParcelFileDescriptor.open(modelFile, ParcelFileDescriptor.MODE_READ_ONLY)
271
+ }
272
+}
273
+
274
+// In any Onde-powered app — checks for a cached model before downloading
275
+val uri = Uri.parse("content://com.ondeinference.models/qwen25-1.5b")
276
+val pfd = contentResolver.openFileDescriptor(uri, "r")
277
+if (pfd != null) {
278
+ // model already downloaded by a sibling app — read from file descriptor
279
+} else {
280
+ // no sibling app has the model — download our own copy
281
+}
282
+```
283
+
284
+No special permissions required, works on all API levels, no Google Play policy
285
+issues. The only requirement is that at least one Onde app is installed on the
286
+device. This is tracked but not yet implemented — ship with per-app caching for
287
+now and add the provider when multiple production apps exist.
288
+
289
---
290
291
## Kotlin Public API
Cargo.toml
+1
-1
@@ -25,7 +25,7 @@ anyhow = "1"
25
home = "0.5.4"
26
log = "^0.4"
27
serde = { version = "1.0", features = ["derive"] }
28
-tokio = { version = "1", features = ["macros", "rt", "sync"] }
28
+tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync"] }
29
tsync = "2"
30
31
thiserror = "2"
src/inference/ffi.rs
+2
-2
@@ -87,7 +87,7 @@ pub struct OndeChatEngine {
87
inner: ChatEngine,
88
}
89
90
-#[uniffi::export]
90
+#[uniffi::export(async_runtime = "tokio")]
91
impl OndeChatEngine {
92
// ── Construction ─────────────────────────────────────────────────────
93
@@ -248,7 +248,7 @@ impl OndeChatEngine {
248
/// listener: MyHandler()
249
/// )
250
/// ```
251
-#[uniffi::export]
251
+#[uniffi::export(async_runtime = "tokio")]
252
pub async fn stream_chat_message(
253
engine: Arc<OndeChatEngine>,
254
message: String,