@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,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
|
+
}
|