@dvai-bridge/android 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/LICENSE +51 -0
  2. package/README.md +199 -0
  3. package/android/build.gradle +165 -0
  4. package/android/gradle.properties +5 -0
  5. package/android/settings.gradle +1 -0
  6. package/android/src/androidTest/java/co/deepvoiceai/bridge/RealModelIntegrationTest.kt +162 -0
  7. package/android/src/main/AndroidManifest.xml +3 -0
  8. package/android/src/main/java/co/deepvoiceai/bridge/BackendKind.kt +21 -0
  9. package/android/src/main/java/co/deepvoiceai/bridge/BackendSelector.kt +28 -0
  10. package/android/src/main/java/co/deepvoiceai/bridge/BoundServer.kt +39 -0
  11. package/android/src/main/java/co/deepvoiceai/bridge/DVAIBridge.kt +642 -0
  12. package/android/src/main/java/co/deepvoiceai/bridge/DVAIBridgeConfig.kt +119 -0
  13. package/android/src/main/java/co/deepvoiceai/bridge/DVAIBridgeError.kt +51 -0
  14. package/android/src/main/java/co/deepvoiceai/bridge/OffloadProxy.kt +574 -0
  15. package/android/src/main/java/co/deepvoiceai/bridge/ProgressBroadcaster.kt +55 -0
  16. package/android/src/main/java/co/deepvoiceai/bridge/ProgressEvent.kt +25 -0
  17. package/android/src/main/java/co/deepvoiceai/bridge/ReactiveState.kt +60 -0
  18. package/android/src/main/java/co/deepvoiceai/bridge/license/Audience.kt +134 -0
  19. package/android/src/main/java/co/deepvoiceai/bridge/license/Discovery.kt +146 -0
  20. package/android/src/main/java/co/deepvoiceai/bridge/license/LicenseTypes.kt +158 -0
  21. package/android/src/main/java/co/deepvoiceai/bridge/license/LicenseValidator.kt +400 -0
  22. package/android/src/main/java/co/deepvoiceai/bridge/license/PublicKeys.kt +91 -0
  23. package/android/src/test/java/co/deepvoiceai/bridge/BackendSelectorTest.kt +76 -0
  24. package/android/src/test/java/co/deepvoiceai/bridge/CapabilityPrecheckTest.kt +117 -0
  25. package/android/src/test/java/co/deepvoiceai/bridge/DVAIBridgeAPIShapeTest.kt +86 -0
  26. package/android/src/test/java/co/deepvoiceai/bridge/OffloadProxyDecisionTest.kt +144 -0
  27. package/android/src/test/java/co/deepvoiceai/bridge/OffloadProxyForwardingTest.kt +327 -0
  28. package/android/src/test/java/co/deepvoiceai/bridge/ProgressBroadcasterTest.kt +56 -0
  29. package/android/src/test/java/co/deepvoiceai/bridge/license/LicenseValidatorTest.kt +539 -0
  30. package/package.json +19 -0
@@ -0,0 +1,60 @@
1
+ package co.deepvoiceai.bridge
2
+
3
+ import kotlinx.coroutines.flow.MutableStateFlow
4
+ import kotlinx.coroutines.flow.StateFlow
5
+ import kotlinx.coroutines.flow.asStateFlow
6
+
7
+ /**
8
+ * Compose- / Lifecycle-friendly reactive view of the running bridge state.
9
+ * Mirrors iOS `DVAIBridgeReactiveState` (which uses `@Observable` /
10
+ * `ObservableObject`).
11
+ *
12
+ * Each property is a [StateFlow] you can `collect` from in a Compose
13
+ * `LaunchedEffect` or wire to a ViewModel:
14
+ *
15
+ * ```kotlin
16
+ * @Composable
17
+ * fun BridgeStatus() {
18
+ * val isReady by DVAIBridge.reactive.isReady.collectAsState()
19
+ * val baseUrl by DVAIBridge.reactive.baseUrl.collectAsState()
20
+ * Text(if (isReady) "Ready: $baseUrl" else "Not running")
21
+ * }
22
+ * ```
23
+ *
24
+ * The state is updated internally on every successful [DVAIBridge.start] and
25
+ * [DVAIBridge.stop]; consumers never write directly.
26
+ */
27
+ class DVAIBridgeReactiveState internal constructor() {
28
+ private val _isReady = MutableStateFlow(false)
29
+ val isReady: StateFlow<Boolean> = _isReady.asStateFlow()
30
+
31
+ private val _baseUrl = MutableStateFlow<String?>(null)
32
+ val baseUrl: StateFlow<String?> = _baseUrl.asStateFlow()
33
+
34
+ private val _port = MutableStateFlow<Int?>(null)
35
+ val port: StateFlow<Int?> = _port.asStateFlow()
36
+
37
+ private val _backend = MutableStateFlow<BackendKind?>(null)
38
+ val backend: StateFlow<BackendKind?> = _backend.asStateFlow()
39
+
40
+ private val _modelId = MutableStateFlow<String?>(null)
41
+ val modelId: StateFlow<String?> = _modelId.asStateFlow()
42
+
43
+ /** Internal setter, invoked by [DVAIBridge] on start. */
44
+ internal fun onStarted(server: BoundServer) {
45
+ _baseUrl.value = server.baseUrl
46
+ _port.value = server.port
47
+ _backend.value = server.backend
48
+ _modelId.value = server.modelId
49
+ _isReady.value = true
50
+ }
51
+
52
+ /** Internal setter, invoked by [DVAIBridge] on stop. */
53
+ internal fun onStopped() {
54
+ _isReady.value = false
55
+ _baseUrl.value = null
56
+ _port.value = null
57
+ _backend.value = null
58
+ _modelId.value = null
59
+ }
60
+ }
@@ -0,0 +1,134 @@
1
+ package co.deepvoiceai.bridge.license
2
+
3
+ import android.content.Context
4
+ import android.content.pm.ApplicationInfo
5
+
6
+ /**
7
+ * Runtime audience + platform + dev-mode detection for the Android SDK.
8
+ *
9
+ * Kotlin port of `packages/dvai-bridge-core/src/license/audience.ts`.
10
+ * The semantics are identical to the JS side but the platform APIs
11
+ * differ — audience is read from [Context.getPackageName] rather than
12
+ * `window.location.hostname`, and dev-mode is detected via
13
+ * `BuildConfig.DEBUG` + `ApplicationInfo.FLAG_DEBUGGABLE` rather than
14
+ * hostname heuristics.
15
+ *
16
+ * "Audience" on Android = the host app's package name (e.g.
17
+ * `"com.acme.app"`). License JWTs bind to package names exactly like
18
+ * iOS bundle ids; subdomain-style `*.acme.com` wildcards work for
19
+ * domain-tail matching too.
20
+ *
21
+ * "Dev mode" detection bypasses license enforcement entirely so
22
+ * developers don't need a license to run the SDK on a debug build.
23
+ * This matches the JS-side `detectDevMode()` behaviour.
24
+ */
25
+
26
+ /** Detect the current SDK platform identifier. Always [DvaiPlatform.ANDROID]. */
27
+ fun detectPlatform(): DvaiPlatform = DvaiPlatform.ANDROID
28
+
29
+ /**
30
+ * Detect the current audience string the license must bind. Returns
31
+ * the host app's package name from [Context.getPackageName].
32
+ *
33
+ * Returns null only if [context] is null — in practice this never
34
+ * happens because [LicenseValidator] requires a non-null context at
35
+ * construction.
36
+ */
37
+ fun detectAudience(context: Context?): String? = context?.packageName
38
+
39
+ /**
40
+ * Detect whether the SDK is running in a developer environment where
41
+ * license enforcement should be bypassed. The bypass list is
42
+ * intentionally generous: blocking a developer mid-`./gradlew installDebug`
43
+ * with a license-not-found error would be hostile.
44
+ *
45
+ * Precedence (highest first):
46
+ * 1. `DVAI_FORCE_PROD=1` env var — production mode forced, overrides DEBUG.
47
+ * 2. `DVAI_FORCE_DEV=1` env var — dev mode forced.
48
+ * 3. [hostBuildConfigDebug] (a `BuildConfig.DEBUG` value passed in by the
49
+ * host app via [co.deepvoiceai.bridge.StartOptions]) — dev mode.
50
+ * 4. [ApplicationInfo.FLAG_DEBUGGABLE] on the host app — dev mode.
51
+ * 5. Otherwise production, license required.
52
+ *
53
+ * @param context Application context — used to read
54
+ * `ApplicationInfo.FLAG_DEBUGGABLE`.
55
+ * @param hostBuildConfigDebug The host app's `BuildConfig.DEBUG`, passed in
56
+ * explicitly because the validator lives in a
57
+ * library module whose own `BuildConfig.DEBUG`
58
+ * never reflects the host app's state. Pass null
59
+ * to skip this check.
60
+ */
61
+ fun detectDevMode(
62
+ context: Context?,
63
+ hostBuildConfigDebug: Boolean? = null,
64
+ ): DevModeResult {
65
+ // 1. Explicit env-var overrides win — these mirror the JS side so the
66
+ // same DVAI_FORCE_PROD / DVAI_FORCE_DEV semantics work in CI/test
67
+ // contexts on any platform.
68
+ val forceProd = System.getenv("DVAI_FORCE_PROD")
69
+ if (forceProd == "1" || forceProd == "true") {
70
+ return DevModeResult(isDev = false, reason = "DVAI_FORCE_PROD set")
71
+ }
72
+ val forceDev = System.getenv("DVAI_FORCE_DEV")
73
+ if (forceDev == "1" || forceDev == "true") {
74
+ return DevModeResult(isDev = true, reason = "DVAI_FORCE_DEV set")
75
+ }
76
+
77
+ // 2. Host app's BuildConfig.DEBUG (explicit, preferred). When the host
78
+ // passes a non-null value we treat it as authoritative — the host
79
+ // knows whether they're a debug build better than we do. When null,
80
+ // we fall through to the manifest-flag heuristic.
81
+ when (hostBuildConfigDebug) {
82
+ true -> return DevModeResult(isDev = true, reason = "BuildConfig.DEBUG=true")
83
+ false -> return DevModeResult(isDev = false, reason = "BuildConfig.DEBUG=false (host-supplied)")
84
+ null -> { /* fall through */ }
85
+ }
86
+
87
+ // 3. ApplicationInfo.FLAG_DEBUGGABLE — set on debug builds and on apps
88
+ // with `android:debuggable="true"` in the manifest. Fallback when the
89
+ // host hasn't wired their BuildConfig.DEBUG through to StartOptions.
90
+ if (context != null) {
91
+ val flags = context.applicationInfo.flags
92
+ if ((flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0) {
93
+ return DevModeResult(isDev = true, reason = "ApplicationInfo.FLAG_DEBUGGABLE set")
94
+ }
95
+ }
96
+
97
+ return DevModeResult(isDev = false, reason = "production-class environment")
98
+ }
99
+
100
+ /** Outcome of [detectDevMode]. */
101
+ data class DevModeResult(val isDev: Boolean, val reason: String)
102
+
103
+ /**
104
+ * Decide whether a license-payload `aud` entry matches the current
105
+ * runtime audience. Supports exact match and `*.example.com` wildcard
106
+ * matching for subdomain binding. Returns the matched `aud` pattern
107
+ * on success so it can be recorded for audit, or null on miss.
108
+ *
109
+ * Match rules (identical to the JS side):
110
+ * - `"foo"` matches `"foo"` exactly
111
+ * - `"*.example.com"` matches `"example.com"` AND any `"<sub>.example.com"`
112
+ * - `"*"` matches any non-empty audience (intentionally permissive; use
113
+ * for trial/site licenses that span all of a customer's deployments)
114
+ *
115
+ * Runtime audience of `null` matches `"*"` only.
116
+ */
117
+ fun matchAudience(runtimeAudience: String?, audClaim: List<String>): String? {
118
+ if (runtimeAudience == null) {
119
+ return if (audClaim.contains("*")) "*" else null
120
+ }
121
+ val runtime = runtimeAudience.lowercase()
122
+ for (pattern in audClaim) {
123
+ val p = pattern.lowercase()
124
+ if (p == "*") return pattern // permissive wildcard
125
+ if (p == runtime) return pattern // exact match
126
+ if (p.startsWith("*.")) {
127
+ val suffix = p.substring(2)
128
+ if (runtime == suffix || runtime.endsWith(".$suffix")) {
129
+ return pattern
130
+ }
131
+ }
132
+ }
133
+ return null
134
+ }
@@ -0,0 +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
+ }
@@ -0,0 +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)