@dvai-bridge/android 4.0.0 → 4.0.2
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/android/build.gradle +252 -165
- package/android/gradle.properties +1 -1
- package/android/src/main/java/co/deepvoiceai/bridge/BoundServer.kt +39 -39
- package/android/src/main/java/co/deepvoiceai/bridge/DVAIBridge.kt +642 -642
- package/android/src/main/java/co/deepvoiceai/bridge/DVAIBridgeConfig.kt +119 -119
- package/android/src/main/java/co/deepvoiceai/bridge/license/Audience.kt +134 -134
- package/android/src/main/java/co/deepvoiceai/bridge/license/Discovery.kt +146 -146
- package/android/src/main/java/co/deepvoiceai/bridge/license/LicenseTypes.kt +158 -158
- package/android/src/main/java/co/deepvoiceai/bridge/license/LicenseValidator.kt +400 -400
- package/android/src/main/java/co/deepvoiceai/bridge/license/PublicKeys.kt +91 -91
- package/android/src/test/java/co/deepvoiceai/bridge/license/LicenseValidatorTest.kt +539 -539
- package/package.json +1 -1
- package/LICENSE +0 -51
- package/README.md +0 -199
|
@@ -1,400 +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
|
-
}
|
|
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
|
+
}
|