@ondeinference / onde / commits / efe935c

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,