@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.
- package/LICENSE +51 -0
- package/README.md +199 -0
- package/android/build.gradle +165 -0
- package/android/gradle.properties +5 -0
- package/android/settings.gradle +1 -0
- package/android/src/androidTest/java/co/deepvoiceai/bridge/RealModelIntegrationTest.kt +162 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/co/deepvoiceai/bridge/BackendKind.kt +21 -0
- package/android/src/main/java/co/deepvoiceai/bridge/BackendSelector.kt +28 -0
- package/android/src/main/java/co/deepvoiceai/bridge/BoundServer.kt +39 -0
- package/android/src/main/java/co/deepvoiceai/bridge/DVAIBridge.kt +642 -0
- package/android/src/main/java/co/deepvoiceai/bridge/DVAIBridgeConfig.kt +119 -0
- package/android/src/main/java/co/deepvoiceai/bridge/DVAIBridgeError.kt +51 -0
- package/android/src/main/java/co/deepvoiceai/bridge/OffloadProxy.kt +574 -0
- package/android/src/main/java/co/deepvoiceai/bridge/ProgressBroadcaster.kt +55 -0
- package/android/src/main/java/co/deepvoiceai/bridge/ProgressEvent.kt +25 -0
- package/android/src/main/java/co/deepvoiceai/bridge/ReactiveState.kt +60 -0
- package/android/src/main/java/co/deepvoiceai/bridge/license/Audience.kt +134 -0
- package/android/src/main/java/co/deepvoiceai/bridge/license/Discovery.kt +146 -0
- package/android/src/main/java/co/deepvoiceai/bridge/license/LicenseTypes.kt +158 -0
- package/android/src/main/java/co/deepvoiceai/bridge/license/LicenseValidator.kt +400 -0
- package/android/src/main/java/co/deepvoiceai/bridge/license/PublicKeys.kt +91 -0
- package/android/src/test/java/co/deepvoiceai/bridge/BackendSelectorTest.kt +76 -0
- package/android/src/test/java/co/deepvoiceai/bridge/CapabilityPrecheckTest.kt +117 -0
- package/android/src/test/java/co/deepvoiceai/bridge/DVAIBridgeAPIShapeTest.kt +86 -0
- package/android/src/test/java/co/deepvoiceai/bridge/OffloadProxyDecisionTest.kt +144 -0
- package/android/src/test/java/co/deepvoiceai/bridge/OffloadProxyForwardingTest.kt +327 -0
- package/android/src/test/java/co/deepvoiceai/bridge/ProgressBroadcasterTest.kt +56 -0
- package/android/src/test/java/co/deepvoiceai/bridge/license/LicenseValidatorTest.kt +539 -0
- 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
|
+
)
|