@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,400 @@
1
+ package co.deepvoiceai.bridge.license
2
+
3
+ import android.content.Context
4
+ import android.util.Base64
5
+ import com.nimbusds.jose.JOSEException
6
+ import com.nimbusds.jose.JWSAlgorithm
7
+ import com.nimbusds.jose.crypto.ECDSAVerifier
8
+ import com.nimbusds.jose.jwk.Curve
9
+ import com.nimbusds.jose.jwk.ECKey
10
+ import com.nimbusds.jose.util.Base64URL
11
+ import com.nimbusds.jwt.SignedJWT
12
+ import kotlinx.coroutines.Dispatchers
13
+ import kotlinx.coroutines.withContext
14
+ import org.json.JSONObject
15
+ import java.text.ParseException
16
+ import java.util.Date
17
+
18
+ /**
19
+ * DVAI-Bridge license validator — offline JWT verification.
20
+ *
21
+ * Kotlin port of `packages/dvai-bridge-core/src/license/LicenseValidator.ts`.
22
+ * Verifies a JWT (header + payload + ECDSA P-256 signature) using
23
+ * `nimbus-jose-jwt`. The SDK ships only with public keys (see
24
+ * [DvaiPublicKeys]) and cannot itself produce valid licenses — so
25
+ * reverse-engineering the bundled APK gains nothing.
26
+ *
27
+ * Network calls: zero. The whole flow is offline by design — there's
28
+ * no "phone home" step, no license server polling, no DRM beacon.
29
+ *
30
+ * Android divergence from the JS validator: in production (non-DEBUG)
31
+ * Android builds, both [validateAndAssert] and the SDK's `start(...)`
32
+ * THROW [LicenseRequiredError] rather than falling back to a watermarked
33
+ * free-tier. This matches the iOS validator and the BSL 1.1 commercial
34
+ * enforcement story for native mobile distributions.
35
+ *
36
+ * @param context Application context (typically
37
+ * `applicationContext`). Required for audience
38
+ * detection (package name) and discovery
39
+ * (assets, raw resources, internal storage).
40
+ * @param token Pre-loaded JWT string. If non-null, skips all
41
+ * auto-discovery.
42
+ * @param path Explicit license file path. Overrides
43
+ * auto-discovery if non-null.
44
+ * @param hostBuildConfigDebug The host app's `BuildConfig.DEBUG` value. Pass
45
+ * this from `Application.onCreate()` so the
46
+ * validator can bypass enforcement on debug builds.
47
+ * Falls back to `ApplicationInfo.FLAG_DEBUGGABLE`
48
+ * if null.
49
+ * @param publicKeys Override for the public-key registry. Defaults
50
+ * to [DvaiPublicKeys.REGISTRY]. Tests inject
51
+ * their own keypair so they can sign + verify
52
+ * against a deterministic key without polluting
53
+ * the production registry.
54
+ * @param allowPlaceholderKey If true, accept tokens signed under
55
+ * [PLACEHOLDER_KID]. Off by default — a real
56
+ * production build must replace the placeholder
57
+ * with a generated key. Tests set this to true.
58
+ */
59
+ class LicenseValidator(
60
+ private val context: Context,
61
+ private val token: String? = null,
62
+ private val path: String? = null,
63
+ private val hostBuildConfigDebug: Boolean? = null,
64
+ private val publicKeys: Map<String, DvaiPublicKeyJwk> = DvaiPublicKeys.REGISTRY,
65
+ private val allowPlaceholderKey: Boolean = false,
66
+ ) {
67
+ /**
68
+ * Validate WITHOUT throwing. Returns a [LicenseStatus] describing what
69
+ * the validator determined; never throws on missing / invalid /
70
+ * expired licenses. Useful for host-app dashboards that want to
71
+ * display the licensee / expiry / fallback reason without halting
72
+ * SDK startup, and for tests.
73
+ *
74
+ * Idempotent; safe to call multiple times.
75
+ */
76
+ suspend fun validate(): LicenseStatus = withContext(Dispatchers.IO) {
77
+ // 1. Dev-mode bypass — license required only in production.
78
+ val dev = detectDevMode(context, hostBuildConfigDebug)
79
+ if (dev.isDev) {
80
+ return@withContext LicenseStatus.FreeDev(dev.reason)
81
+ }
82
+
83
+ // 2. Discover the token. Returns null when no license source is
84
+ // configured AND auto-discovery fails.
85
+ val discovered = discoverLicenseToken(
86
+ context,
87
+ LicenseDiscoveryOptions(token = token, path = path),
88
+ )
89
+ if (discovered == null) {
90
+ return@withContext LicenseStatus.FreeProd(
91
+ "no license token found; checked config.licenseToken, " +
92
+ "config.licenseKeyPath, DVAI_LICENSE_PATH env, " +
93
+ "DVAI_LICENSE_TOKEN env, assets/$DEFAULT_LICENSE_FILENAME, " +
94
+ "res/raw/$DEFAULT_LICENSE_RAW_RESOURCE_NAME, " +
95
+ "and filesDir/$DEFAULT_LICENSE_FILENAME",
96
+ )
97
+ }
98
+
99
+ // 3. Verify signature + claims with nimbus-jose-jwt.
100
+ verifyToken(discovered.token, detectPlatform(), detectAudience(context))
101
+ }
102
+
103
+ /**
104
+ * Strict validation entry point used by the SDK at startup. Returns
105
+ * [LicenseStatus] on success ([LicenseStatus.Commercial],
106
+ * [LicenseStatus.Trial], [LicenseStatus.FreeDev]) and THROWS
107
+ * [LicenseRequiredError] on [LicenseStatus.FreeProd] /
108
+ * [LicenseStatus.FreeExpired].
109
+ *
110
+ * This is the BSL 1.1 enforcement point: in production / release
111
+ * builds (any non-dev-mode environment), the SDK refuses to operate
112
+ * without a valid commercial or trial license. Developers running on
113
+ * debug builds / `BuildConfig.DEBUG=true` / explicit DVAI_FORCE_DEV
114
+ * are unaffected — those return a [LicenseStatus.FreeDev] status and
115
+ * the SDK proceeds normally.
116
+ *
117
+ * Use [validate] instead when you want to inspect the status without
118
+ * halting startup (host-app dashboards, test fixtures).
119
+ */
120
+ suspend fun validateAndAssert(): LicenseStatus {
121
+ val status = validate()
122
+ when (status) {
123
+ is LicenseStatus.FreeProd ->
124
+ throw LicenseRequiredError(buildRequiredErrorMessage(status), status)
125
+ is LicenseStatus.FreeExpired ->
126
+ throw LicenseRequiredError(buildRequiredErrorMessage(status), status)
127
+ else -> return status
128
+ }
129
+ }
130
+
131
+ private fun verifyToken(
132
+ token: String,
133
+ platform: DvaiPlatform,
134
+ runtimeAudience: String?,
135
+ ): LicenseStatus {
136
+ // Parse the JWT structure first so we can read the header to pick
137
+ // the right public key. We could let nimbus iterate but specifying
138
+ // the key up-front gives clearer error messages on misses.
139
+ val parts = token.split(".")
140
+ if (parts.size != 3 || parts[0].isEmpty()) {
141
+ return LicenseStatus.FreeProd(
142
+ "license token is not a well-formed JWT (need 3 segments)",
143
+ )
144
+ }
145
+
146
+ val headerJson: JSONObject = try {
147
+ JSONObject(base64UrlDecodeUtf8(parts[0]))
148
+ } catch (e: Throwable) {
149
+ return LicenseStatus.FreeProd(
150
+ "license token header is not parseable JSON: ${e.message ?: e.javaClass.simpleName}",
151
+ )
152
+ }
153
+
154
+ val alg = headerJson.optString("alg", "")
155
+ if (alg != "ES256") {
156
+ // Refuse `alg: none` and any non-ES256 algorithm. Critical
157
+ // defense against the classic JWT algorithm-confusion vulnerability.
158
+ return LicenseStatus.FreeProd(
159
+ "license token uses unsupported alg \"${alg.ifEmpty { "(missing)" }}\", expected ES256",
160
+ )
161
+ }
162
+
163
+ val kid = headerJson.optString("kid", "")
164
+ if (kid.isEmpty()) {
165
+ return LicenseStatus.FreeProd(
166
+ "license token header missing kid; cannot select verification key",
167
+ )
168
+ }
169
+
170
+ val jwk = publicKeys[kid]
171
+ ?: return LicenseStatus.FreeProd(
172
+ "license token kid \"$kid\" is not in the SDK's public-key " +
173
+ "registry; either the key was rotated and you're on an old SDK, " +
174
+ "or the token was signed with a key we don't recognise",
175
+ )
176
+
177
+ if (kid == PLACEHOLDER_KID && !allowPlaceholderKey) {
178
+ return LicenseStatus.FreeProd(
179
+ "license token signed with the placeholder key (kid \"$PLACEHOLDER_KID\"); " +
180
+ "replace the placeholder in PublicKeys.kt with a real key generated " +
181
+ "via scripts/license/generate-keypair.mjs before issuing real licenses",
182
+ )
183
+ }
184
+
185
+ // Parse the JWT structure (header / payload / signature) with nimbus.
186
+ val signed: SignedJWT = try {
187
+ SignedJWT.parse(token)
188
+ } catch (e: ParseException) {
189
+ return LicenseStatus.FreeProd(
190
+ "license token verification failed: ${e.message ?: "parse error"}",
191
+ )
192
+ }
193
+
194
+ // Verify signature. Build the public ECKey from the JWK coordinate
195
+ // strings (x / y). nimbus expects `Base64URL`-wrapped strings — they're
196
+ // already base64url-encoded in the JWK, so we just rehydrate.
197
+ val ecKey: ECKey = try {
198
+ ECKey.Builder(Curve.P_256, Base64URL(jwk.x), Base64URL(jwk.y))
199
+ .keyID(jwk.kid ?: kid)
200
+ .build()
201
+ } catch (e: Throwable) {
202
+ return LicenseStatus.FreeProd(
203
+ "license public-key entry is malformed: ${e.message ?: e.javaClass.simpleName}",
204
+ )
205
+ }
206
+
207
+ try {
208
+ if (signed.header.algorithm != JWSAlgorithm.ES256) {
209
+ // Defense-in-depth — we already checked the header above,
210
+ // but nimbus might surface its own opinion about the alg.
211
+ return LicenseStatus.FreeProd(
212
+ "license token uses unsupported alg \"${signed.header.algorithm}\", expected ES256",
213
+ )
214
+ }
215
+ val verifier = ECDSAVerifier(ecKey)
216
+ if (!signed.verify(verifier)) {
217
+ return LicenseStatus.FreeProd(
218
+ "license token signature did not verify against kid \"$kid\"; " +
219
+ "the token may have been tampered with or was signed by a different key",
220
+ )
221
+ }
222
+ } catch (e: JOSEException) {
223
+ return LicenseStatus.FreeProd(
224
+ "license token verification failed: ${e.message ?: e.javaClass.simpleName}",
225
+ )
226
+ } catch (e: Throwable) {
227
+ return LicenseStatus.FreeProd(
228
+ "license token verification failed: ${e.message ?: e.javaClass.simpleName}",
229
+ )
230
+ }
231
+
232
+ // Coerce + validate the payload shape ourselves. nimbus' claim
233
+ // set is loose-typed JSON; we want strict checks with specific
234
+ // free-prod reasons.
235
+ val claims = signed.jwtClaimsSet
236
+ val payload = parsePayload(claims.toJSONObject())
237
+ ?: return LicenseStatus.FreeProd(
238
+ "license token payload missing required DVAI fields (tier/platforms/aud/licensee)",
239
+ )
240
+
241
+ // Issuer check.
242
+ if (payload.iss != "DVAI-Bridge") {
243
+ return LicenseStatus.FreeProd(
244
+ "license token claim \"iss\" failed: expected \"DVAI-Bridge\", got \"${payload.iss}\"",
245
+ )
246
+ }
247
+
248
+ // Expiry check. Surface "free-expired" specifically so the developer
249
+ // knows whose renewal to chase.
250
+ val nowSec = System.currentTimeMillis() / 1000
251
+ if (payload.exp <= nowSec) {
252
+ return LicenseStatus.FreeExpired(
253
+ licensee = payload.licensee,
254
+ expiredAt = payload.exp,
255
+ )
256
+ }
257
+
258
+ // Platform check.
259
+ if (!payload.platforms.contains(platform.wire)) {
260
+ return LicenseStatus.FreeProd(
261
+ "license token does not authorise platform \"${platform.wire}\"; " +
262
+ "the token covers [${payload.platforms.joinToString(", ")}]",
263
+ )
264
+ }
265
+
266
+ // Audience check.
267
+ val matched = matchAudience(runtimeAudience, payload.aud)
268
+ ?: return LicenseStatus.FreeProd(
269
+ "license token's audience entries [${payload.aud.joinToString(", ")}] " +
270
+ "do not match the current runtime audience \"${runtimeAudience ?: "(none)"}\"" +
271
+ if (runtimeAudience == null) {
272
+ " — set DVAI_AUDIENCE in your environment, or use a \"*\" aud entry for any-domain licenses"
273
+ } else {
274
+ ""
275
+ },
276
+ )
277
+
278
+ return when (payload.tier) {
279
+ "commercial" -> LicenseStatus.Commercial(
280
+ licensee = payload.licensee,
281
+ expiresAt = payload.exp,
282
+ platform = platform,
283
+ audienceMatched = matched,
284
+ )
285
+ "trial" -> LicenseStatus.Trial(
286
+ licensee = payload.licensee,
287
+ expiresAt = payload.exp,
288
+ platform = platform,
289
+ audienceMatched = matched,
290
+ )
291
+ else -> LicenseStatus.FreeProd(
292
+ "license token payload missing required DVAI fields (tier/platforms/aud/licensee)",
293
+ )
294
+ }
295
+ }
296
+ }
297
+
298
+ /* -------------------------------------------------------------------------- */
299
+ /* Helpers */
300
+ /* -------------------------------------------------------------------------- */
301
+
302
+ /**
303
+ * Parse the JWT payload into a [DvaiLicensePayload], or return null if any
304
+ * required field is missing/malformed. Mirrors `isLicensePayload` on the JS side.
305
+ */
306
+ private fun parsePayload(claims: Map<String, Any?>): DvaiLicensePayload? {
307
+ val iss = claims["iss"] as? String ?: return null
308
+ val sub = claims["sub"] as? String ?: return null
309
+ val tier = claims["tier"] as? String ?: return null
310
+ if (tier != "commercial" && tier != "trial") return null
311
+ val licensee = claims["licensee"] as? String ?: return null
312
+
313
+ // `aud` can be either a JSON array (the canonical form) or a single
314
+ // string (nimbus sometimes single-unwraps single-element audiences).
315
+ // We coerce both into a List<String>.
316
+ val audRaw = claims["aud"]
317
+ val aud: List<String> = when (audRaw) {
318
+ is List<*> -> audRaw.filterIsInstance<String>().also {
319
+ if (it.size != audRaw.size) return null
320
+ }
321
+ is String -> listOf(audRaw)
322
+ else -> return null
323
+ }
324
+
325
+ val platformsRaw = claims["platforms"] as? List<*> ?: return null
326
+ val platforms = platformsRaw.filterIsInstance<String>()
327
+ if (platforms.size != platformsRaw.size) return null
328
+
329
+ // `iat` / `exp` are seconds-since-epoch integers in our JWTs, but
330
+ // nimbus' parser may surface them as Date or Number. Coerce both.
331
+ val iat = coerceEpochSeconds(claims["iat"]) ?: return null
332
+ val exp = coerceEpochSeconds(claims["exp"]) ?: return null
333
+
334
+ return DvaiLicensePayload(
335
+ iss = iss,
336
+ sub = sub,
337
+ aud = aud,
338
+ tier = tier,
339
+ platforms = platforms,
340
+ licensee = licensee,
341
+ iat = iat,
342
+ exp = exp,
343
+ )
344
+ }
345
+
346
+ private fun coerceEpochSeconds(v: Any?): Long? = when (v) {
347
+ is Number -> v.toLong()
348
+ is Date -> v.time / 1000
349
+ else -> null
350
+ }
351
+
352
+ /** Decode base64url-encoded JSON header to a UTF-8 string. */
353
+ private fun base64UrlDecodeUtf8(s: String): String {
354
+ val bytes = Base64.decode(s, Base64.URL_SAFE or Base64.NO_WRAP)
355
+ return String(bytes, Charsets.UTF_8)
356
+ }
357
+
358
+ /**
359
+ * Build the developer-facing error message for [LicenseRequiredError].
360
+ * Intentionally verbose — mirrors the JS side's multi-line format with
361
+ * Android-specific resolution paths.
362
+ */
363
+ internal fun buildRequiredErrorMessage(status: LicenseStatus): String {
364
+ val header =
365
+ "\n" +
366
+ "DVAI-Bridge Commercial License Required\n" +
367
+ "=======================================\n"
368
+
369
+ val reason = when (status) {
370
+ is LicenseStatus.FreeExpired ->
371
+ "License for \"${status.licensee}\" expired at ${Date(status.expiredAt * 1000)}."
372
+ is LicenseStatus.FreeProd -> status.reason
373
+ else -> "(unknown status)"
374
+ }
375
+
376
+ val remediation =
377
+ "\n" +
378
+ "This SDK is licensed under BSL 1.1 and requires a valid commercial\n" +
379
+ "or trial license to run in production / release builds.\n" +
380
+ "\n" +
381
+ "To resolve:\n" +
382
+ " 1. Obtain a license at https://deepvoiceai.com/dvai-bridge/license\n" +
383
+ " 2. Place the file at one of these locations (any will work):\n" +
384
+ " - assets/dvai-license.jwt (bundled in the APK; auto-discovered)\n" +
385
+ " - res/raw/dvai_license (bundled raw resource; auto-discovered)\n" +
386
+ " - filesDir/dvai-license.jwt (internal storage; auto-discovered)\n" +
387
+ " - the path you pass as StartOptions.licenseKeyPath\n" +
388
+ " - the path in \$DVAI_LICENSE_PATH\n" +
389
+ " - inline JWT in StartOptions.licenseToken or \$DVAI_LICENSE_TOKEN\n" +
390
+ " 3. Re-run.\n" +
391
+ "\n" +
392
+ "Developing locally? The SDK auto-detects dev mode on:\n" +
393
+ " - BuildConfig.DEBUG=true (pass hostBuildConfigDebug through StartOptions)\n" +
394
+ " - apps installed with ApplicationInfo.FLAG_DEBUGGABLE\n" +
395
+ " - DVAI_FORCE_DEV=1 environment variable (explicit override)\n" +
396
+ "Any of these silences this error and lets the SDK run without a\n" +
397
+ "license.\n"
398
+
399
+ return header + "\n" + reason + "\n" + remediation
400
+ }
@@ -0,0 +1,91 @@
1
+ package co.deepvoiceai.bridge.license
2
+
3
+ /**
4
+ * Public-key registry for DVAI-Bridge license JWT verification.
5
+ *
6
+ * Kotlin port of `packages/dvai-bridge-core/src/license/publicKeys.ts` — semantics
7
+ * and registry contents are 1:1 with the JS side. The same JWT format and the
8
+ * same kids work across the JS, iOS, and Android validators.
9
+ *
10
+ * Each entry is keyed by `kid` (key id, written by the license generator
11
+ * into the JWT header). The SDK looks up the matching entry by kid when
12
+ * verifying a license token. Multiple entries can coexist so that key
13
+ * rotation is non-disruptive: ship the new key in a release alongside
14
+ * the old, leave the old in place for ~12 months while previously-
15
+ * issued licenses naturally expire or get re-issued, then prune.
16
+ *
17
+ * THE PRIVATE KEY DOES NOT LIVE HERE. It belongs in your secrets
18
+ * manager (1Password / AWS Secrets Manager / Vault), accessible only
19
+ * to the license-generator service that produces signed JWTs. The
20
+ * mathematics of ECDSA P-256 guarantee that a holder of the public
21
+ * key alone cannot forge a signature.
22
+ */
23
+
24
+ /** ES256 (P-256 ECDSA) public key in JWK form. */
25
+ data class DvaiPublicKeyJwk(
26
+ val kty: String = "EC",
27
+ val crv: String = "P-256",
28
+ val x: String,
29
+ val y: String,
30
+ val alg: String? = "ES256",
31
+ val use: String? = "sig",
32
+ val kid: String? = null,
33
+ )
34
+
35
+ /**
36
+ * `kid` reserved for the placeholder key. The validator refuses to
37
+ * accept tokens signed with this kid unless the caller explicitly opts
38
+ * in (`allowPlaceholderKey = true` passed to the validator constructor,
39
+ * used by tests and by the sample license printed by the keypair-
40
+ * generator script).
41
+ */
42
+ const val PLACEHOLDER_KID: String = "placeholder-do-not-ship"
43
+
44
+ /**
45
+ * Registry mapping `kid` → public key JWK.
46
+ *
47
+ * The entry below is a **placeholder** — it is a published, well-known
48
+ * test keypair and DOES NOT verify any real production license. Before
49
+ * shipping licenses to customers, replace it with the output of
50
+ * `scripts/license/generate-keypair.mjs`. The SDK refuses to validate
51
+ * licenses against the placeholder kid unless `allowPlaceholderKey` is
52
+ * set (test-only escape hatch).
53
+ *
54
+ * To add a new key for rotation, add a second entry keyed by the new
55
+ * `kid`; old licenses keep verifying against the old key, new licenses
56
+ * (issued by the generator that knows the new private key) verify
57
+ * against the new entry.
58
+ */
59
+ object DvaiPublicKeys {
60
+ /** Production registry. Mirrors `DVAI_PUBLIC_KEYS` on the JS side. */
61
+ val REGISTRY: Map<String, DvaiPublicKeyJwk> = mapOf(
62
+ // Production key, kid `2026-05`. Generated 2026-05-15 by
63
+ // scripts/license/generate-keypair.mjs. The matching private
64
+ // key lives in the operator's secrets manager.
65
+ "2026-05" to DvaiPublicKeyJwk(
66
+ kty = "EC",
67
+ crv = "P-256",
68
+ x = "2Y8TuhnlE4tiVDtliozYTgc1TAqi4_TBTI6FHe1p_Vw",
69
+ y = "pyxMJHj10HPe2hnpJvMpnZ4AzpYZRfqGEMhpBr1-Oto",
70
+ alg = "ES256",
71
+ use = "sig",
72
+ kid = "2026-05",
73
+ ),
74
+ // PLACEHOLDER — used by the SDK's own unit tests and by the
75
+ // sample license printed by `generate-keypair.mjs`. The
76
+ // validator REFUSES to accept tokens signed under this kid
77
+ // unless `allowPlaceholderKey = true` is passed to the
78
+ // validator constructor (test-only escape hatch). Safe to keep
79
+ // in production builds; remove only if you want test fixtures
80
+ // to stop working.
81
+ PLACEHOLDER_KID to DvaiPublicKeyJwk(
82
+ kty = "EC",
83
+ crv = "P-256",
84
+ x = "MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4",
85
+ y = "4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM",
86
+ alg = "ES256",
87
+ use = "sig",
88
+ kid = PLACEHOLDER_KID,
89
+ ),
90
+ )
91
+ }
@@ -0,0 +1,76 @@
1
+ package co.deepvoiceai.bridge
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Test
5
+ import java.io.File
6
+ import java.nio.file.Files
7
+
8
+ class BackendSelectorTest {
9
+ @Test
10
+ fun `explicit backend bypasses resolution`() {
11
+ for (kind in listOf(BackendKind.Llama, BackendKind.MediaPipe, BackendKind.LiteRT)) {
12
+ val opts = StartOptions(backend = kind, modelPath = "/tmp/x.gguf")
13
+ assertEquals(kind, BackendSelector.resolve(opts))
14
+ }
15
+ }
16
+
17
+ @Test
18
+ fun `auto with task suffix and existing file resolves to MediaPipe`() {
19
+ val tmp = Files.createTempFile("dvai-test", ".task").toFile()
20
+ try {
21
+ val opts = StartOptions(backend = BackendKind.Auto, modelPath = tmp.absolutePath)
22
+ assertEquals(BackendKind.MediaPipe, BackendSelector.resolve(opts))
23
+ } finally {
24
+ tmp.delete()
25
+ }
26
+ }
27
+
28
+ @Test
29
+ fun `auto with task suffix but missing file falls through to llama`() {
30
+ val opts = StartOptions(
31
+ backend = BackendKind.Auto,
32
+ modelPath = "/tmp/does-not-exist-${System.nanoTime()}.task",
33
+ )
34
+ assertEquals(BackendKind.Llama, BackendSelector.resolve(opts))
35
+ }
36
+
37
+ @Test
38
+ fun `auto with tflite suffix resolves to LiteRT`() {
39
+ val opts = StartOptions(backend = BackendKind.Auto, modelPath = "/tmp/x.tflite")
40
+ assertEquals(BackendKind.LiteRT, BackendSelector.resolve(opts))
41
+ }
42
+
43
+ @Test
44
+ fun `auto with litertlm suffix resolves to LiteRT`() {
45
+ val opts = StartOptions(backend = BackendKind.Auto, modelPath = "/tmp/x.litertlm")
46
+ assertEquals(BackendKind.LiteRT, BackendSelector.resolve(opts))
47
+ }
48
+
49
+ @Test
50
+ fun `auto with gguf suffix resolves to Llama`() {
51
+ val opts = StartOptions(backend = BackendKind.Auto, modelPath = "/tmp/x.gguf")
52
+ assertEquals(BackendKind.Llama, BackendSelector.resolve(opts))
53
+ }
54
+
55
+ @Test
56
+ fun `auto with no modelPath defaults to Llama`() {
57
+ val opts = StartOptions(backend = BackendKind.Auto, modelPath = null)
58
+ assertEquals(BackendKind.Llama, BackendSelector.resolve(opts))
59
+ }
60
+
61
+ @Test
62
+ fun `auto with unknown extension defaults to Llama`() {
63
+ val opts = StartOptions(backend = BackendKind.Auto, modelPath = "/tmp/something.bin")
64
+ assertEquals(BackendKind.Llama, BackendSelector.resolve(opts))
65
+ }
66
+
67
+ @Test
68
+ fun `BackendKind enum order matches iOS counterpart`() {
69
+ // Cross-platform spec compliance — the iOS DVAIBridge.BackendKind
70
+ // declares cases in the same order: Auto, Llama, MediaPipe, LiteRT.
71
+ // (Foundation / CoreML / MLX are iOS-only and don't appear here.)
72
+ val expected = listOf("Auto", "Llama", "MediaPipe", "LiteRT")
73
+ val actual = BackendKind.values().map { it.name }
74
+ assertEquals(expected, actual)
75
+ }
76
+ }
@@ -0,0 +1,117 @@
1
+ package co.deepvoiceai.bridge
2
+
3
+ import co.deepvoiceai.bridge.shared.core.capability.CapabilityPrecheck
4
+ import co.deepvoiceai.bridge.shared.core.capability.CpuClass
5
+ import co.deepvoiceai.bridge.shared.core.capability.DeviceCapabilityHints
6
+ import co.deepvoiceai.bridge.shared.core.capability.GpuClass
7
+ import co.deepvoiceai.bridge.shared.core.capability.PrecheckMode
8
+ import org.junit.Assert.assertEquals
9
+ import org.junit.Assert.assertTrue
10
+ import org.junit.Test
11
+ import org.junit.runner.RunWith
12
+ import org.robolectric.RobolectricTestRunner
13
+ import org.robolectric.RuntimeEnvironment
14
+ import org.robolectric.annotation.Config
15
+
16
+ /**
17
+ * v3.2 — pre-init capability gate (Kotlin).
18
+ *
19
+ * Mirrors `packages/dvai-bridge-core/src/__tests__/precheck.test.ts`
20
+ * one-to-one. Same hints, same expected modes — guarantees that the
21
+ * Android SDK and the TS core agree on what's a "too-weak" or
22
+ * "offload-only" device for a given hardware shape.
23
+ *
24
+ * Heuristic-only: no real device call. Pass [DeviceCapabilityHints]
25
+ * directly to [CapabilityPrecheck.assess] via the `hints` override.
26
+ * Robolectric provides the ApplicationContext (the assess() function
27
+ * needs one even when hints are pre-supplied — for consistency with
28
+ * the auto-detect path).
29
+ */
30
+ @RunWith(RobolectricTestRunner::class)
31
+ @Config(sdk = [34])
32
+ class CapabilityPrecheckTest {
33
+
34
+ private val ctx: android.content.Context get() = RuntimeEnvironment.getApplication()
35
+
36
+ private val highEndDesktop = DeviceCapabilityHints(
37
+ hasNpu = false, ramGb = 32, gpuClass = GpuClass.DISCRETE, cpuClass = CpuClass.HIGH,
38
+ )
39
+ private val appleSiliconLaptop = DeviceCapabilityHints(
40
+ hasNpu = true, ramGb = 16, gpuClass = GpuClass.APPLE_SILICON, cpuClass = CpuClass.HIGH,
41
+ )
42
+ private val midRangeLaptop = DeviceCapabilityHints(
43
+ hasNpu = false, ramGb = 8, gpuClass = GpuClass.INTEGRATED, cpuClass = CpuClass.MID,
44
+ )
45
+ private val lowEndLaptop = DeviceCapabilityHints(
46
+ hasNpu = false, ramGb = 4, gpuClass = GpuClass.INTEGRATED, cpuClass = CpuClass.LOW,
47
+ )
48
+ private val veryWeakDevice = DeviceCapabilityHints(
49
+ hasNpu = false, ramGb = 2, gpuClass = GpuClass.NONE, cpuClass = CpuClass.LOW,
50
+ )
51
+
52
+ @Test
53
+ fun `high-end desktop classifies as OK`() {
54
+ val result = CapabilityPrecheck.assess(ctx, hints = highEndDesktop)
55
+ assertEquals(PrecheckMode.OK, result.mode)
56
+ assertTrue("expected > 10 tok/s, got ${result.tokPerSec}", result.tokPerSec > 10.0)
57
+ }
58
+
59
+ @Test
60
+ fun `Apple Silicon classifies as OK`() {
61
+ val result = CapabilityPrecheck.assess(ctx, hints = appleSiliconLaptop)
62
+ assertEquals(PrecheckMode.OK, result.mode)
63
+ }
64
+
65
+ @Test
66
+ fun `mid-range laptop classifies as OFFLOAD_ONLY at default thresholds`() {
67
+ // 8 (integrated) * 1.0 (mid CPU) * 1.0 (8 GB RAM) * 1.0 (no NPU) = 8 tok/s
68
+ // Above hardwareMinimum (3), below minLocalCapability (10) ⇒ offload-only.
69
+ val result = CapabilityPrecheck.assess(ctx, hints = midRangeLaptop)
70
+ assertEquals(PrecheckMode.OFFLOAD_ONLY, result.mode)
71
+ assertEquals(8.0, result.tokPerSec, 0.01)
72
+ }
73
+
74
+ @Test
75
+ fun `low-end laptop classifies as OFFLOAD_ONLY`() {
76
+ // 8 * 0.6 * 0.7 = 3.4 tok/s ⇒ above floor (3), below comfort (10).
77
+ val result = CapabilityPrecheck.assess(ctx, hints = lowEndLaptop)
78
+ assertEquals(PrecheckMode.OFFLOAD_ONLY, result.mode)
79
+ }
80
+
81
+ @Test
82
+ fun `very-weak device classifies as TOO_WEAK`() {
83
+ // 3 (no GPU) * 0.6 (low CPU) * 0.3 (RAM < 4) = 0.5 tok/s ⇒ too-weak.
84
+ val result = CapabilityPrecheck.assess(ctx, hints = veryWeakDevice)
85
+ assertEquals(PrecheckMode.TOO_WEAK, result.mode)
86
+ assertTrue(result.tokPerSec < 3.0)
87
+ }
88
+
89
+ @Test
90
+ fun `custom hardwareMinimum is honored`() {
91
+ val result = CapabilityPrecheck.assess(
92
+ ctx,
93
+ thresholds = CapabilityPrecheck.Thresholds(hardwareMinimum = 12.0),
94
+ hints = midRangeLaptop,
95
+ )
96
+ assertEquals(PrecheckMode.TOO_WEAK, result.mode)
97
+ }
98
+
99
+ @Test
100
+ fun `custom minLocalCapability is honored`() {
101
+ val result = CapabilityPrecheck.assess(
102
+ ctx,
103
+ thresholds = CapabilityPrecheck.Thresholds(minLocalCapability = 5.0),
104
+ hints = midRangeLaptop,
105
+ )
106
+ assertEquals(PrecheckMode.OK, result.mode)
107
+ }
108
+
109
+ @Test
110
+ fun `result reason mentions tok-per-second`() {
111
+ val result = CapabilityPrecheck.assess(ctx, hints = veryWeakDevice)
112
+ assertTrue(
113
+ "reason should mention tok/s — got: ${result.reason}",
114
+ result.reason.contains("tok/s"),
115
+ )
116
+ }
117
+ }