@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,146 +1,146 @@
|
|
|
1
|
-
package co.deepvoiceai.bridge.license
|
|
2
|
-
|
|
3
|
-
import android.content.Context
|
|
4
|
-
import java.io.File
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* License-file discovery for the Android SDK.
|
|
8
|
-
*
|
|
9
|
-
* Kotlin port of `packages/dvai-bridge-core/src/license/discovery.ts`.
|
|
10
|
-
* The discovery priority order mirrors the JS side but the platform-
|
|
11
|
-
* default locations are Android-specific:
|
|
12
|
-
*
|
|
13
|
-
* 1. An explicit string literal passed via [LicenseDiscoveryOptions.token]
|
|
14
|
-
* — useful for CI / test contexts where reading a file isn't practical.
|
|
15
|
-
* 2. A path passed via [LicenseDiscoveryOptions.path] — the developer
|
|
16
|
-
* points the SDK at a file they've placed somewhere non-default.
|
|
17
|
-
* 3. The `DVAI_LICENSE_PATH` env var — same as (2) but driven by
|
|
18
|
-
* process environment, helpful for emulator-based testing.
|
|
19
|
-
* 4. The `DVAI_LICENSE_TOKEN` env var — inline JWT as an alternative
|
|
20
|
-
* to a file path.
|
|
21
|
-
* 5. The bundled asset `assets/dvai-license.jwt` — the conventional
|
|
22
|
-
* ship-with-the-APK location. Auto-discovered.
|
|
23
|
-
* 6. The bundled raw resource `R.raw.dvai_license` — alternative
|
|
24
|
-
* asset location for apps that already use the res/raw/ folder.
|
|
25
|
-
* Looked up reflectively against the host app's R.raw class.
|
|
26
|
-
* 7. Internal storage `context.filesDir/dvai-license.jwt` — for
|
|
27
|
-
* apps that drop the license at runtime (e.g. fetched from
|
|
28
|
-
* a self-hosted endpoint after first launch).
|
|
29
|
-
*
|
|
30
|
-
* Returning `null` means "no license file found"; the validator treats
|
|
31
|
-
* that as the free-prod case, which on Android escalates to a throw via
|
|
32
|
-
* [LicenseValidator.validateAndAssert].
|
|
33
|
-
*/
|
|
34
|
-
|
|
35
|
-
/** Default filename the SDK looks for at every file location. */
|
|
36
|
-
const val DEFAULT_LICENSE_FILENAME: String = "dvai-license.jwt"
|
|
37
|
-
|
|
38
|
-
/** Default raw-resource name (without the `R.raw.` prefix). */
|
|
39
|
-
const val DEFAULT_LICENSE_RAW_RESOURCE_NAME: String = "dvai_license"
|
|
40
|
-
|
|
41
|
-
data class LicenseDiscoveryOptions(
|
|
42
|
-
/** Pre-loaded JWT string (skips all filesystem / asset lookups). */
|
|
43
|
-
val token: String? = null,
|
|
44
|
-
/** Explicit path to load from. Overrides auto-discovery. */
|
|
45
|
-
val path: String? = null,
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
/** Result of a successful discovery; identifies the source for audit logging. */
|
|
49
|
-
data class DiscoveredToken(val token: String, val source: String)
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Best-effort load of a license JWT. Returns the raw token string on
|
|
53
|
-
* success or null on miss. Errors during loading (file not found, asset
|
|
54
|
-
* not present) collapse to null — the validator's responsibility is to
|
|
55
|
-
* handle the no-license case gracefully, not the discovery layer's.
|
|
56
|
-
*
|
|
57
|
-
* Side-effect-free in the sense of "no network calls"; reads from the
|
|
58
|
-
* filesystem and the APK's assets directory only.
|
|
59
|
-
*/
|
|
60
|
-
fun discoverLicenseToken(
|
|
61
|
-
context: Context,
|
|
62
|
-
opts: LicenseDiscoveryOptions = LicenseDiscoveryOptions(),
|
|
63
|
-
): DiscoveredToken? {
|
|
64
|
-
// 1. Explicit token wins.
|
|
65
|
-
if (!opts.token.isNullOrEmpty()) {
|
|
66
|
-
return DiscoveredToken(opts.token.trim(), "config.licenseToken")
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// 2. Explicit path (config option). An explicit path that doesn't load
|
|
70
|
-
// is a real miss, not a silent fallthrough — matches JS behaviour.
|
|
71
|
-
if (!opts.path.isNullOrEmpty()) {
|
|
72
|
-
val loaded = tryLoadFromPath(opts.path)
|
|
73
|
-
return loaded?.let { DiscoveredToken(it, opts.path) }
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// 3. Env-var path.
|
|
77
|
-
val envPath = System.getenv("DVAI_LICENSE_PATH")
|
|
78
|
-
if (!envPath.isNullOrEmpty()) {
|
|
79
|
-
val loaded = tryLoadFromPath(envPath)
|
|
80
|
-
if (loaded != null) return DiscoveredToken(loaded, "DVAI_LICENSE_PATH=$envPath")
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// 4. Env-var inline token (alternative to file).
|
|
84
|
-
val envToken = System.getenv("DVAI_LICENSE_TOKEN")
|
|
85
|
-
if (!envToken.isNullOrEmpty()) {
|
|
86
|
-
return DiscoveredToken(envToken.trim(), "DVAI_LICENSE_TOKEN env var")
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// 5. Bundled asset: assets/dvai-license.jwt.
|
|
90
|
-
val asset = tryLoadFromAsset(context, DEFAULT_LICENSE_FILENAME)
|
|
91
|
-
if (asset != null) return DiscoveredToken(asset, "assets/$DEFAULT_LICENSE_FILENAME")
|
|
92
|
-
|
|
93
|
-
// 6. Bundled raw resource: R.raw.dvai_license (looked up reflectively
|
|
94
|
-
// against the host app's R class because we can't reference its
|
|
95
|
-
// R from a library module).
|
|
96
|
-
val raw = tryLoadFromRawResource(context, DEFAULT_LICENSE_RAW_RESOURCE_NAME)
|
|
97
|
-
if (raw != null) return DiscoveredToken(raw, "res/raw/$DEFAULT_LICENSE_RAW_RESOURCE_NAME")
|
|
98
|
-
|
|
99
|
-
// 7. Internal storage: filesDir/dvai-license.jwt.
|
|
100
|
-
val internalFile = File(context.filesDir, DEFAULT_LICENSE_FILENAME)
|
|
101
|
-
val internal = tryLoadFromFile(internalFile)
|
|
102
|
-
if (internal != null) return DiscoveredToken(internal, internalFile.absolutePath)
|
|
103
|
-
|
|
104
|
-
return null
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
private fun tryLoadFromPath(path: String): String? = tryLoadFromFile(File(path))
|
|
108
|
-
|
|
109
|
-
private fun tryLoadFromFile(file: File): String? {
|
|
110
|
-
return try {
|
|
111
|
-
if (!file.exists() || !file.isFile) return null
|
|
112
|
-
val text = file.readText(Charsets.UTF_8).trim()
|
|
113
|
-
text.ifEmpty { null }
|
|
114
|
-
} catch (_: Throwable) {
|
|
115
|
-
null
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
private fun tryLoadFromAsset(context: Context, name: String): String? {
|
|
120
|
-
return try {
|
|
121
|
-
context.assets.open(name).use { input ->
|
|
122
|
-
val text = input.readBytes().toString(Charsets.UTF_8).trim()
|
|
123
|
-
text.ifEmpty { null }
|
|
124
|
-
}
|
|
125
|
-
} catch (_: Throwable) {
|
|
126
|
-
null
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
private fun tryLoadFromRawResource(context: Context, name: String): String? {
|
|
131
|
-
return try {
|
|
132
|
-
// Resource ids are dynamic — the library module has no compile-time
|
|
133
|
-
// reference to the host app's R.raw, so look it up by name via
|
|
134
|
-
// Resources.getIdentifier(). Returns 0 if the host app doesn't
|
|
135
|
-
// ship a `res/raw/dvai_license.*` resource, which we treat as a
|
|
136
|
-
// clean miss.
|
|
137
|
-
val resId = context.resources.getIdentifier(name, "raw", context.packageName)
|
|
138
|
-
if (resId == 0) return null
|
|
139
|
-
context.resources.openRawResource(resId).use { input ->
|
|
140
|
-
val text = input.readBytes().toString(Charsets.UTF_8).trim()
|
|
141
|
-
text.ifEmpty { null }
|
|
142
|
-
}
|
|
143
|
-
} catch (_: Throwable) {
|
|
144
|
-
null
|
|
145
|
-
}
|
|
146
|
-
}
|
|
1
|
+
package co.deepvoiceai.bridge.license
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import java.io.File
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* License-file discovery for the Android SDK.
|
|
8
|
+
*
|
|
9
|
+
* Kotlin port of `packages/dvai-bridge-core/src/license/discovery.ts`.
|
|
10
|
+
* The discovery priority order mirrors the JS side but the platform-
|
|
11
|
+
* default locations are Android-specific:
|
|
12
|
+
*
|
|
13
|
+
* 1. An explicit string literal passed via [LicenseDiscoveryOptions.token]
|
|
14
|
+
* — useful for CI / test contexts where reading a file isn't practical.
|
|
15
|
+
* 2. A path passed via [LicenseDiscoveryOptions.path] — the developer
|
|
16
|
+
* points the SDK at a file they've placed somewhere non-default.
|
|
17
|
+
* 3. The `DVAI_LICENSE_PATH` env var — same as (2) but driven by
|
|
18
|
+
* process environment, helpful for emulator-based testing.
|
|
19
|
+
* 4. The `DVAI_LICENSE_TOKEN` env var — inline JWT as an alternative
|
|
20
|
+
* to a file path.
|
|
21
|
+
* 5. The bundled asset `assets/dvai-license.jwt` — the conventional
|
|
22
|
+
* ship-with-the-APK location. Auto-discovered.
|
|
23
|
+
* 6. The bundled raw resource `R.raw.dvai_license` — alternative
|
|
24
|
+
* asset location for apps that already use the res/raw/ folder.
|
|
25
|
+
* Looked up reflectively against the host app's R.raw class.
|
|
26
|
+
* 7. Internal storage `context.filesDir/dvai-license.jwt` — for
|
|
27
|
+
* apps that drop the license at runtime (e.g. fetched from
|
|
28
|
+
* a self-hosted endpoint after first launch).
|
|
29
|
+
*
|
|
30
|
+
* Returning `null` means "no license file found"; the validator treats
|
|
31
|
+
* that as the free-prod case, which on Android escalates to a throw via
|
|
32
|
+
* [LicenseValidator.validateAndAssert].
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** Default filename the SDK looks for at every file location. */
|
|
36
|
+
const val DEFAULT_LICENSE_FILENAME: String = "dvai-license.jwt"
|
|
37
|
+
|
|
38
|
+
/** Default raw-resource name (without the `R.raw.` prefix). */
|
|
39
|
+
const val DEFAULT_LICENSE_RAW_RESOURCE_NAME: String = "dvai_license"
|
|
40
|
+
|
|
41
|
+
data class LicenseDiscoveryOptions(
|
|
42
|
+
/** Pre-loaded JWT string (skips all filesystem / asset lookups). */
|
|
43
|
+
val token: String? = null,
|
|
44
|
+
/** Explicit path to load from. Overrides auto-discovery. */
|
|
45
|
+
val path: String? = null,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
/** Result of a successful discovery; identifies the source for audit logging. */
|
|
49
|
+
data class DiscoveredToken(val token: String, val source: String)
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Best-effort load of a license JWT. Returns the raw token string on
|
|
53
|
+
* success or null on miss. Errors during loading (file not found, asset
|
|
54
|
+
* not present) collapse to null — the validator's responsibility is to
|
|
55
|
+
* handle the no-license case gracefully, not the discovery layer's.
|
|
56
|
+
*
|
|
57
|
+
* Side-effect-free in the sense of "no network calls"; reads from the
|
|
58
|
+
* filesystem and the APK's assets directory only.
|
|
59
|
+
*/
|
|
60
|
+
fun discoverLicenseToken(
|
|
61
|
+
context: Context,
|
|
62
|
+
opts: LicenseDiscoveryOptions = LicenseDiscoveryOptions(),
|
|
63
|
+
): DiscoveredToken? {
|
|
64
|
+
// 1. Explicit token wins.
|
|
65
|
+
if (!opts.token.isNullOrEmpty()) {
|
|
66
|
+
return DiscoveredToken(opts.token.trim(), "config.licenseToken")
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 2. Explicit path (config option). An explicit path that doesn't load
|
|
70
|
+
// is a real miss, not a silent fallthrough — matches JS behaviour.
|
|
71
|
+
if (!opts.path.isNullOrEmpty()) {
|
|
72
|
+
val loaded = tryLoadFromPath(opts.path)
|
|
73
|
+
return loaded?.let { DiscoveredToken(it, opts.path) }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 3. Env-var path.
|
|
77
|
+
val envPath = System.getenv("DVAI_LICENSE_PATH")
|
|
78
|
+
if (!envPath.isNullOrEmpty()) {
|
|
79
|
+
val loaded = tryLoadFromPath(envPath)
|
|
80
|
+
if (loaded != null) return DiscoveredToken(loaded, "DVAI_LICENSE_PATH=$envPath")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 4. Env-var inline token (alternative to file).
|
|
84
|
+
val envToken = System.getenv("DVAI_LICENSE_TOKEN")
|
|
85
|
+
if (!envToken.isNullOrEmpty()) {
|
|
86
|
+
return DiscoveredToken(envToken.trim(), "DVAI_LICENSE_TOKEN env var")
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 5. Bundled asset: assets/dvai-license.jwt.
|
|
90
|
+
val asset = tryLoadFromAsset(context, DEFAULT_LICENSE_FILENAME)
|
|
91
|
+
if (asset != null) return DiscoveredToken(asset, "assets/$DEFAULT_LICENSE_FILENAME")
|
|
92
|
+
|
|
93
|
+
// 6. Bundled raw resource: R.raw.dvai_license (looked up reflectively
|
|
94
|
+
// against the host app's R class because we can't reference its
|
|
95
|
+
// R from a library module).
|
|
96
|
+
val raw = tryLoadFromRawResource(context, DEFAULT_LICENSE_RAW_RESOURCE_NAME)
|
|
97
|
+
if (raw != null) return DiscoveredToken(raw, "res/raw/$DEFAULT_LICENSE_RAW_RESOURCE_NAME")
|
|
98
|
+
|
|
99
|
+
// 7. Internal storage: filesDir/dvai-license.jwt.
|
|
100
|
+
val internalFile = File(context.filesDir, DEFAULT_LICENSE_FILENAME)
|
|
101
|
+
val internal = tryLoadFromFile(internalFile)
|
|
102
|
+
if (internal != null) return DiscoveredToken(internal, internalFile.absolutePath)
|
|
103
|
+
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private fun tryLoadFromPath(path: String): String? = tryLoadFromFile(File(path))
|
|
108
|
+
|
|
109
|
+
private fun tryLoadFromFile(file: File): String? {
|
|
110
|
+
return try {
|
|
111
|
+
if (!file.exists() || !file.isFile) return null
|
|
112
|
+
val text = file.readText(Charsets.UTF_8).trim()
|
|
113
|
+
text.ifEmpty { null }
|
|
114
|
+
} catch (_: Throwable) {
|
|
115
|
+
null
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private fun tryLoadFromAsset(context: Context, name: String): String? {
|
|
120
|
+
return try {
|
|
121
|
+
context.assets.open(name).use { input ->
|
|
122
|
+
val text = input.readBytes().toString(Charsets.UTF_8).trim()
|
|
123
|
+
text.ifEmpty { null }
|
|
124
|
+
}
|
|
125
|
+
} catch (_: Throwable) {
|
|
126
|
+
null
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private fun tryLoadFromRawResource(context: Context, name: String): String? {
|
|
131
|
+
return try {
|
|
132
|
+
// Resource ids are dynamic — the library module has no compile-time
|
|
133
|
+
// reference to the host app's R.raw, so look it up by name via
|
|
134
|
+
// Resources.getIdentifier(). Returns 0 if the host app doesn't
|
|
135
|
+
// ship a `res/raw/dvai_license.*` resource, which we treat as a
|
|
136
|
+
// clean miss.
|
|
137
|
+
val resId = context.resources.getIdentifier(name, "raw", context.packageName)
|
|
138
|
+
if (resId == 0) return null
|
|
139
|
+
context.resources.openRawResource(resId).use { input ->
|
|
140
|
+
val text = input.readBytes().toString(Charsets.UTF_8).trim()
|
|
141
|
+
text.ifEmpty { null }
|
|
142
|
+
}
|
|
143
|
+
} catch (_: Throwable) {
|
|
144
|
+
null
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -1,158 +1,158 @@
|
|
|
1
|
-
package co.deepvoiceai.bridge.license
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Type surface for the DVAI-Bridge offline JWT license system.
|
|
5
|
-
*
|
|
6
|
-
* Kotlin port of `packages/dvai-bridge-core/src/license/types.ts`. The
|
|
7
|
-
* sealed [LicenseStatus] hierarchy mirrors the JS discriminated-union;
|
|
8
|
-
* each case carries the same fields. Android-native consumers dispatch
|
|
9
|
-
* on `when (status)` exhaustively, matching the JS `switch(status.kind)`.
|
|
10
|
-
*
|
|
11
|
-
* The whole license flow is deliberately small:
|
|
12
|
-
* 1. A signed JWT (produced server-side by your license generator) is
|
|
13
|
-
* either dropped at a platform-default path, pointed at via the
|
|
14
|
-
* [co.deepvoiceai.bridge.StartOptions.licenseKeyPath] config option,
|
|
15
|
-
* or pasted directly into [co.deepvoiceai.bridge.StartOptions.licenseToken].
|
|
16
|
-
* 2. The SDK reads it, verifies the ECDSA P-256 signature against the
|
|
17
|
-
* key registry in [DvaiPublicKeys], and checks four runtime claims:
|
|
18
|
-
* - signature must verify against a known kid
|
|
19
|
-
* - `exp` must be in the future
|
|
20
|
-
* - `aud` must include the current audience (package name)
|
|
21
|
-
* - `platforms` must include "android"
|
|
22
|
-
* 3. The outcome is summarised in a [LicenseStatus] value that the
|
|
23
|
-
* rest of the SDK can dispatch on.
|
|
24
|
-
*
|
|
25
|
-
* Nothing in this file makes network calls. The entire flow is offline.
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
/** Platform identifiers the SDK recognises in license `platforms` claims. */
|
|
29
|
-
enum class DvaiPlatform(val wire: String) {
|
|
30
|
-
WEB("web"),
|
|
31
|
-
NODE("node"),
|
|
32
|
-
IOS("ios"),
|
|
33
|
-
ANDROID("android"),
|
|
34
|
-
DOTNET("dotnet"),
|
|
35
|
-
FLUTTER("flutter"),
|
|
36
|
-
REACT_NATIVE("react-native"),
|
|
37
|
-
CAPACITOR("capacitor");
|
|
38
|
-
|
|
39
|
-
companion object {
|
|
40
|
-
fun fromWire(s: String): DvaiPlatform? = values().firstOrNull { it.wire == s }
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Payload shape we issue (subset; extra claims tolerated). Mirrors the
|
|
46
|
-
* JS-side `DvaiLicensePayload`.
|
|
47
|
-
*
|
|
48
|
-
* @property iss Standard JWT issuer claim. Must be `"DVAI-Bridge"`.
|
|
49
|
-
* @property sub Standard subject — internal license id. Surfaced in audit logs.
|
|
50
|
-
* @property aud Audience binding — list of domains and/or bundle ids permitted
|
|
51
|
-
* to activate this license. Each entry is either an exact string
|
|
52
|
-
* match (e.g. `"com.acme.app"`) or a wildcard subdomain
|
|
53
|
-
* pattern (e.g. `"*.acme.com"` matches both `acme.com` and
|
|
54
|
-
* `app.acme.com`), or `"*"` for any-audience licenses.
|
|
55
|
-
* @property tier Tier the license grants. `"commercial"` and `"trial"` are
|
|
56
|
-
* the live tiers; the validator never produces `"free-*"` here
|
|
57
|
-
* (those are computed at validation time).
|
|
58
|
-
* @property platforms Which DVAI-Bridge SDK platforms this license activates.
|
|
59
|
-
* The current runtime platform must appear here for the
|
|
60
|
-
* license to apply.
|
|
61
|
-
* @property licensee Display name of the licensee, for audit logs + user-facing messaging.
|
|
62
|
-
* @property iat Standard JWT issued-at (seconds since Unix epoch).
|
|
63
|
-
* @property exp Standard JWT expiry (seconds since Unix epoch).
|
|
64
|
-
*/
|
|
65
|
-
data class DvaiLicensePayload(
|
|
66
|
-
val iss: String,
|
|
67
|
-
val sub: String,
|
|
68
|
-
val aud: List<String>,
|
|
69
|
-
val tier: String, // "commercial" | "trial"
|
|
70
|
-
val platforms: List<String>,
|
|
71
|
-
val licensee: String,
|
|
72
|
-
val iat: Long,
|
|
73
|
-
val exp: Long,
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Result of license validation. Sealed class so the consumer's decision
|
|
78
|
-
* tree is exhaustive (`Commercial` or `Trial` → premium; everything else
|
|
79
|
-
* → free).
|
|
80
|
-
*
|
|
81
|
-
* Note: on Android the validator NEVER produces a [FreeProd] status that
|
|
82
|
-
* the SDK then runs in free tier — production Android without a license
|
|
83
|
-
* throws via [LicenseRequiredError]. The status case exists so that
|
|
84
|
-
* `validate()` (the non-throwing variant) can describe the failure for
|
|
85
|
-
* host-app dashboards / logging, but [LicenseValidator.validateAndAssert]
|
|
86
|
-
* (called from `DVAIBridge.start`) converts it to a throw.
|
|
87
|
-
*/
|
|
88
|
-
sealed class LicenseStatus {
|
|
89
|
-
/** Paid commercial license — premium tier. */
|
|
90
|
-
data class Commercial(
|
|
91
|
-
val licensee: String,
|
|
92
|
-
val expiresAt: Long,
|
|
93
|
-
val platform: DvaiPlatform,
|
|
94
|
-
val audienceMatched: String,
|
|
95
|
-
) : LicenseStatus()
|
|
96
|
-
|
|
97
|
-
/** Paid trial license — premium tier, time-limited. */
|
|
98
|
-
data class Trial(
|
|
99
|
-
val licensee: String,
|
|
100
|
-
val expiresAt: Long,
|
|
101
|
-
val platform: DvaiPlatform,
|
|
102
|
-
val audienceMatched: String,
|
|
103
|
-
) : LicenseStatus()
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Debug build / dev-mode bypass. License enforcement skipped.
|
|
107
|
-
* @property reason Why dev mode was detected (for logging / dashboard surfacing).
|
|
108
|
-
*/
|
|
109
|
-
data class FreeDev(val reason: String) : LicenseStatus()
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Production deploy with no valid license. On Android,
|
|
113
|
-
* `validateAndAssert()` throws [LicenseRequiredError] for this case
|
|
114
|
-
* rather than allowing free-tier operation — unlike the JS SDK which
|
|
115
|
-
* runs with a watermark in free-prod.
|
|
116
|
-
*
|
|
117
|
-
* @property reason Specific failure mode (no token, signature failed,
|
|
118
|
-
* audience mismatch, etc.) — verbose by design so the
|
|
119
|
-
* developer can self-debug without a support ticket.
|
|
120
|
-
*/
|
|
121
|
-
data class FreeProd(val reason: String) : LicenseStatus()
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Had a valid license but `exp` is in the past. On Android,
|
|
125
|
-
* `validateAndAssert()` throws [LicenseRequiredError] for this case.
|
|
126
|
-
*/
|
|
127
|
-
data class FreeExpired(
|
|
128
|
-
val licensee: String,
|
|
129
|
-
val expiredAt: Long,
|
|
130
|
-
) : LicenseStatus()
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** Returns true iff `status` represents a paid / unwatermarked tier. */
|
|
134
|
-
fun isPaidTier(status: LicenseStatus): Boolean =
|
|
135
|
-
status is LicenseStatus.Commercial || status is LicenseStatus.Trial
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Thrown by [LicenseValidator.validateAndAssert] (and propagated from
|
|
139
|
-
* `DVAIBridge.start(...)`) when an SDK consumer attempts to run the
|
|
140
|
-
* library in a production / release context without a valid commercial
|
|
141
|
-
* or trial license.
|
|
142
|
-
*
|
|
143
|
-
* The error message is intentionally verbose: it tells the developer
|
|
144
|
-
* exactly which check failed (missing file, expired, audience mismatch,
|
|
145
|
-
* etc.), how to resolve it, and where to put the license file once
|
|
146
|
-
* they have one. This is the front line of the BSL 1.1 commercial
|
|
147
|
-
* enforcement story — surface it clearly enough that a developer can
|
|
148
|
-
* unblock themselves without a support ticket.
|
|
149
|
-
*
|
|
150
|
-
* The `status` field carries the underlying [LicenseStatus] so
|
|
151
|
-
* programmatic callers can dispatch on `err.status` if they want to
|
|
152
|
-
* handle "expired" differently from "missing".
|
|
153
|
-
*/
|
|
154
|
-
class LicenseRequiredError(
|
|
155
|
-
message: String,
|
|
156
|
-
/** The underlying validator status that triggered the throw. */
|
|
157
|
-
val status: LicenseStatus,
|
|
158
|
-
) : Exception(message)
|
|
1
|
+
package co.deepvoiceai.bridge.license
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Type surface for the DVAI-Bridge offline JWT license system.
|
|
5
|
+
*
|
|
6
|
+
* Kotlin port of `packages/dvai-bridge-core/src/license/types.ts`. The
|
|
7
|
+
* sealed [LicenseStatus] hierarchy mirrors the JS discriminated-union;
|
|
8
|
+
* each case carries the same fields. Android-native consumers dispatch
|
|
9
|
+
* on `when (status)` exhaustively, matching the JS `switch(status.kind)`.
|
|
10
|
+
*
|
|
11
|
+
* The whole license flow is deliberately small:
|
|
12
|
+
* 1. A signed JWT (produced server-side by your license generator) is
|
|
13
|
+
* either dropped at a platform-default path, pointed at via the
|
|
14
|
+
* [co.deepvoiceai.bridge.StartOptions.licenseKeyPath] config option,
|
|
15
|
+
* or pasted directly into [co.deepvoiceai.bridge.StartOptions.licenseToken].
|
|
16
|
+
* 2. The SDK reads it, verifies the ECDSA P-256 signature against the
|
|
17
|
+
* key registry in [DvaiPublicKeys], and checks four runtime claims:
|
|
18
|
+
* - signature must verify against a known kid
|
|
19
|
+
* - `exp` must be in the future
|
|
20
|
+
* - `aud` must include the current audience (package name)
|
|
21
|
+
* - `platforms` must include "android"
|
|
22
|
+
* 3. The outcome is summarised in a [LicenseStatus] value that the
|
|
23
|
+
* rest of the SDK can dispatch on.
|
|
24
|
+
*
|
|
25
|
+
* Nothing in this file makes network calls. The entire flow is offline.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Platform identifiers the SDK recognises in license `platforms` claims. */
|
|
29
|
+
enum class DvaiPlatform(val wire: String) {
|
|
30
|
+
WEB("web"),
|
|
31
|
+
NODE("node"),
|
|
32
|
+
IOS("ios"),
|
|
33
|
+
ANDROID("android"),
|
|
34
|
+
DOTNET("dotnet"),
|
|
35
|
+
FLUTTER("flutter"),
|
|
36
|
+
REACT_NATIVE("react-native"),
|
|
37
|
+
CAPACITOR("capacitor");
|
|
38
|
+
|
|
39
|
+
companion object {
|
|
40
|
+
fun fromWire(s: String): DvaiPlatform? = values().firstOrNull { it.wire == s }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Payload shape we issue (subset; extra claims tolerated). Mirrors the
|
|
46
|
+
* JS-side `DvaiLicensePayload`.
|
|
47
|
+
*
|
|
48
|
+
* @property iss Standard JWT issuer claim. Must be `"DVAI-Bridge"`.
|
|
49
|
+
* @property sub Standard subject — internal license id. Surfaced in audit logs.
|
|
50
|
+
* @property aud Audience binding — list of domains and/or bundle ids permitted
|
|
51
|
+
* to activate this license. Each entry is either an exact string
|
|
52
|
+
* match (e.g. `"com.acme.app"`) or a wildcard subdomain
|
|
53
|
+
* pattern (e.g. `"*.acme.com"` matches both `acme.com` and
|
|
54
|
+
* `app.acme.com`), or `"*"` for any-audience licenses.
|
|
55
|
+
* @property tier Tier the license grants. `"commercial"` and `"trial"` are
|
|
56
|
+
* the live tiers; the validator never produces `"free-*"` here
|
|
57
|
+
* (those are computed at validation time).
|
|
58
|
+
* @property platforms Which DVAI-Bridge SDK platforms this license activates.
|
|
59
|
+
* The current runtime platform must appear here for the
|
|
60
|
+
* license to apply.
|
|
61
|
+
* @property licensee Display name of the licensee, for audit logs + user-facing messaging.
|
|
62
|
+
* @property iat Standard JWT issued-at (seconds since Unix epoch).
|
|
63
|
+
* @property exp Standard JWT expiry (seconds since Unix epoch).
|
|
64
|
+
*/
|
|
65
|
+
data class DvaiLicensePayload(
|
|
66
|
+
val iss: String,
|
|
67
|
+
val sub: String,
|
|
68
|
+
val aud: List<String>,
|
|
69
|
+
val tier: String, // "commercial" | "trial"
|
|
70
|
+
val platforms: List<String>,
|
|
71
|
+
val licensee: String,
|
|
72
|
+
val iat: Long,
|
|
73
|
+
val exp: Long,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Result of license validation. Sealed class so the consumer's decision
|
|
78
|
+
* tree is exhaustive (`Commercial` or `Trial` → premium; everything else
|
|
79
|
+
* → free).
|
|
80
|
+
*
|
|
81
|
+
* Note: on Android the validator NEVER produces a [FreeProd] status that
|
|
82
|
+
* the SDK then runs in free tier — production Android without a license
|
|
83
|
+
* throws via [LicenseRequiredError]. The status case exists so that
|
|
84
|
+
* `validate()` (the non-throwing variant) can describe the failure for
|
|
85
|
+
* host-app dashboards / logging, but [LicenseValidator.validateAndAssert]
|
|
86
|
+
* (called from `DVAIBridge.start`) converts it to a throw.
|
|
87
|
+
*/
|
|
88
|
+
sealed class LicenseStatus {
|
|
89
|
+
/** Paid commercial license — premium tier. */
|
|
90
|
+
data class Commercial(
|
|
91
|
+
val licensee: String,
|
|
92
|
+
val expiresAt: Long,
|
|
93
|
+
val platform: DvaiPlatform,
|
|
94
|
+
val audienceMatched: String,
|
|
95
|
+
) : LicenseStatus()
|
|
96
|
+
|
|
97
|
+
/** Paid trial license — premium tier, time-limited. */
|
|
98
|
+
data class Trial(
|
|
99
|
+
val licensee: String,
|
|
100
|
+
val expiresAt: Long,
|
|
101
|
+
val platform: DvaiPlatform,
|
|
102
|
+
val audienceMatched: String,
|
|
103
|
+
) : LicenseStatus()
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Debug build / dev-mode bypass. License enforcement skipped.
|
|
107
|
+
* @property reason Why dev mode was detected (for logging / dashboard surfacing).
|
|
108
|
+
*/
|
|
109
|
+
data class FreeDev(val reason: String) : LicenseStatus()
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Production deploy with no valid license. On Android,
|
|
113
|
+
* `validateAndAssert()` throws [LicenseRequiredError] for this case
|
|
114
|
+
* rather than allowing free-tier operation — unlike the JS SDK which
|
|
115
|
+
* runs with a watermark in free-prod.
|
|
116
|
+
*
|
|
117
|
+
* @property reason Specific failure mode (no token, signature failed,
|
|
118
|
+
* audience mismatch, etc.) — verbose by design so the
|
|
119
|
+
* developer can self-debug without a support ticket.
|
|
120
|
+
*/
|
|
121
|
+
data class FreeProd(val reason: String) : LicenseStatus()
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Had a valid license but `exp` is in the past. On Android,
|
|
125
|
+
* `validateAndAssert()` throws [LicenseRequiredError] for this case.
|
|
126
|
+
*/
|
|
127
|
+
data class FreeExpired(
|
|
128
|
+
val licensee: String,
|
|
129
|
+
val expiredAt: Long,
|
|
130
|
+
) : LicenseStatus()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Returns true iff `status` represents a paid / unwatermarked tier. */
|
|
134
|
+
fun isPaidTier(status: LicenseStatus): Boolean =
|
|
135
|
+
status is LicenseStatus.Commercial || status is LicenseStatus.Trial
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Thrown by [LicenseValidator.validateAndAssert] (and propagated from
|
|
139
|
+
* `DVAIBridge.start(...)`) when an SDK consumer attempts to run the
|
|
140
|
+
* library in a production / release context without a valid commercial
|
|
141
|
+
* or trial license.
|
|
142
|
+
*
|
|
143
|
+
* The error message is intentionally verbose: it tells the developer
|
|
144
|
+
* exactly which check failed (missing file, expired, audience mismatch,
|
|
145
|
+
* etc.), how to resolve it, and where to put the license file once
|
|
146
|
+
* they have one. This is the front line of the BSL 1.1 commercial
|
|
147
|
+
* enforcement story — surface it clearly enough that a developer can
|
|
148
|
+
* unblock themselves without a support ticket.
|
|
149
|
+
*
|
|
150
|
+
* The `status` field carries the underlying [LicenseStatus] so
|
|
151
|
+
* programmatic callers can dispatch on `err.status` if they want to
|
|
152
|
+
* handle "expired" differently from "missing".
|
|
153
|
+
*/
|
|
154
|
+
class LicenseRequiredError(
|
|
155
|
+
message: String,
|
|
156
|
+
/** The underlying validator status that triggered the throw. */
|
|
157
|
+
val status: LicenseStatus,
|
|
158
|
+
) : Exception(message)
|