@dvai-bridge/android 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/LICENSE +51 -0
  2. package/README.md +199 -0
  3. package/android/build.gradle +165 -0
  4. package/android/gradle.properties +5 -0
  5. package/android/settings.gradle +1 -0
  6. package/android/src/androidTest/java/co/deepvoiceai/bridge/RealModelIntegrationTest.kt +162 -0
  7. package/android/src/main/AndroidManifest.xml +3 -0
  8. package/android/src/main/java/co/deepvoiceai/bridge/BackendKind.kt +21 -0
  9. package/android/src/main/java/co/deepvoiceai/bridge/BackendSelector.kt +28 -0
  10. package/android/src/main/java/co/deepvoiceai/bridge/BoundServer.kt +39 -0
  11. package/android/src/main/java/co/deepvoiceai/bridge/DVAIBridge.kt +642 -0
  12. package/android/src/main/java/co/deepvoiceai/bridge/DVAIBridgeConfig.kt +119 -0
  13. package/android/src/main/java/co/deepvoiceai/bridge/DVAIBridgeError.kt +51 -0
  14. package/android/src/main/java/co/deepvoiceai/bridge/OffloadProxy.kt +574 -0
  15. package/android/src/main/java/co/deepvoiceai/bridge/ProgressBroadcaster.kt +55 -0
  16. package/android/src/main/java/co/deepvoiceai/bridge/ProgressEvent.kt +25 -0
  17. package/android/src/main/java/co/deepvoiceai/bridge/ReactiveState.kt +60 -0
  18. package/android/src/main/java/co/deepvoiceai/bridge/license/Audience.kt +134 -0
  19. package/android/src/main/java/co/deepvoiceai/bridge/license/Discovery.kt +146 -0
  20. package/android/src/main/java/co/deepvoiceai/bridge/license/LicenseTypes.kt +158 -0
  21. package/android/src/main/java/co/deepvoiceai/bridge/license/LicenseValidator.kt +400 -0
  22. package/android/src/main/java/co/deepvoiceai/bridge/license/PublicKeys.kt +91 -0
  23. package/android/src/test/java/co/deepvoiceai/bridge/BackendSelectorTest.kt +76 -0
  24. package/android/src/test/java/co/deepvoiceai/bridge/CapabilityPrecheckTest.kt +117 -0
  25. package/android/src/test/java/co/deepvoiceai/bridge/DVAIBridgeAPIShapeTest.kt +86 -0
  26. package/android/src/test/java/co/deepvoiceai/bridge/OffloadProxyDecisionTest.kt +144 -0
  27. package/android/src/test/java/co/deepvoiceai/bridge/OffloadProxyForwardingTest.kt +327 -0
  28. package/android/src/test/java/co/deepvoiceai/bridge/ProgressBroadcasterTest.kt +56 -0
  29. package/android/src/test/java/co/deepvoiceai/bridge/license/LicenseValidatorTest.kt +539 -0
  30. package/package.json +19 -0
@@ -0,0 +1,39 @@
1
+ package co.deepvoiceai.bridge
2
+
3
+ import co.deepvoiceai.bridge.license.LicenseStatus
4
+
5
+ /**
6
+ * Result of a successful [DVAIBridge.start] call. Mirrors the iOS DVAIBridge
7
+ * `BoundServer` struct + the Capacitor JS shim's StartResult.
8
+ *
9
+ * @param baseUrl Full base URL of the embedded OpenAI-compatible server,
10
+ * including the `/v1` suffix. Example:
11
+ * `"http://127.0.0.1:38883/v1"`.
12
+ * @param port Port the HTTP server actually bound to (port-fallback may
13
+ * have moved it past [StartOptions.httpBasePort]).
14
+ * @param backend The backend that actually loaded — useful when [StartOptions]
15
+ * set [BackendKind.Auto] and the consumer wants to know what
16
+ * was picked.
17
+ * @param modelId Stable identifier for the loaded model. Surfaced in the
18
+ * `model` field of every OpenAI response.
19
+ * @param licenseStatus v3.3 — outcome of the offline JWT license check that
20
+ * ran during [DVAIBridge.start]. Null when the validator
21
+ * didn't run (legacy callers, missing context). Production
22
+ * starts always carry a non-null status here; failure modes
23
+ * throw before this struct is produced.
24
+ */
25
+ data class BoundServer(
26
+ val baseUrl: String,
27
+ val port: Int,
28
+ val backend: BackendKind,
29
+ val modelId: String,
30
+ val licenseStatus: LicenseStatus? = null,
31
+ )
32
+
33
+ /** Read-only status snapshot returned by [DVAIBridge.status]. */
34
+ data class StatusInfo(
35
+ val running: Boolean,
36
+ val baseUrl: String? = null,
37
+ val backend: BackendKind? = null,
38
+ val modelId: String? = null,
39
+ )
@@ -0,0 +1,642 @@
1
+ package co.deepvoiceai.bridge
2
+
3
+ import android.content.Context
4
+ import android.os.Build
5
+ import co.deepvoiceai.bridge.license.LicenseStatus
6
+ import co.deepvoiceai.bridge.license.LicenseValidator
7
+ import co.deepvoiceai.bridge.llama.core.ModelDownloader
8
+ import co.deepvoiceai.bridge.shared.core.CorsConfig
9
+ import co.deepvoiceai.bridge.shared.core.capability.CapabilityCache
10
+ import co.deepvoiceai.bridge.shared.core.capability.DeviceID
11
+ import co.deepvoiceai.bridge.shared.core.capability.CapabilityPrecheck
12
+ import co.deepvoiceai.bridge.shared.core.capability.DeviceCapabilityHints
13
+ import co.deepvoiceai.bridge.shared.core.capability.PrecheckMode
14
+ import co.deepvoiceai.bridge.shared.core.discovery.NsdAdvertiser
15
+ import co.deepvoiceai.bridge.shared.core.discovery.NsdDiscovery
16
+ import co.deepvoiceai.bridge.shared.core.discovery.PeerTxtKeys
17
+ import co.deepvoiceai.bridge.shared.core.offload.OffloadConfig
18
+ import co.deepvoiceai.bridge.shared.core.pairing.PairingPolicy
19
+ import kotlinx.serialization.Serializable
20
+ import co.deepvoiceai.bridge.shared.core.pairing.PairingRequest
21
+ import co.deepvoiceai.bridge.shared.core.pairing.PairingStore
22
+ import kotlinx.coroutines.Dispatchers
23
+ import kotlinx.coroutines.flow.MutableSharedFlow
24
+ import kotlinx.coroutines.flow.SharedFlow
25
+ import kotlinx.coroutines.flow.asSharedFlow
26
+ import kotlinx.coroutines.sync.Mutex
27
+ import kotlinx.coroutines.sync.withLock
28
+ import kotlinx.coroutines.withContext
29
+ import java.io.File
30
+
31
+ // Aliases for the per-backend PluginState classes — they share names but
32
+ // live in distinct packages, so we can't import all three with bare names
33
+ // without collisions.
34
+ import co.deepvoiceai.bridge.llama.core.PluginState as LlamaPluginState
35
+ import co.deepvoiceai.bridge.mediapipe.core.PluginState as MediaPipePluginState
36
+ import co.deepvoiceai.bridge.litert.core.LiteRTPluginState
37
+
38
+ /**
39
+ * The DVAIBridge Android Native SDK entry point. Singleton (Kotlin `object`)
40
+ * — same shape as iOS `DVAIBridge.shared` and the Capacitor JS shim.
41
+ *
42
+ * Lifecycle:
43
+ *
44
+ * 1. Call [init] once from your `Application.onCreate()` (or any other
45
+ * one-time bootstrap) to give the bridge an `applicationContext`.
46
+ * [start] will throw [IllegalStateException] if you skip this.
47
+ * 2. Call [start] with [StartOptions] — pick a backend explicitly or pass
48
+ * [BackendKind.Auto] to let [BackendSelector] pick from the
49
+ * `modelPath` extension.
50
+ * 3. Hit `http://127.0.0.1:<port>/v1/...` for OpenAI-compatible HTTP
51
+ * requests, OR collect from [reactive] / [progressFlow] for in-process
52
+ * observables.
53
+ * 4. Call [stop] to release the backend and free the port.
54
+ *
55
+ * Thread-safety: all state-mutating methods serialize through a Mutex,
56
+ * matching the per-core `PluginState` pattern.
57
+ */
58
+ object DVAIBridge {
59
+ private val mutex = Mutex()
60
+ private val broadcaster = ProgressBroadcaster()
61
+
62
+ /** Compose- / Lifecycle-friendly observable view of the running state. */
63
+ val reactive: DVAIBridgeReactiveState = DVAIBridgeReactiveState()
64
+
65
+ /** Shared progress-event stream. Idiomatic Kotlin surface. */
66
+ val progressFlow: SharedFlow<ProgressEvent> get() = broadcaster.flow
67
+
68
+ @Volatile private var applicationContext: Context? = null
69
+ @Volatile private var activePlugin: Any? = null
70
+ @Volatile private var activeBackend: BackendKind? = null
71
+ @Volatile private var activeServer: BoundServer? = null
72
+
73
+ // -------------------------------------------------------------------------
74
+ // Phase 3 Task 8b — distributed inference / device offload
75
+ // -------------------------------------------------------------------------
76
+ @Volatile private var nsdDiscovery: NsdDiscovery? = null
77
+ @Volatile private var nsdAdvertiser: NsdAdvertiser? = null
78
+ @Volatile private var capabilityCache: CapabilityCache? = null
79
+ @Volatile private var pairingStore: PairingStore? = null
80
+ @Volatile private var pairingPolicy: PairingPolicy? = null
81
+
82
+ // -------------------------------------------------------------------------
83
+ // v3.2 Phase 5 — pre-routing offload proxy
84
+ // -------------------------------------------------------------------------
85
+ @Volatile private var offloadProxy: OffloadProxy? = null
86
+ /** Set true after the precheck classifies the device into offload-only.
87
+ * In that mode no model is downloaded / loaded; every request forwards
88
+ * to a paired peer. */
89
+ @Volatile var offloadOnlyMode: Boolean = false
90
+ private set
91
+
92
+ /**
93
+ * Hot stream of [PairingRequest]s — surfaced to host UI when a peer
94
+ * device requests to pair with this device over the LAN. Compose
95
+ * collects `lifecycleScope.launch { DVAIBridge.pairingRequests.collect ... }`.
96
+ *
97
+ * Empty / never-emits when offload is disabled.
98
+ */
99
+ val pairingRequests: SharedFlow<PairingRequest>
100
+ get() = pairingPolicy?.requests ?: emptyPairingRequests
101
+
102
+ private val emptyPairingRequests: SharedFlow<PairingRequest> =
103
+ MutableSharedFlow<PairingRequest>(replay = 0, extraBufferCapacity = 1).asSharedFlow()
104
+
105
+ /**
106
+ * One-time bootstrap. Stores [applicationContext] for backends that need
107
+ * a Context (currently the MediaPipe backend). Idempotent — additional
108
+ * calls are no-ops.
109
+ */
110
+ @JvmStatic
111
+ fun init(applicationContext: Context) {
112
+ if (this.applicationContext == null) {
113
+ this.applicationContext = applicationContext.applicationContext
114
+ }
115
+ }
116
+
117
+ /**
118
+ * v3.2 — pre-init hardware assessment.
119
+ *
120
+ * Returns a JSON-serializable description of how this device would
121
+ * handle local inference, BEFORE any model download/load. The SDK
122
+ * itself never shows UI for hardware decisions — consumer apps
123
+ * call this and decide their own UX based on the returned `mode`:
124
+ *
125
+ * - `OK` → device can comfortably run the model
126
+ * locally; [start] proceeds normally.
127
+ * - `OFFLOAD_ONLY` → device can run but slowly (below
128
+ * `minLocalCapability`); [start] skips the
129
+ * model load and routes every request to
130
+ * a paired peer.
131
+ * - `TOO_WEAK` → device is below the hardware floor (3
132
+ * tok/s by default); [start] also skips
133
+ * the model load. Consumers typically bail
134
+ * rather than even calling [start].
135
+ *
136
+ * The result is `kotlinx.serialization.Serializable` so it can be
137
+ * passed to a Capacitor / React Native bridge as JSON or stored
138
+ * directly. Pass overrides for [hardwareMinimum] and
139
+ * [minLocalCapability] to mirror your `OffloadConfig`.
140
+ */
141
+ @JvmStatic
142
+ fun assessHardware(
143
+ hardwareMinimum: Double = 3.0,
144
+ minLocalCapability: Double = 10.0,
145
+ ): HardwareAssessment {
146
+ val ctx = applicationContext
147
+ ?: throw DVAIBridgeError.ConfigurationInvalid(
148
+ "DVAIBridge.init(context) must be called before assessHardware().",
149
+ )
150
+ val precheck = CapabilityPrecheck.assess(
151
+ context = ctx,
152
+ thresholds = CapabilityPrecheck.Thresholds(
153
+ hardwareMinimum = hardwareMinimum,
154
+ minLocalCapability = minLocalCapability,
155
+ ),
156
+ )
157
+ return HardwareAssessment(
158
+ mode = precheck.mode,
159
+ tokPerSec = precheck.tokPerSec,
160
+ reason = precheck.reason,
161
+ hints = precheck.hints,
162
+ )
163
+ }
164
+
165
+ /**
166
+ * Boot the embedded HTTP server with the chosen backend. Throws
167
+ * [DVAIBridgeError.AlreadyStarted] if a previous start() hasn't been
168
+ * paired with a [stop]. Throws [DVAIBridgeError.BackendUnavailable] if
169
+ * the runtime can't satisfy the requested backend.
170
+ */
171
+ suspend fun start(opts: StartOptions): BoundServer = mutex.withLock {
172
+ activeServer?.let { server ->
173
+ throw DVAIBridgeError.AlreadyStarted(server.backend, server.baseUrl)
174
+ }
175
+ val resolved = BackendSelector.resolve(opts)
176
+ if (resolved == BackendKind.Auto) {
177
+ // Should be unreachable — BackendSelector.resolve is total for non-Auto.
178
+ throw DVAIBridgeError.ConfigurationInvalid("BackendSelector returned Auto; cannot dispatch.")
179
+ }
180
+ broadcaster.emit(ProgressEvent.Started(phase = "start"))
181
+
182
+ // ---------------------------------------------------------------
183
+ // v3.3 — offline JWT license validation.
184
+ //
185
+ // BSL 1.1 enforcement: in production (non-DEBUG) Android builds
186
+ // the SDK refuses to start without a valid commercial / trial
187
+ // license. Debug builds (BuildConfig.DEBUG=true, FLAG_DEBUGGABLE,
188
+ // or DVAI_FORCE_DEV=1) skip enforcement entirely.
189
+ //
190
+ // Runs BEFORE backend init so failed validation aborts cleanly
191
+ // without downloading a model or binding a port.
192
+ // ---------------------------------------------------------------
193
+ val ctxForLicense = applicationContext
194
+ ?: throw DVAIBridgeError.ConfigurationInvalid(
195
+ "DVAIBridge.init(context) must be called before start().",
196
+ )
197
+ val licenseStatus: LicenseStatus = try {
198
+ LicenseValidator(
199
+ context = ctxForLicense,
200
+ token = opts.licenseToken,
201
+ path = opts.licenseKeyPath,
202
+ hostBuildConfigDebug = opts.hostBuildConfigDebug,
203
+ ).validateAndAssert()
204
+ } catch (e: co.deepvoiceai.bridge.license.LicenseRequiredError) {
205
+ // Surface the license failure on the progress stream so host UIs
206
+ // can render the error inline; wrap as BackendError because the
207
+ // progress sealed class' Failed.error is DVAIBridgeError-typed.
208
+ // The original LicenseRequiredError is rethrown unchanged.
209
+ broadcaster.emit(
210
+ ProgressEvent.Failed(
211
+ phase = "license",
212
+ error = DVAIBridgeError.BackendError(e),
213
+ ),
214
+ )
215
+ throw e
216
+ }
217
+
218
+ // ---------------------------------------------------------------
219
+ // v3.2 Phase 5 — pre-init capability gate.
220
+ //
221
+ // Runs the heuristic (no model required) and decides:
222
+ // - TOO_WEAK → call host onHardwareTooWeak hook + throw
223
+ // - OFFLOAD_ONLY → skip backend init; bring up only proxy +
224
+ // discovery + pairing
225
+ // - OK → start backend normally + proxy in front
226
+ //
227
+ // Only executes when offload.enabled === true; otherwise the
228
+ // existing v3.1 path runs unchanged.
229
+ // ---------------------------------------------------------------
230
+ val offload = opts.offload?.takeIf { it.enabled }
231
+ offloadOnlyMode = false
232
+ if (offload != null) {
233
+ val ctx = applicationContext
234
+ ?: throw DVAIBridgeError.ConfigurationInvalid(
235
+ "DVAIBridge.init(context) must be called before start() with offload enabled.",
236
+ )
237
+ val precheck = CapabilityPrecheck.assess(
238
+ context = ctx,
239
+ thresholds = CapabilityPrecheck.Thresholds(
240
+ hardwareMinimum = offload.hardwareMinimum,
241
+ minLocalCapability = offload.minLocalCapability,
242
+ ),
243
+ )
244
+ broadcaster.emit(
245
+ ProgressEvent.Progress(
246
+ phase = "precheck",
247
+ percent = -1f,
248
+ message = "[DVAI/precheck] ${precheck.mode}: ${precheck.reason}",
249
+ ),
250
+ )
251
+ // v3.2 — both TOO_WEAK and OFFLOAD_ONLY collapse to "skip
252
+ // backend init" from the SDK's perspective. The SDK does
253
+ // NOT throw and does NOT show any UI; consumers query
254
+ // [assessHardware] ahead of start() and decide their own UX.
255
+ offloadOnlyMode =
256
+ precheck.mode == PrecheckMode.OFFLOAD_ONLY ||
257
+ precheck.mode == PrecheckMode.TOO_WEAK
258
+ }
259
+
260
+ // Determine the backend's internal port. When the proxy is in
261
+ // front, shift the backend off the user-facing port to avoid
262
+ // collision: backend at httpBasePort + 100, proxy at httpBasePort.
263
+ val proxyEnabled = offload != null
264
+ val backendInternalBasePort = if (proxyEnabled) opts.httpBasePort + 100 else opts.httpBasePort
265
+ val backendOpts = if (proxyEnabled && !offloadOnlyMode) {
266
+ opts.copy(httpBasePort = backendInternalBasePort)
267
+ } else {
268
+ opts
269
+ }
270
+
271
+ // ---- Backend init (skipped in offload-only mode) ----
272
+ val backendServer: BoundServer? = if (offloadOnlyMode) {
273
+ broadcaster.emit(
274
+ ProgressEvent.Progress(
275
+ phase = "backend",
276
+ percent = -1f,
277
+ message = "[DVAI/precheck] OFFLOAD_ONLY — backend init skipped (no model download/load).",
278
+ ),
279
+ )
280
+ null
281
+ } else {
282
+ try {
283
+ when (resolved) {
284
+ BackendKind.Llama -> startLlama(backendOpts)
285
+ BackendKind.MediaPipe -> startMediaPipe(backendOpts)
286
+ BackendKind.LiteRT -> startLiteRT(backendOpts)
287
+ BackendKind.Auto -> error("unreachable")
288
+ }
289
+ } catch (e: DVAIBridgeError) {
290
+ broadcaster.emit(ProgressEvent.Failed(phase = "start", error = e))
291
+ throw e
292
+ } catch (e: Throwable) {
293
+ val wrapped = DVAIBridgeError.BackendError(e)
294
+ broadcaster.emit(ProgressEvent.Failed(phase = "start", error = wrapped))
295
+ throw wrapped
296
+ }
297
+ }
298
+
299
+ // ---- Public-facing server: backend (no offload) OR proxy (offload) ----
300
+ val rawServer: BoundServer = if (proxyEnabled) {
301
+ // Bring up offload services (discovery + pairing) BEFORE proxy
302
+ // start so the proxy can read the live peer list on first request.
303
+ try {
304
+ initOffload(offload!!, backendServer ?: BoundServer(
305
+ baseUrl = "http://127.0.0.1:0",
306
+ port = 0,
307
+ backend = resolved,
308
+ modelId = "",
309
+ ))
310
+ } catch (e: Throwable) {
311
+ broadcaster.emit(
312
+ ProgressEvent.Progress(
313
+ phase = "start",
314
+ percent = -1f,
315
+ message = "[DVAI/offload] init skipped: ${e.message}",
316
+ ),
317
+ )
318
+ }
319
+
320
+ val proxy = OffloadProxy(
321
+ backendBaseUrl = backendServer?.baseUrl,
322
+ offloadConfig = offload!!,
323
+ pairingPolicy = pairingPolicy,
324
+ discovery = nsdDiscovery,
325
+ appId = opts.modelId ?: "co.deepvoiceai.dvai-bridge",
326
+ selfDeviceId = applicationContext?.let { DeviceID.get(it) } ?: "unknown",
327
+ )
328
+ val boundProxyPort = proxy.start(basePort = opts.httpBasePort, maxAttempts = opts.httpMaxPortAttempts)
329
+ offloadProxy = proxy
330
+ BoundServer(
331
+ baseUrl = proxy.baseUrl()!!,
332
+ port = boundProxyPort,
333
+ backend = resolved,
334
+ modelId = backendServer?.modelId.orEmpty(),
335
+ )
336
+ } else {
337
+ backendServer!!
338
+ }
339
+
340
+ // Attach the license status so host apps can inspect the
341
+ // licensee / expiry without re-running the validator.
342
+ val server: BoundServer = rawServer.copy(licenseStatus = licenseStatus)
343
+
344
+ activeServer = server
345
+ activeBackend = resolved
346
+
347
+ // When the proxy is NOT in use, run the legacy offload-init path
348
+ // for parity with v3.0/v3.1 consumers that have offload set but
349
+ // not enabled (or for non-offload consumers — initOffload is a no-op
350
+ // when offload == null, but defensively gate on enabled here too).
351
+ if (!proxyEnabled) {
352
+ opts.offload?.takeIf { it.enabled }?.let { off ->
353
+ try {
354
+ initOffload(off, server)
355
+ } catch (e: Throwable) {
356
+ broadcaster.emit(
357
+ ProgressEvent.Progress(
358
+ phase = "start",
359
+ percent = -1f,
360
+ message = "[DVAI/offload] init skipped: ${e.message}",
361
+ ),
362
+ )
363
+ }
364
+ }
365
+ }
366
+
367
+ reactive.onStarted(server)
368
+ broadcaster.emit(ProgressEvent.Completed(phase = "start"))
369
+ return server
370
+ }
371
+
372
+ /**
373
+ * Initialise the discovery, capability cache, and pairing layer for
374
+ * the current run. Called from [start] when [OffloadConfig.enabled]
375
+ * is true. Idempotent within a single start/stop cycle.
376
+ */
377
+ private fun initOffload(off: OffloadConfig, server: BoundServer) {
378
+ val ctx = applicationContext
379
+ ?: throw DVAIBridgeError.ConfigurationInvalid(
380
+ "DVAIBridge.init(context) must be called before start() with offload enabled.",
381
+ )
382
+ val deviceId = DeviceID.get(ctx)
383
+ capabilityCache = CapabilityCache(ctx)
384
+ val store = PairingStore(ctx).also { pairingStore = it }
385
+ pairingPolicy = PairingPolicy(store)
386
+
387
+ if (off.discoverLAN) {
388
+ val discovery = NsdDiscovery(ctx, selfDeviceId = deviceId)
389
+ try {
390
+ discovery.start()
391
+ nsdDiscovery = discovery
392
+ } catch (e: Throwable) {
393
+ broadcaster.emit(
394
+ ProgressEvent.Progress(
395
+ phase = "start",
396
+ percent = -1f,
397
+ message = "[DVAI/discovery] start failed: ${e.message}",
398
+ ),
399
+ )
400
+ }
401
+
402
+ val advertiser = NsdAdvertiser(ctx)
403
+ val txt = mutableMapOf(
404
+ PeerTxtKeys.DEVICE_ID to deviceId,
405
+ PeerTxtKeys.DEVICE_NAME to deviceNameFromBuild(),
406
+ PeerTxtKeys.DVAI_VERSION to LIBRARY_VERSION,
407
+ )
408
+ if (server.modelId.isNotEmpty()) txt[PeerTxtKeys.MODELS] = server.modelId
409
+ try {
410
+ advertiser.start(
411
+ serviceName = "dvai-bridge-${deviceId.take(8)}",
412
+ port = server.port,
413
+ txt = txt,
414
+ )
415
+ nsdAdvertiser = advertiser
416
+ } catch (e: Throwable) {
417
+ broadcaster.emit(
418
+ ProgressEvent.Progress(
419
+ phase = "start",
420
+ percent = -1f,
421
+ message = "[DVAI/advertiser] start failed: ${e.message}",
422
+ ),
423
+ )
424
+ }
425
+ }
426
+ }
427
+
428
+ private fun deviceNameFromBuild(): String =
429
+ listOf(Build.MANUFACTURER, Build.MODEL).joinToString(" ").trim().ifEmpty { "android" }
430
+
431
+ private val LIBRARY_VERSION: String = "3.0.0"
432
+
433
+ private suspend fun startLlama(opts: StartOptions): BoundServer {
434
+ val plugin = LlamaPluginState()
435
+ val result = plugin.start(opts.toMap())
436
+ activePlugin = plugin
437
+ return result.toBoundServer(BackendKind.Llama)
438
+ }
439
+
440
+ private suspend fun startMediaPipe(opts: StartOptions): BoundServer {
441
+ val ctx = applicationContext
442
+ ?: throw DVAIBridgeError.ConfigurationInvalid(
443
+ "DVAIBridge.init(context) must be called before start() for the MediaPipe backend.",
444
+ )
445
+ val plugin = MediaPipePluginState()
446
+ // MediaPipe PluginState takes context as a per-start arg (not a ctor arg)
447
+ // because the backend re-resolves context at every start to support
448
+ // async-attach lifecycles in Capacitor consumer apps.
449
+ val result = plugin.start(opts.toMap(), ctx)
450
+ activePlugin = plugin
451
+ return result.toBoundServer(BackendKind.MediaPipe)
452
+ }
453
+
454
+ private suspend fun startLiteRT(opts: StartOptions): BoundServer {
455
+ val plugin = LiteRTPluginState()
456
+ val result = plugin.start(opts.toMap())
457
+ activePlugin = plugin
458
+ return result.toBoundServer(BackendKind.LiteRT)
459
+ }
460
+
461
+ /** Stop the active backend. Idempotent — safe to call when nothing is running. */
462
+ suspend fun stop() = mutex.withLock {
463
+ // Tear down offload services first so peers see us drop off
464
+ // before the HTTP port closes.
465
+ try { nsdAdvertiser?.stop() } catch (_: Throwable) { }
466
+ try { nsdDiscovery?.stop() } catch (_: Throwable) { }
467
+ // v3.2 — stop the proxy *after* discovery so in-flight peer
468
+ // forwards drain cleanly, but before the backend so consumer
469
+ // requests stop coming in before the backend goes away.
470
+ try { offloadProxy?.stop() } catch (_: Throwable) { }
471
+ offloadProxy = null
472
+ offloadOnlyMode = false
473
+ nsdAdvertiser = null
474
+ nsdDiscovery = null
475
+ capabilityCache = null
476
+ pairingPolicy = null
477
+ pairingStore = null
478
+
479
+ val plugin = activePlugin ?: run {
480
+ activePlugin = null
481
+ activeBackend = null
482
+ activeServer = null
483
+ reactive.onStopped()
484
+ return@withLock
485
+ }
486
+ when (plugin) {
487
+ is LlamaPluginState -> plugin.stop()
488
+ is MediaPipePluginState -> plugin.stop()
489
+ is LiteRTPluginState -> plugin.stop()
490
+ }
491
+ activePlugin = null
492
+ activeBackend = null
493
+ activeServer = null
494
+ reactive.onStopped()
495
+ }
496
+
497
+ /** Synchronous read of the most-recent state. */
498
+ @JvmStatic
499
+ fun status(): StatusInfo {
500
+ val server = activeServer
501
+ return StatusInfo(
502
+ running = server != null,
503
+ baseUrl = server?.baseUrl,
504
+ backend = server?.backend,
505
+ modelId = server?.modelId,
506
+ )
507
+ }
508
+
509
+ /**
510
+ * Download a model file with sha256 verification. Wraps llama-core's
511
+ * `ModelDownloader` (resumable HTTP Range request via OkHttp). Available
512
+ * regardless of the chosen backend — the MediaPipe / LiteRT backends
513
+ * use the same downloader to pull their .task / .tflite checkpoints.
514
+ */
515
+ suspend fun downloadModel(opts: DownloadOptions): DownloadResult {
516
+ val ctx = applicationContext
517
+ ?: throw DVAIBridgeError.ConfigurationInvalid(
518
+ "DVAIBridge.init(context) must be called before downloadModel().",
519
+ )
520
+ broadcaster.emit(ProgressEvent.Started(phase = "download"))
521
+ val downloader = ModelDownloader(ctx)
522
+ return try {
523
+ // ModelDownloader.downloadModel is BLOCKING — bounce to IO.
524
+ val (absolutePath, _) = withContext(Dispatchers.IO) {
525
+ downloader.downloadModel(
526
+ url = opts.url,
527
+ expectedSha256 = opts.sha256,
528
+ destFilename = opts.destFilename,
529
+ headers = emptyMap(),
530
+ onProgress = { bytesDone, bytesTotal ->
531
+ val percent = if (bytesTotal != null && bytesTotal > 0) {
532
+ bytesDone.toFloat() / bytesTotal.toFloat()
533
+ } else {
534
+ -1f
535
+ }
536
+ broadcaster.emit(
537
+ ProgressEvent.Progress(
538
+ phase = "download",
539
+ percent = percent,
540
+ message = "$bytesDone bytes",
541
+ ),
542
+ )
543
+ },
544
+ )
545
+ }
546
+ val file = File(absolutePath)
547
+ val result = DownloadResult(
548
+ path = absolutePath,
549
+ sha256 = opts.sha256.lowercase(),
550
+ sizeBytes = file.length(),
551
+ )
552
+ broadcaster.emit(ProgressEvent.Completed(phase = "download"))
553
+ result
554
+ } catch (e: ModelDownloader.DownloadError.ChecksumMismatch) {
555
+ val wrapped = DVAIBridgeError.ChecksumMismatch(e.expected, e.got)
556
+ broadcaster.emit(ProgressEvent.Failed(phase = "download", error = wrapped))
557
+ throw wrapped
558
+ } catch (e: DVAIBridgeError) {
559
+ broadcaster.emit(ProgressEvent.Failed(phase = "download", error = e))
560
+ throw e
561
+ } catch (e: Throwable) {
562
+ val wrapped = DVAIBridgeError.DownloadFailed(
563
+ e.message ?: e::class.qualifiedName ?: "unknown",
564
+ e,
565
+ )
566
+ broadcaster.emit(ProgressEvent.Failed(phase = "download", error = wrapped))
567
+ throw wrapped
568
+ }
569
+ }
570
+
571
+ /** Register a Java-friendly progress callback. */
572
+ @JvmStatic
573
+ fun addProgressListener(listener: ProgressListener) {
574
+ broadcaster.addListener(listener)
575
+ }
576
+
577
+ /** Unregister a Java-friendly progress callback. */
578
+ @JvmStatic
579
+ fun removeProgressListener(listener: ProgressListener) {
580
+ broadcaster.removeListener(listener)
581
+ }
582
+ }
583
+
584
+ /**
585
+ * v3.2 — JSON-serializable result of [DVAIBridge.assessHardware]. Returned
586
+ * to consumer code so the app developer can decide whether to call
587
+ * [DVAIBridge.start] and what (if anything) to surface in the UI.
588
+ *
589
+ * `@Serializable` so it round-trips cleanly through Capacitor / React
590
+ * Native / Pigeon bridges as JSON without any custom converter.
591
+ */
592
+ @Serializable
593
+ data class HardwareAssessment(
594
+ /** Lifecycle mode the SDK would enter on [DVAIBridge.start]. */
595
+ val mode: PrecheckMode,
596
+ /** Estimated decode tok/s for any 1–3B-class model on this device. */
597
+ val tokPerSec: Double,
598
+ /** Human-readable explanation; safe to log or display. */
599
+ val reason: String,
600
+ /** Underlying hints used to compute the estimate. */
601
+ val hints: DeviceCapabilityHints,
602
+ )
603
+
604
+ /**
605
+ * Convert [StartOptions] to the loose `Map<String, Any?>` that each
606
+ * per-backend `PluginState` consumes. Mirrors the JS-bridge contract used
607
+ * by Capacitor wrappers.
608
+ */
609
+ private fun StartOptions.toMap(): Map<String, Any?> {
610
+ val map = mutableMapOf<String, Any?>(
611
+ "contextSize" to contextSize,
612
+ "threads" to threads,
613
+ "httpBasePort" to httpBasePort,
614
+ "httpMaxPortAttempts" to httpMaxPortAttempts,
615
+ "corsOrigin" to when (val c = corsOrigin) {
616
+ CorsConfig.Wildcard -> "*"
617
+ is CorsConfig.Exact -> c.origin
618
+ is CorsConfig.Allowlist -> c.origins
619
+ },
620
+ )
621
+ modelPath?.let { map["modelPath"] = it }
622
+ tokenizerPath?.let { map["tokenizerPath"] = it }
623
+ mmprojPath?.let { map["mmprojPath"] = it }
624
+ chatTemplate?.let { map["chatTemplate"] = it }
625
+ modelId?.let { map["modelId"] = it }
626
+ map["gpuLayers"] = gpuLayers
627
+ map["embeddingMode"] = embeddingMode
628
+ map["visionEnabled"] = visionEnabled
629
+ map["temperature"] = temperature.toDouble()
630
+ map["topP"] = topP.toDouble()
631
+ map["topK"] = topK
632
+ map["maxNewTokens"] = maxNewTokens
633
+ return map
634
+ }
635
+
636
+ /** Convert a per-backend `PluginState.start` return map into a [BoundServer]. */
637
+ private fun Map<String, Any?>.toBoundServer(backend: BackendKind): BoundServer = BoundServer(
638
+ baseUrl = this["baseUrl"] as String,
639
+ port = (this["port"] as Number).toInt(),
640
+ backend = backend,
641
+ modelId = this["modelId"] as? String ?: "",
642
+ )