@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.
@@ -1,119 +1,119 @@
1
- package co.deepvoiceai.bridge
2
-
3
- import co.deepvoiceai.bridge.shared.core.CorsConfig
4
- import co.deepvoiceai.bridge.shared.core.offload.OffloadConfig
5
-
6
- /**
7
- * Options accepted by [DVAIBridge.start]. Mirrors the iOS DVAIBridge
8
- * `StartOptions` struct + the Capacitor JS shim's StartOptions.
9
- *
10
- * @param backend Which backend to use. [BackendKind.Auto] resolves
11
- * via [BackendSelector] at start-time.
12
- * @param modelPath Filesystem path to the model checkpoint. Required
13
- * for Llama (.gguf), MediaPipe (.task), and LiteRT
14
- * (.tflite / .litertlm) backends.
15
- * @param tokenizerPath Filesystem path to a directory containing
16
- * tokenizer.json (and optional tokenizer_config.json).
17
- * Required for the LiteRT backend; ignored otherwise.
18
- * @param mmprojPath Optional multimodal projector path for Llama
19
- * (vision/audio LLMs).
20
- * @param chatTemplate Optional Jinja chat template override (Llama backend
21
- * only). Falls back to the model's bundled template.
22
- * @param gpuLayers Llama backend: number of transformer layers to
23
- * offload to GPU (Vulkan / OpenCL on supported
24
- * devices). 99 = all layers, 0 = CPU only. Ignored
25
- * by other backends.
26
- * @param contextSize Context window in tokens. Defaults to 2048.
27
- * @param threads CPU thread count for the inference loop. Llama
28
- * uses this directly; LiteRT/MediaPipe pick their
29
- * own threading by default.
30
- * @param embeddingMode Llama backend: open the model in
31
- * embedding-extraction mode rather than completion
32
- * mode. Mutually exclusive with chat completion.
33
- * @param visionEnabled MediaPipe backend: open the LiteRT-LM EngineConfig
34
- * with `visionBackend` enabled.
35
- * @param temperature LiteRT backend: sampling temperature (0 = greedy).
36
- * @param topP LiteRT backend: nucleus sampling cutoff (1 = disabled).
37
- * @param topK LiteRT backend: top-K truncation (0 = disabled).
38
- * @param maxNewTokens LiteRT backend: hard cap on tokens generated per request.
39
- * @param httpBasePort First port the HTTP server tries to bind. Defaults
40
- * to 38883 (matches the rest of the dvai-bridge family).
41
- * @param httpMaxPortAttempts Number of consecutive ports to try before giving
42
- * up. Defaults to 16.
43
- * @param corsOrigin CORS allow-origin policy. See [CorsConfig].
44
- * Defaults to wildcard.
45
- * @param modelId Optional override for the model id surfaced via
46
- * `/v1/models`. Defaults to the file name minus
47
- * extension when null.
48
- */
49
- data class StartOptions(
50
- val backend: BackendKind = BackendKind.Auto,
51
- val modelPath: String? = null,
52
- val tokenizerPath: String? = null,
53
- val mmprojPath: String? = null,
54
- val chatTemplate: String? = null,
55
- val gpuLayers: Int = 99,
56
- val contextSize: Int = 2048,
57
- val threads: Int = 4,
58
- val embeddingMode: Boolean = false,
59
- val visionEnabled: Boolean = false,
60
- val temperature: Float = 0f,
61
- val topP: Float = 1f,
62
- val topK: Int = 0,
63
- val maxNewTokens: Int = 512,
64
- val httpBasePort: Int = 38883,
65
- val httpMaxPortAttempts: Int = 16,
66
- val corsOrigin: CorsConfig = CorsConfig.Wildcard,
67
- val modelId: String? = null,
68
- /**
69
- * Phase 3 — opt-in distributed inference / device offload. When
70
- * `enabled = true`, [DVAIBridge] spins up an [NsdDiscovery] +
71
- * [NsdAdvertiser], a [CapabilityCache], and a [PairingPolicy]
72
- * whose `requests` Flow is exposed via `DVAIBridge.pairingRequests`
73
- * for the host UI. Default null = behave exactly like v2.x.
74
- */
75
- val offload: OffloadConfig? = null,
76
- /**
77
- * v3.3 — offline JWT license validator config. When non-null, the
78
- * SDK loads the JWT from this filesystem path and verifies it at
79
- * startup. Auto-discovery (assets/, res/raw/, filesDir/) runs when
80
- * BOTH this AND [licenseToken] are null.
81
- *
82
- * Production Android builds without a valid license throw
83
- * [co.deepvoiceai.bridge.license.LicenseRequiredError] from
84
- * [DVAIBridge.start]. Debug builds (`hostBuildConfigDebug = true`
85
- * or `ApplicationInfo.FLAG_DEBUGGABLE`) skip validation entirely.
86
- */
87
- val licenseKeyPath: String? = null,
88
- /**
89
- * v3.3 — inline JWT license token. Overrides every other discovery
90
- * source. Useful for CI / test contexts where reading a file isn't
91
- * practical and operators inject via env var or build config.
92
- */
93
- val licenseToken: String? = null,
94
- /**
95
- * v3.3 — the host app's `BuildConfig.DEBUG` value, passed through to
96
- * the license validator's dev-mode bypass. When true the validator
97
- * returns `FreeDev` without trying to verify anything; when false (or
98
- * null) the validator falls back to `ApplicationInfo.FLAG_DEBUGGABLE`.
99
- *
100
- * Pass `BuildConfig.DEBUG` from your app module here — the validator
101
- * lives in this library module whose own `BuildConfig.DEBUG` never
102
- * reflects the host app's state.
103
- */
104
- val hostBuildConfigDebug: Boolean? = null,
105
- )
106
-
107
- /** Options for [DVAIBridge.downloadModel]. */
108
- data class DownloadOptions(
109
- val url: String,
110
- val sha256: String,
111
- val destFilename: String,
112
- )
113
-
114
- /** Result of a successful [DVAIBridge.downloadModel] call. */
115
- data class DownloadResult(
116
- val path: String,
117
- val sha256: String,
118
- val sizeBytes: Long,
119
- )
1
+ package co.deepvoiceai.bridge
2
+
3
+ import co.deepvoiceai.bridge.shared.core.CorsConfig
4
+ import co.deepvoiceai.bridge.shared.core.offload.OffloadConfig
5
+
6
+ /**
7
+ * Options accepted by [DVAIBridge.start]. Mirrors the iOS DVAIBridge
8
+ * `StartOptions` struct + the Capacitor JS shim's StartOptions.
9
+ *
10
+ * @param backend Which backend to use. [BackendKind.Auto] resolves
11
+ * via [BackendSelector] at start-time.
12
+ * @param modelPath Filesystem path to the model checkpoint. Required
13
+ * for Llama (.gguf), MediaPipe (.task), and LiteRT
14
+ * (.tflite / .litertlm) backends.
15
+ * @param tokenizerPath Filesystem path to a directory containing
16
+ * tokenizer.json (and optional tokenizer_config.json).
17
+ * Required for the LiteRT backend; ignored otherwise.
18
+ * @param mmprojPath Optional multimodal projector path for Llama
19
+ * (vision/audio LLMs).
20
+ * @param chatTemplate Optional Jinja chat template override (Llama backend
21
+ * only). Falls back to the model's bundled template.
22
+ * @param gpuLayers Llama backend: number of transformer layers to
23
+ * offload to GPU (Vulkan / OpenCL on supported
24
+ * devices). 99 = all layers, 0 = CPU only. Ignored
25
+ * by other backends.
26
+ * @param contextSize Context window in tokens. Defaults to 2048.
27
+ * @param threads CPU thread count for the inference loop. Llama
28
+ * uses this directly; LiteRT/MediaPipe pick their
29
+ * own threading by default.
30
+ * @param embeddingMode Llama backend: open the model in
31
+ * embedding-extraction mode rather than completion
32
+ * mode. Mutually exclusive with chat completion.
33
+ * @param visionEnabled MediaPipe backend: open the LiteRT-LM EngineConfig
34
+ * with `visionBackend` enabled.
35
+ * @param temperature LiteRT backend: sampling temperature (0 = greedy).
36
+ * @param topP LiteRT backend: nucleus sampling cutoff (1 = disabled).
37
+ * @param topK LiteRT backend: top-K truncation (0 = disabled).
38
+ * @param maxNewTokens LiteRT backend: hard cap on tokens generated per request.
39
+ * @param httpBasePort First port the HTTP server tries to bind. Defaults
40
+ * to 38883 (matches the rest of the dvai-bridge family).
41
+ * @param httpMaxPortAttempts Number of consecutive ports to try before giving
42
+ * up. Defaults to 16.
43
+ * @param corsOrigin CORS allow-origin policy. See [CorsConfig].
44
+ * Defaults to wildcard.
45
+ * @param modelId Optional override for the model id surfaced via
46
+ * `/v1/models`. Defaults to the file name minus
47
+ * extension when null.
48
+ */
49
+ data class StartOptions(
50
+ val backend: BackendKind = BackendKind.Auto,
51
+ val modelPath: String? = null,
52
+ val tokenizerPath: String? = null,
53
+ val mmprojPath: String? = null,
54
+ val chatTemplate: String? = null,
55
+ val gpuLayers: Int = 99,
56
+ val contextSize: Int = 2048,
57
+ val threads: Int = 4,
58
+ val embeddingMode: Boolean = false,
59
+ val visionEnabled: Boolean = false,
60
+ val temperature: Float = 0f,
61
+ val topP: Float = 1f,
62
+ val topK: Int = 0,
63
+ val maxNewTokens: Int = 512,
64
+ val httpBasePort: Int = 38883,
65
+ val httpMaxPortAttempts: Int = 16,
66
+ val corsOrigin: CorsConfig = CorsConfig.Wildcard,
67
+ val modelId: String? = null,
68
+ /**
69
+ * Phase 3 — opt-in distributed inference / device offload. When
70
+ * `enabled = true`, [DVAIBridge] spins up an [NsdDiscovery] +
71
+ * [NsdAdvertiser], a [CapabilityCache], and a [PairingPolicy]
72
+ * whose `requests` Flow is exposed via `DVAIBridge.pairingRequests`
73
+ * for the host UI. Default null = behave exactly like v2.x.
74
+ */
75
+ val offload: OffloadConfig? = null,
76
+ /**
77
+ * v3.3 — offline JWT license validator config. When non-null, the
78
+ * SDK loads the JWT from this filesystem path and verifies it at
79
+ * startup. Auto-discovery (assets/, res/raw/, filesDir/) runs when
80
+ * BOTH this AND [licenseToken] are null.
81
+ *
82
+ * Production Android builds without a valid license throw
83
+ * [co.deepvoiceai.bridge.license.LicenseRequiredError] from
84
+ * [DVAIBridge.start]. Debug builds (`hostBuildConfigDebug = true`
85
+ * or `ApplicationInfo.FLAG_DEBUGGABLE`) skip validation entirely.
86
+ */
87
+ val licenseKeyPath: String? = null,
88
+ /**
89
+ * v3.3 — inline JWT license token. Overrides every other discovery
90
+ * source. Useful for CI / test contexts where reading a file isn't
91
+ * practical and operators inject via env var or build config.
92
+ */
93
+ val licenseToken: String? = null,
94
+ /**
95
+ * v3.3 — the host app's `BuildConfig.DEBUG` value, passed through to
96
+ * the license validator's dev-mode bypass. When true the validator
97
+ * returns `FreeDev` without trying to verify anything; when false (or
98
+ * null) the validator falls back to `ApplicationInfo.FLAG_DEBUGGABLE`.
99
+ *
100
+ * Pass `BuildConfig.DEBUG` from your app module here — the validator
101
+ * lives in this library module whose own `BuildConfig.DEBUG` never
102
+ * reflects the host app's state.
103
+ */
104
+ val hostBuildConfigDebug: Boolean? = null,
105
+ )
106
+
107
+ /** Options for [DVAIBridge.downloadModel]. */
108
+ data class DownloadOptions(
109
+ val url: String,
110
+ val sha256: String,
111
+ val destFilename: String,
112
+ )
113
+
114
+ /** Result of a successful [DVAIBridge.downloadModel] call. */
115
+ data class DownloadResult(
116
+ val path: String,
117
+ val sha256: String,
118
+ val sizeBytes: Long,
119
+ )
@@ -1,134 +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
- }
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
+ }