@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,539 @@
|
|
|
1
|
+
package co.deepvoiceai.bridge.license
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import com.nimbusds.jose.JOSEObjectType
|
|
5
|
+
import com.nimbusds.jose.JWSAlgorithm
|
|
6
|
+
import com.nimbusds.jose.JWSHeader
|
|
7
|
+
import com.nimbusds.jose.crypto.ECDSASigner
|
|
8
|
+
import com.nimbusds.jose.jwk.Curve
|
|
9
|
+
import com.nimbusds.jose.jwk.ECKey
|
|
10
|
+
import com.nimbusds.jose.jwk.gen.ECKeyGenerator
|
|
11
|
+
import com.nimbusds.jwt.JWTClaimsSet
|
|
12
|
+
import com.nimbusds.jwt.SignedJWT
|
|
13
|
+
import kotlinx.coroutines.test.runTest
|
|
14
|
+
import org.junit.Assert.assertEquals
|
|
15
|
+
import org.junit.Assert.assertNotNull
|
|
16
|
+
import org.junit.Assert.assertTrue
|
|
17
|
+
import org.junit.Assert.fail
|
|
18
|
+
import org.junit.Before
|
|
19
|
+
import org.junit.BeforeClass
|
|
20
|
+
import org.junit.Test
|
|
21
|
+
import org.junit.runner.RunWith
|
|
22
|
+
import org.robolectric.RobolectricTestRunner
|
|
23
|
+
import org.robolectric.RuntimeEnvironment
|
|
24
|
+
import org.robolectric.annotation.Config
|
|
25
|
+
import java.util.Date
|
|
26
|
+
import java.util.UUID
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Tests for the JWT-based license validator on Android.
|
|
30
|
+
*
|
|
31
|
+
* Direct port of `packages/dvai-bridge-core/src/__tests__/license.test.ts` —
|
|
32
|
+
* same scenarios, same expectations, same coverage of failure modes.
|
|
33
|
+
*
|
|
34
|
+
* Two APIs are tested:
|
|
35
|
+
* - `validate()` — never throws; returns `LicenseStatus`.
|
|
36
|
+
* - `validateAndAssert()` — throws `LicenseRequiredError` for
|
|
37
|
+
* `FreeProd` / `FreeExpired`. This is the
|
|
38
|
+
* BSL 1.1 enforcement entry point used by
|
|
39
|
+
* `DVAIBridge.start()`.
|
|
40
|
+
*
|
|
41
|
+
* Runs under Robolectric so `android.content.Context` (audience detection,
|
|
42
|
+
* discovery) and `android.util.Base64` (JWT header decode) are available
|
|
43
|
+
* without an emulator. The test keypair is generated once per class so we
|
|
44
|
+
* don't re-derive on every case.
|
|
45
|
+
*
|
|
46
|
+
* Every test that exercises a non-dev-mode code path passes
|
|
47
|
+
* `hostBuildConfigDebug = false` explicitly via [prodValidator]. Robolectric
|
|
48
|
+
* applications default to `ApplicationInfo.FLAG_DEBUGGABLE` set, which
|
|
49
|
+
* would otherwise collapse every test into a `FreeDev` bypass.
|
|
50
|
+
*/
|
|
51
|
+
@RunWith(RobolectricTestRunner::class)
|
|
52
|
+
@Config(sdk = [34])
|
|
53
|
+
class LicenseValidatorTest {
|
|
54
|
+
|
|
55
|
+
companion object {
|
|
56
|
+
private const val TEST_KID = "test-kid-2026"
|
|
57
|
+
private lateinit var ecJwk: ECKey
|
|
58
|
+
private lateinit var publicKeys: Map<String, DvaiPublicKeyJwk>
|
|
59
|
+
|
|
60
|
+
@BeforeClass
|
|
61
|
+
@JvmStatic
|
|
62
|
+
fun generateTestKeyPair() {
|
|
63
|
+
// ES256 / P-256 keypair, mirrors the JS test setup.
|
|
64
|
+
ecJwk = ECKeyGenerator(Curve.P_256)
|
|
65
|
+
.keyID(TEST_KID)
|
|
66
|
+
.generate()
|
|
67
|
+
val pub = ecJwk.toPublicJWK()
|
|
68
|
+
publicKeys = mapOf(
|
|
69
|
+
TEST_KID to DvaiPublicKeyJwk(
|
|
70
|
+
kty = "EC",
|
|
71
|
+
crv = "P-256",
|
|
72
|
+
x = pub.x.toString(),
|
|
73
|
+
y = pub.y.toString(),
|
|
74
|
+
alg = "ES256",
|
|
75
|
+
use = "sig",
|
|
76
|
+
kid = TEST_KID,
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private lateinit var context: Context
|
|
83
|
+
|
|
84
|
+
@Before
|
|
85
|
+
fun setup() {
|
|
86
|
+
context = RuntimeEnvironment.getApplication()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Build a [LicenseValidator] with production-mode defaults so tests
|
|
91
|
+
* exercise the license-required branches. Robolectric defaults
|
|
92
|
+
* `ApplicationInfo.FLAG_DEBUGGABLE` to set, which would otherwise
|
|
93
|
+
* collapse every test into a `FreeDev` bypass; pass
|
|
94
|
+
* `hostBuildConfigDebug = false` explicitly to override.
|
|
95
|
+
*
|
|
96
|
+
* Dev-mode tests construct [LicenseValidator] directly with
|
|
97
|
+
* `hostBuildConfigDebug = true`.
|
|
98
|
+
*/
|
|
99
|
+
private fun prodValidator(
|
|
100
|
+
token: String? = null,
|
|
101
|
+
path: String? = null,
|
|
102
|
+
registry: Map<String, DvaiPublicKeyJwk> = publicKeys,
|
|
103
|
+
allowPlaceholderKey: Boolean = false,
|
|
104
|
+
): LicenseValidator = LicenseValidator(
|
|
105
|
+
context = context,
|
|
106
|
+
token = token,
|
|
107
|
+
path = path,
|
|
108
|
+
hostBuildConfigDebug = false,
|
|
109
|
+
publicKeys = registry,
|
|
110
|
+
allowPlaceholderKey = allowPlaceholderKey,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
/** Mint a license JWT for tests. Mirrors `mintLicense` in the JS suite. */
|
|
114
|
+
private fun mintLicense(
|
|
115
|
+
aud: List<String> = listOf("*"),
|
|
116
|
+
platforms: List<String> = listOf("node", "web", "ios", "android"),
|
|
117
|
+
tier: String = "commercial",
|
|
118
|
+
licensee: String = "Test Co",
|
|
119
|
+
expSecondsFromNow: Long = 30L * 24 * 3600,
|
|
120
|
+
absoluteExpSeconds: Long? = null,
|
|
121
|
+
iss: String = "DVAI-Bridge",
|
|
122
|
+
kid: String = TEST_KID,
|
|
123
|
+
signWith: ECKey = ecJwk,
|
|
124
|
+
): String {
|
|
125
|
+
val nowSec = System.currentTimeMillis() / 1000
|
|
126
|
+
val exp = absoluteExpSeconds ?: (nowSec + expSecondsFromNow)
|
|
127
|
+
val claims = JWTClaimsSet.Builder()
|
|
128
|
+
.issuer(iss)
|
|
129
|
+
.subject("test-license")
|
|
130
|
+
.audience(aud)
|
|
131
|
+
.issueTime(Date(nowSec * 1000))
|
|
132
|
+
.expirationTime(Date(exp * 1000))
|
|
133
|
+
.claim("tier", tier)
|
|
134
|
+
.claim("licensee", licensee)
|
|
135
|
+
.claim("platforms", platforms)
|
|
136
|
+
.build()
|
|
137
|
+
val header = JWSHeader.Builder(JWSAlgorithm.ES256)
|
|
138
|
+
.type(JOSEObjectType.JWT)
|
|
139
|
+
.keyID(kid)
|
|
140
|
+
.build()
|
|
141
|
+
val signed = SignedJWT(header, claims)
|
|
142
|
+
signed.sign(ECDSASigner(signWith))
|
|
143
|
+
return signed.serialize()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/* ------------------------------------------------------------------ */
|
|
147
|
+
/* Happy path */
|
|
148
|
+
/* ------------------------------------------------------------------ */
|
|
149
|
+
|
|
150
|
+
@Test
|
|
151
|
+
fun `accepts a well-formed commercial token and reports licensee + expiry`() = runTest {
|
|
152
|
+
val token = mintLicense(
|
|
153
|
+
aud = listOf(context.packageName),
|
|
154
|
+
platforms = listOf("android"),
|
|
155
|
+
licensee = "Acme Inc",
|
|
156
|
+
)
|
|
157
|
+
val status = prodValidator(token = token).validate()
|
|
158
|
+
assertTrue("expected Commercial, got $status", status is LicenseStatus.Commercial)
|
|
159
|
+
val c = status as LicenseStatus.Commercial
|
|
160
|
+
assertEquals("Acme Inc", c.licensee)
|
|
161
|
+
assertEquals(context.packageName, c.audienceMatched)
|
|
162
|
+
assertEquals(DvaiPlatform.ANDROID, c.platform)
|
|
163
|
+
assertTrue(c.expiresAt > System.currentTimeMillis() / 1000)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
@Test
|
|
167
|
+
fun `matches wildcard subdomain audience entries`() = runTest {
|
|
168
|
+
// Android audience is the package name; package names use dot-
|
|
169
|
+
// separated reverse-domain notation so the same `*.acme.com` rule
|
|
170
|
+
// applies to `app.acme.com`-shaped package names.
|
|
171
|
+
val pkg = context.packageName
|
|
172
|
+
val parts = pkg.split(".")
|
|
173
|
+
if (parts.size < 2) return@runTest // can't build a wildcard from a 1-segment package
|
|
174
|
+
val suffix = parts.drop(1).joinToString(".")
|
|
175
|
+
val token = mintLicense(
|
|
176
|
+
aud = listOf("*.$suffix"),
|
|
177
|
+
platforms = listOf("android"),
|
|
178
|
+
)
|
|
179
|
+
val status = prodValidator(token = token).validate()
|
|
180
|
+
assertTrue("expected Commercial, got $status", status is LicenseStatus.Commercial)
|
|
181
|
+
assertEquals("*.$suffix", (status as LicenseStatus.Commercial).audienceMatched)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
@Test
|
|
185
|
+
fun `matches star audience for any-domain trial licenses`() = runTest {
|
|
186
|
+
val token = mintLicense(
|
|
187
|
+
aud = listOf("*"),
|
|
188
|
+
platforms = listOf("android"),
|
|
189
|
+
tier = "trial",
|
|
190
|
+
)
|
|
191
|
+
val status = prodValidator(token = token).validate()
|
|
192
|
+
assertTrue("expected Trial, got $status", status is LicenseStatus.Trial)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/* ------------------------------------------------------------------ */
|
|
196
|
+
/* Failure modes — must collapse to a free-* status, never throw */
|
|
197
|
+
/* ------------------------------------------------------------------ */
|
|
198
|
+
|
|
199
|
+
@Test
|
|
200
|
+
fun `returns FreeProd when the token has been tampered with`() = runTest {
|
|
201
|
+
val token = mintLicense(aud = listOf(context.packageName), platforms = listOf("android"))
|
|
202
|
+
// Flip bytes in the payload segment to break the signature.
|
|
203
|
+
val parts = token.split(".")
|
|
204
|
+
val corrupted = "${parts[0]}.${parts[1].dropLast(2)}XX.${parts[2]}"
|
|
205
|
+
val status = prodValidator(token = corrupted).validate()
|
|
206
|
+
assertTrue("expected FreeProd, got $status", status is LicenseStatus.FreeProd)
|
|
207
|
+
val reason = (status as LicenseStatus.FreeProd).reason.lowercase()
|
|
208
|
+
assertTrue(
|
|
209
|
+
"reason was: $reason",
|
|
210
|
+
reason.contains("signature") || reason.contains("verification") ||
|
|
211
|
+
reason.contains("parseable") || reason.contains("claim"),
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
@Test
|
|
216
|
+
fun `returns FreeExpired when exp is in the past`() = runTest {
|
|
217
|
+
val pastSeconds = System.currentTimeMillis() / 1000 - 3600
|
|
218
|
+
val token = mintLicense(
|
|
219
|
+
aud = listOf(context.packageName),
|
|
220
|
+
platforms = listOf("android"),
|
|
221
|
+
licensee = "Expired Co",
|
|
222
|
+
absoluteExpSeconds = pastSeconds,
|
|
223
|
+
)
|
|
224
|
+
val status = prodValidator(token = token).validate()
|
|
225
|
+
assertTrue("expected FreeExpired, got $status", status is LicenseStatus.FreeExpired)
|
|
226
|
+
val e = status as LicenseStatus.FreeExpired
|
|
227
|
+
assertEquals("Expired Co", e.licensee)
|
|
228
|
+
assertTrue(e.expiredAt < System.currentTimeMillis() / 1000)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
@Test
|
|
232
|
+
fun `returns FreeProd when audience does not match`() = runTest {
|
|
233
|
+
val token = mintLicense(
|
|
234
|
+
aud = listOf("com.different.app"),
|
|
235
|
+
platforms = listOf("android"),
|
|
236
|
+
)
|
|
237
|
+
val status = prodValidator(token = token).validate()
|
|
238
|
+
assertTrue("expected FreeProd, got $status", status is LicenseStatus.FreeProd)
|
|
239
|
+
val reason = (status as LicenseStatus.FreeProd).reason
|
|
240
|
+
assertTrue(reason, reason.contains("audience"))
|
|
241
|
+
assertTrue(reason, reason.contains(context.packageName))
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
@Test
|
|
245
|
+
fun `returns FreeProd when the runtime platform is not in the platforms claim`() = runTest {
|
|
246
|
+
val token = mintLicense(
|
|
247
|
+
aud = listOf(context.packageName),
|
|
248
|
+
platforms = listOf("ios", "web"), // not android
|
|
249
|
+
)
|
|
250
|
+
val status = prodValidator(token = token).validate()
|
|
251
|
+
assertTrue("expected FreeProd, got $status", status is LicenseStatus.FreeProd)
|
|
252
|
+
val reason = (status as LicenseStatus.FreeProd).reason
|
|
253
|
+
assertTrue(reason, reason.contains("platform"))
|
|
254
|
+
assertTrue(reason, reason.contains("android"))
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
@Test
|
|
258
|
+
fun `returns FreeProd when the kid in the header is not in the registry`() = runTest {
|
|
259
|
+
val token = mintLicense(
|
|
260
|
+
aud = listOf(context.packageName),
|
|
261
|
+
platforms = listOf("android"),
|
|
262
|
+
kid = "unknown-kid-2099",
|
|
263
|
+
)
|
|
264
|
+
val status = prodValidator(token = token).validate()
|
|
265
|
+
assertTrue("expected FreeProd, got $status", status is LicenseStatus.FreeProd)
|
|
266
|
+
val reason = (status as LicenseStatus.FreeProd).reason
|
|
267
|
+
assertTrue(reason, reason.contains("unknown-kid-2099"))
|
|
268
|
+
assertTrue(reason, reason.contains("registry"))
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
@Test
|
|
272
|
+
fun `refuses the placeholder kid unless allowPlaceholderKey is set`() = runTest {
|
|
273
|
+
// Mint with the placeholder kid (using OUR key, registered against
|
|
274
|
+
// the placeholder kid in the local registry). Even though signature
|
|
275
|
+
// verification would succeed, the validator must refuse the kid.
|
|
276
|
+
val registryWithPlaceholderKid = mapOf(
|
|
277
|
+
PLACEHOLDER_KID to publicKeys.values.first().copy(kid = PLACEHOLDER_KID),
|
|
278
|
+
)
|
|
279
|
+
val token = mintLicense(
|
|
280
|
+
aud = listOf(context.packageName),
|
|
281
|
+
platforms = listOf("android"),
|
|
282
|
+
kid = PLACEHOLDER_KID,
|
|
283
|
+
)
|
|
284
|
+
val status = prodValidator(
|
|
285
|
+
token = token,
|
|
286
|
+
registry = registryWithPlaceholderKid,
|
|
287
|
+
).validate()
|
|
288
|
+
assertTrue("expected FreeProd, got $status", status is LicenseStatus.FreeProd)
|
|
289
|
+
assertTrue(
|
|
290
|
+
(status as LicenseStatus.FreeProd).reason.contains("placeholder"),
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
@Test
|
|
295
|
+
fun `accepts the placeholder kid when allowPlaceholderKey is set`() = runTest {
|
|
296
|
+
val registryWithPlaceholderKid = mapOf(
|
|
297
|
+
PLACEHOLDER_KID to publicKeys.values.first().copy(kid = PLACEHOLDER_KID),
|
|
298
|
+
)
|
|
299
|
+
val token = mintLicense(
|
|
300
|
+
aud = listOf(context.packageName),
|
|
301
|
+
platforms = listOf("android"),
|
|
302
|
+
kid = PLACEHOLDER_KID,
|
|
303
|
+
)
|
|
304
|
+
val status = prodValidator(
|
|
305
|
+
token = token,
|
|
306
|
+
registry = registryWithPlaceholderKid,
|
|
307
|
+
allowPlaceholderKey = true,
|
|
308
|
+
).validate()
|
|
309
|
+
assertTrue("expected Commercial, got $status", status is LicenseStatus.Commercial)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
@Test
|
|
313
|
+
fun `rejects alg=none tokens (algorithm-confusion defense)`() = runTest {
|
|
314
|
+
// Build a hand-crafted alg=none token (header + payload + empty sig).
|
|
315
|
+
val headerJson = """{"alg":"none","typ":"JWT"}"""
|
|
316
|
+
val payloadJson = """{"iss":"DVAI-Bridge","sub":"x","aud":["${context.packageName}"],""" +
|
|
317
|
+
""""tier":"commercial","platforms":["android"],"licensee":"Evil Co",""" +
|
|
318
|
+
""""iat":${System.currentTimeMillis() / 1000},"exp":${System.currentTimeMillis() / 1000 + 3600}}"""
|
|
319
|
+
val encoder = java.util.Base64.getUrlEncoder().withoutPadding()
|
|
320
|
+
val header = encoder.encodeToString(headerJson.toByteArray())
|
|
321
|
+
val payload = encoder.encodeToString(payloadJson.toByteArray())
|
|
322
|
+
val noneToken = "$header.$payload."
|
|
323
|
+
val status = prodValidator(token = noneToken).validate()
|
|
324
|
+
assertTrue("expected FreeProd, got $status", status is LicenseStatus.FreeProd)
|
|
325
|
+
assertTrue((status as LicenseStatus.FreeProd).reason.contains("ES256"))
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
@Test
|
|
329
|
+
fun `returns FreeProd when token is malformed`() = runTest {
|
|
330
|
+
val status = prodValidator(token = "not.a.valid.jwt").validate() // 4 segments
|
|
331
|
+
assertTrue(status is LicenseStatus.FreeProd)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
@Test
|
|
335
|
+
fun `returns FreeProd when no token is provided AND no auto-discovery succeeds`() = runTest {
|
|
336
|
+
// No token, no path — and the Robolectric app has no bundled asset
|
|
337
|
+
// or raw resource named dvai-license.jwt, and filesDir is empty.
|
|
338
|
+
val status = prodValidator().validate()
|
|
339
|
+
assertTrue("expected FreeProd, got $status", status is LicenseStatus.FreeProd)
|
|
340
|
+
assertTrue(
|
|
341
|
+
(status as LicenseStatus.FreeProd).reason.contains("no license token found"),
|
|
342
|
+
)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/* ------------------------------------------------------------------ */
|
|
346
|
+
/* Dev mode bypass */
|
|
347
|
+
/* ------------------------------------------------------------------ */
|
|
348
|
+
|
|
349
|
+
@Test
|
|
350
|
+
fun `returns FreeDev when hostBuildConfigDebug is true`() = runTest {
|
|
351
|
+
val status = LicenseValidator(
|
|
352
|
+
context = context,
|
|
353
|
+
hostBuildConfigDebug = true,
|
|
354
|
+
publicKeys = publicKeys,
|
|
355
|
+
).validate()
|
|
356
|
+
assertTrue("expected FreeDev, got $status", status is LicenseStatus.FreeDev)
|
|
357
|
+
assertTrue(
|
|
358
|
+
(status as LicenseStatus.FreeDev).reason.contains("BuildConfig.DEBUG"),
|
|
359
|
+
)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
@Test
|
|
363
|
+
fun `returns FreeDev when host omits BuildConfig and FLAG_DEBUGGABLE is set`() = runTest {
|
|
364
|
+
// Robolectric's default ApplicationInfo carries FLAG_DEBUGGABLE — the
|
|
365
|
+
// fallback heuristic should pick that up and return FreeDev when the
|
|
366
|
+
// host didn't pass an explicit hostBuildConfigDebug.
|
|
367
|
+
val status = LicenseValidator(
|
|
368
|
+
context = context,
|
|
369
|
+
// hostBuildConfigDebug intentionally null
|
|
370
|
+
publicKeys = publicKeys,
|
|
371
|
+
).validate()
|
|
372
|
+
assertTrue("expected FreeDev, got $status", status is LicenseStatus.FreeDev)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
@Test
|
|
376
|
+
fun `explicit hostBuildConfigDebug=false overrides FLAG_DEBUGGABLE`() = runTest {
|
|
377
|
+
// Robolectric has FLAG_DEBUGGABLE set, but the host explicitly says
|
|
378
|
+
// "not a debug build" — production mode wins, validator falls through
|
|
379
|
+
// to discovery and (since no token is configured) returns FreeProd.
|
|
380
|
+
val status = LicenseValidator(
|
|
381
|
+
context = context,
|
|
382
|
+
hostBuildConfigDebug = false,
|
|
383
|
+
publicKeys = publicKeys,
|
|
384
|
+
).validate()
|
|
385
|
+
assertTrue("expected FreeProd, got $status", status is LicenseStatus.FreeProd)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/* ------------------------------------------------------------------ */
|
|
389
|
+
/* Token discovery */
|
|
390
|
+
/* ------------------------------------------------------------------ */
|
|
391
|
+
|
|
392
|
+
@Test
|
|
393
|
+
fun `loads from an explicit path`() = runTest {
|
|
394
|
+
val tmp = java.io.File.createTempFile("dvai-license-", ".jwt")
|
|
395
|
+
val token = mintLicense(aud = listOf(context.packageName), platforms = listOf("android"))
|
|
396
|
+
tmp.writeText(token)
|
|
397
|
+
|
|
398
|
+
val status = prodValidator(path = tmp.absolutePath).validate()
|
|
399
|
+
assertTrue("expected Commercial, got $status", status is LicenseStatus.Commercial)
|
|
400
|
+
tmp.delete()
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
@Test
|
|
404
|
+
fun `returns FreeProd when explicit path does not exist`() = runTest {
|
|
405
|
+
val status = prodValidator(
|
|
406
|
+
path = "/nonexistent/path/dvai-license.jwt-${UUID.randomUUID()}",
|
|
407
|
+
).validate()
|
|
408
|
+
assertTrue("expected FreeProd, got $status", status is LicenseStatus.FreeProd)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
@Test
|
|
412
|
+
fun `loads from filesDir when no explicit source is given`() = runTest {
|
|
413
|
+
val token = mintLicense(aud = listOf(context.packageName), platforms = listOf("android"))
|
|
414
|
+
val file = java.io.File(context.filesDir, "dvai-license.jwt")
|
|
415
|
+
try {
|
|
416
|
+
file.writeText(token)
|
|
417
|
+
val status = prodValidator().validate()
|
|
418
|
+
assertTrue("expected Commercial, got $status", status is LicenseStatus.Commercial)
|
|
419
|
+
} finally {
|
|
420
|
+
file.delete()
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
@Test
|
|
425
|
+
fun `inline token wins over path when both are set`() = runTest {
|
|
426
|
+
val inlineToken = mintLicense(
|
|
427
|
+
aud = listOf(context.packageName),
|
|
428
|
+
platforms = listOf("android"),
|
|
429
|
+
licensee = "Inline Co",
|
|
430
|
+
)
|
|
431
|
+
val status = prodValidator(
|
|
432
|
+
token = inlineToken,
|
|
433
|
+
path = "/nonexistent/path/dvai-license.jwt",
|
|
434
|
+
).validate()
|
|
435
|
+
assertTrue("expected Commercial, got $status", status is LicenseStatus.Commercial)
|
|
436
|
+
assertEquals("Inline Co", (status as LicenseStatus.Commercial).licensee)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/* ------------------------------------------------------------------ */
|
|
440
|
+
/* validateAndAssert — BSL 1.1 enforcement */
|
|
441
|
+
/* ------------------------------------------------------------------ */
|
|
442
|
+
|
|
443
|
+
@Test
|
|
444
|
+
fun `validateAndAssert returns status without throwing for commercial licenses`() = runTest {
|
|
445
|
+
val token = mintLicense(aud = listOf(context.packageName), platforms = listOf("android"))
|
|
446
|
+
val status = prodValidator(token = token).validateAndAssert()
|
|
447
|
+
assertTrue(status is LicenseStatus.Commercial)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
@Test
|
|
451
|
+
fun `validateAndAssert returns status without throwing for trial licenses`() = runTest {
|
|
452
|
+
val token = mintLicense(
|
|
453
|
+
aud = listOf(context.packageName),
|
|
454
|
+
platforms = listOf("android"),
|
|
455
|
+
tier = "trial",
|
|
456
|
+
)
|
|
457
|
+
val status = prodValidator(token = token).validateAndAssert()
|
|
458
|
+
assertTrue(status is LicenseStatus.Trial)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
@Test
|
|
462
|
+
fun `validateAndAssert returns status without throwing for FreeDev`() = runTest {
|
|
463
|
+
val status = LicenseValidator(
|
|
464
|
+
context = context,
|
|
465
|
+
hostBuildConfigDebug = true,
|
|
466
|
+
publicKeys = publicKeys,
|
|
467
|
+
).validateAndAssert()
|
|
468
|
+
assertTrue(status is LicenseStatus.FreeDev)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
@Test
|
|
472
|
+
fun `validateAndAssert throws LicenseRequiredError when no license found in production`() = runTest {
|
|
473
|
+
try {
|
|
474
|
+
prodValidator().validateAndAssert()
|
|
475
|
+
fail("should have thrown")
|
|
476
|
+
} catch (e: LicenseRequiredError) {
|
|
477
|
+
assertTrue(e.status is LicenseStatus.FreeProd)
|
|
478
|
+
assertTrue("message: ${e.message}", e.message!!.contains("Commercial License Required"))
|
|
479
|
+
assertTrue("message: ${e.message}", e.message!!.contains("dvai-license.jwt"))
|
|
480
|
+
assertTrue("message: ${e.message}", e.message!!.contains("DVAI_LICENSE_PATH"))
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
@Test
|
|
485
|
+
fun `validateAndAssert throws with status FreeExpired for expired tokens`() = runTest {
|
|
486
|
+
val past = System.currentTimeMillis() / 1000 - 3600
|
|
487
|
+
val token = mintLicense(
|
|
488
|
+
aud = listOf(context.packageName),
|
|
489
|
+
platforms = listOf("android"),
|
|
490
|
+
licensee = "Expired Co",
|
|
491
|
+
absoluteExpSeconds = past,
|
|
492
|
+
)
|
|
493
|
+
try {
|
|
494
|
+
prodValidator(token = token).validateAndAssert()
|
|
495
|
+
fail("should have thrown")
|
|
496
|
+
} catch (e: LicenseRequiredError) {
|
|
497
|
+
assertTrue(e.status is LicenseStatus.FreeExpired)
|
|
498
|
+
assertTrue("message: ${e.message}", e.message!!.contains("Expired Co"))
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
@Test
|
|
503
|
+
fun `validateAndAssert throws for tampered tokens in production`() = runTest {
|
|
504
|
+
val token = mintLicense(aud = listOf(context.packageName), platforms = listOf("android"))
|
|
505
|
+
val parts = token.split(".")
|
|
506
|
+
val corrupted = "${parts[0]}.${parts[1].dropLast(2)}XX.${parts[2]}"
|
|
507
|
+
try {
|
|
508
|
+
prodValidator(token = corrupted).validateAndAssert()
|
|
509
|
+
fail("should have thrown")
|
|
510
|
+
} catch (e: LicenseRequiredError) {
|
|
511
|
+
assertNotNull(e.status)
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
@Test
|
|
516
|
+
fun `validateAndAssert throws for audience-mismatched tokens in production`() = runTest {
|
|
517
|
+
val token = mintLicense(
|
|
518
|
+
aud = listOf("com.different.app"),
|
|
519
|
+
platforms = listOf("android"),
|
|
520
|
+
)
|
|
521
|
+
try {
|
|
522
|
+
prodValidator(token = token).validateAndAssert()
|
|
523
|
+
fail("should have thrown")
|
|
524
|
+
} catch (_: LicenseRequiredError) {
|
|
525
|
+
// expected
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
@Test
|
|
530
|
+
fun `validateAndAssert does NOT throw in dev mode even when license is invalid`() = runTest {
|
|
531
|
+
val status = LicenseValidator(
|
|
532
|
+
context = context,
|
|
533
|
+
token = "not-even-a-jwt",
|
|
534
|
+
hostBuildConfigDebug = true,
|
|
535
|
+
publicKeys = publicKeys,
|
|
536
|
+
).validateAndAssert()
|
|
537
|
+
assertTrue(status is LicenseStatus.FreeDev)
|
|
538
|
+
}
|
|
539
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dvai-bridge/android",
|
|
3
|
+
"version": "4.0.0",
|
|
4
|
+
"description": "DVAI-Bridge Android native SDK — embedded local OpenAI-compatible HTTP server with llama.cpp + MediaPipe (LiteRT-LM) + LiteRT backends. Capacitor-free, pure Kotlin singleton API.",
|
|
5
|
+
"author": "Deep Chakraborty <https://github.com/dk013>",
|
|
6
|
+
"license": "Custom (See LICENSE)",
|
|
7
|
+
"files": [
|
|
8
|
+
"android/src",
|
|
9
|
+
"android/build.gradle",
|
|
10
|
+
"android/gradle.properties",
|
|
11
|
+
"android/settings.gradle",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"registry": "https://registry.npmjs.org/",
|
|
17
|
+
"access": "public"
|
|
18
|
+
}
|
|
19
|
+
}
|