@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,86 @@
1
+ package co.deepvoiceai.bridge
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertNotNull
5
+ import org.junit.Assert.assertTrue
6
+ import org.junit.Test
7
+ import kotlin.reflect.full.functions
8
+ import kotlin.reflect.full.memberProperties
9
+
10
+ /**
11
+ * Reflection-based smoke checks on the public DVAIBridge surface. These
12
+ * don't actually invoke the bridge — they just assert the 8 expected
13
+ * public methods + properties exist with the right shape.
14
+ *
15
+ * The point: prevent accidental rename / removal of public API across
16
+ * cross-platform parity refactors. Compare against
17
+ * `packages/dvai-bridge-ios/ios/Sources/DVAIBridge/DVAIBridge.swift`.
18
+ */
19
+ class DVAIBridgeAPIShapeTest {
20
+ @Test
21
+ fun `singleton is the DVAIBridge object`() {
22
+ val instance = DVAIBridge
23
+ assertNotNull(instance)
24
+ }
25
+
26
+ @Test
27
+ fun `public methods include start stop status init downloadModel listeners`() {
28
+ val fnNames = DVAIBridge::class.functions.map { it.name }.toSet()
29
+ for (expected in listOf(
30
+ "init",
31
+ "start",
32
+ "stop",
33
+ "status",
34
+ "downloadModel",
35
+ "addProgressListener",
36
+ "removeProgressListener",
37
+ )) {
38
+ assertTrue("missing method: $expected (had: $fnNames)", expected in fnNames)
39
+ }
40
+ }
41
+
42
+ @Test
43
+ fun `public properties include progressFlow and reactive`() {
44
+ val propNames = DVAIBridge::class.memberProperties.map { it.name }.toSet()
45
+ for (expected in listOf("progressFlow", "reactive")) {
46
+ assertTrue("missing property: $expected (had: $propNames)", expected in propNames)
47
+ }
48
+ }
49
+
50
+ @Test
51
+ fun `BackendKind has the expected 4 cases`() {
52
+ // Cross-platform parity: same 4 cases as the iOS Android-relevant
53
+ // subset (iOS adds Foundation / CoreML / MLX which don't exist on
54
+ // Android; Android's MediaPipe / LiteRT don't exist on iOS).
55
+ assertEquals(4, BackendKind.values().size)
56
+ }
57
+
58
+ @Test
59
+ fun `StartOptions defaults match iOS DVAIBridgeConfig defaults`() {
60
+ val opts = StartOptions()
61
+ assertEquals(BackendKind.Auto, opts.backend)
62
+ assertEquals(2048, opts.contextSize)
63
+ assertEquals(4, opts.threads)
64
+ assertEquals(38883, opts.httpBasePort)
65
+ assertEquals(16, opts.httpMaxPortAttempts)
66
+ assertEquals(99, opts.gpuLayers)
67
+ assertEquals(512, opts.maxNewTokens)
68
+ }
69
+
70
+ @Test
71
+ fun `DVAIBridgeError sealed class has expected cases`() {
72
+ // Sanity-check error case names — consumer try-catch blocks should
73
+ // be able to pattern-match on each of these.
74
+ val expected = setOf(
75
+ "AlreadyStarted",
76
+ "ConfigurationInvalid",
77
+ "ModelLoadFailed",
78
+ "BackendUnavailable",
79
+ "BackendError",
80
+ "ChecksumMismatch",
81
+ "DownloadFailed",
82
+ )
83
+ val actual = DVAIBridgeError::class.sealedSubclasses.map { it.simpleName }.toSet()
84
+ assertEquals(expected, actual)
85
+ }
86
+ }
@@ -0,0 +1,144 @@
1
+ package co.deepvoiceai.bridge
2
+
3
+ import co.deepvoiceai.bridge.shared.core.discovery.Peer
4
+ import co.deepvoiceai.bridge.shared.core.discovery.PeerSource
5
+ import co.deepvoiceai.bridge.shared.core.offload.OffloadConfig
6
+ import org.junit.Assert.assertEquals
7
+ import org.junit.Assert.assertNotNull
8
+ import org.junit.Assert.assertNull
9
+ import org.junit.Assert.assertTrue
10
+ import org.junit.Test
11
+ import org.junit.runner.RunWith
12
+ import org.robolectric.RobolectricTestRunner
13
+ import org.robolectric.annotation.Config
14
+
15
+ /**
16
+ * v3.2 — pre-routing decision logic.
17
+ *
18
+ * Mirrors `packages/dvai-bridge-core/src/__tests__/offload-decide.test.ts`
19
+ * for the canonical TS [decide] function. This test exercises the
20
+ * Kotlin port living inside [OffloadProxy] (private; exposed as
21
+ * `internal` for testing).
22
+ *
23
+ * Doesn't bind a server — only constructs an OffloadProxy and calls
24
+ * the route-decision function with synthetic peers. No HTTP, no
25
+ * Ktor lifecycle, fast.
26
+ */
27
+ @RunWith(RobolectricTestRunner::class)
28
+ @Config(sdk = [34])
29
+ class OffloadProxyDecisionTest {
30
+
31
+ private fun makeProxy(
32
+ backendBaseUrl: String? = "http://127.0.0.1:38983",
33
+ offloadEnabled: Boolean = true,
34
+ minLocalCapability: Double = 10.0,
35
+ ) = OffloadProxy(
36
+ backendBaseUrl = backendBaseUrl,
37
+ offloadConfig = OffloadConfig(
38
+ enabled = offloadEnabled,
39
+ minLocalCapability = minLocalCapability,
40
+ ),
41
+ pairingPolicy = null,
42
+ discovery = null,
43
+ appId = "test.app",
44
+ selfDeviceId = "test-self-device",
45
+ )
46
+
47
+ private fun peer(
48
+ deviceId: String,
49
+ modelScore: Map<String, Double>,
50
+ loadedModels: List<String> = emptyList(),
51
+ ) = Peer(
52
+ deviceId = deviceId,
53
+ deviceName = "$deviceId-name",
54
+ dvaiVersion = "3.2.0",
55
+ baseUrl = "http://10.0.0.1:38883",
56
+ loadedModels = loadedModels,
57
+ capability = modelScore,
58
+ via = PeerSource.MDNS,
59
+ )
60
+
61
+ /* ------------------------------------------------------------------ */
62
+ /* pickBestPeer */
63
+ /* ------------------------------------------------------------------ */
64
+
65
+ @Test
66
+ fun `pickBestPeer returns null when no peers`() {
67
+ val proxy = makeProxy()
68
+ assertNull(proxy.pickBestPeer(emptyList(), "model-a"))
69
+ }
70
+
71
+ @Test
72
+ fun `pickBestPeer prefers higher score`() {
73
+ val proxy = makeProxy()
74
+ val a = peer("a", mapOf("model-a" to 5.0))
75
+ val b = peer("b", mapOf("model-a" to 30.0))
76
+ val c = peer("c", mapOf("model-a" to 12.0))
77
+ val best = proxy.pickBestPeer(listOf(a, b, c), "model-a")
78
+ assertEquals("b", best?.peer?.deviceId)
79
+ }
80
+
81
+ @Test
82
+ fun `pickBestPeer prefers peer with model already loaded`() {
83
+ val proxy = makeProxy()
84
+ val notLoaded = peer("a", mapOf("model-a" to 30.0))
85
+ val loaded = peer("b", mapOf("model-a" to 20.0), loadedModels = listOf("model-a"))
86
+ val best = proxy.pickBestPeer(listOf(notLoaded, loaded), "model-a")
87
+ assertEquals("b", best?.peer?.deviceId)
88
+ assertTrue(best!!.hasModel)
89
+ }
90
+
91
+ @Test
92
+ fun `pickBestPeer skips peers with zero score for the model`() {
93
+ val proxy = makeProxy()
94
+ val none = peer("a", mapOf("other" to 100.0))
95
+ val zero = peer("b", mapOf("model-a" to 0.0))
96
+ assertNull(proxy.pickBestPeer(listOf(none, zero), "model-a"))
97
+ }
98
+
99
+ /* ------------------------------------------------------------------ */
100
+ /* decideRoute */
101
+ /* ------------------------------------------------------------------ */
102
+
103
+ private val emptyHeaders = emptyMap<String, String>()
104
+ private fun headers(vararg pairs: Pair<String, String>) = pairs.toMap()
105
+
106
+ @Test
107
+ fun `decideRoute non-chat-completion path goes Local`() {
108
+ val proxy = makeProxy()
109
+ val decision = proxy.decideRoute("/v1/embeddings", ByteArray(0), emptyHeaders)
110
+ assertTrue(
111
+ "expected Local, got $decision",
112
+ decision is OffloadProxy.RouteDecision.Local,
113
+ )
114
+ }
115
+
116
+ @Test
117
+ fun `decideRoute non-chat-completion with no backend gives NoCapableDevice`() {
118
+ val proxy = makeProxy(backendBaseUrl = null)
119
+ val decision = proxy.decideRoute("/v1/embeddings", ByteArray(0), emptyHeaders)
120
+ assertTrue(decision is OffloadProxy.RouteDecision.NoCapableDevice)
121
+ }
122
+
123
+ @Test
124
+ fun `decideRoute chat-completion + offload disabled goes Local`() {
125
+ val proxy = makeProxy(offloadEnabled = false)
126
+ val decision = proxy.decideRoute(
127
+ "/v1/chat/completions",
128
+ """{"model":"m","stream":false}""".toByteArray(),
129
+ emptyHeaders,
130
+ )
131
+ assertTrue(decision is OffloadProxy.RouteDecision.Local)
132
+ }
133
+
134
+ @Test
135
+ fun `decideRoute X-DVAI-Offload never forces local even with peer available`() {
136
+ val proxy = makeProxy()
137
+ val decision = proxy.decideRoute(
138
+ "/v1/chat/completions",
139
+ """{"model":"m"}""".toByteArray(),
140
+ headers("x-dvai-offload" to "never"),
141
+ )
142
+ assertTrue(decision is OffloadProxy.RouteDecision.Local)
143
+ }
144
+ }
@@ -0,0 +1,327 @@
1
+ package co.deepvoiceai.bridge
2
+
3
+ import co.deepvoiceai.bridge.shared.core.discovery.Peer
4
+ import co.deepvoiceai.bridge.shared.core.discovery.PeerSource
5
+ import co.deepvoiceai.bridge.shared.core.offload.OffloadConfig
6
+ import co.deepvoiceai.bridge.shared.core.pairing.Pairing
7
+ import co.deepvoiceai.bridge.shared.core.pairing.PairingPolicy
8
+ import co.deepvoiceai.bridge.shared.core.pairing.PairingSource
9
+ import co.deepvoiceai.bridge.shared.core.pairing.PairingStore
10
+ import okhttp3.MediaType.Companion.toMediaType
11
+ import okhttp3.OkHttpClient
12
+ import okhttp3.Request
13
+ import okhttp3.RequestBody.Companion.toRequestBody
14
+ import okhttp3.mockwebserver.MockResponse
15
+ import okhttp3.mockwebserver.MockWebServer
16
+ import org.junit.After
17
+ import org.junit.Assert.assertEquals
18
+ import org.junit.Assert.assertNotNull
19
+ import org.junit.Assert.assertNull
20
+ import org.junit.Assert.assertTrue
21
+ import org.junit.Before
22
+ import org.junit.Test
23
+ import org.junit.runner.RunWith
24
+ import org.robolectric.RobolectricTestRunner
25
+ import org.robolectric.RuntimeEnvironment
26
+ import org.robolectric.annotation.Config
27
+ import java.util.concurrent.TimeUnit
28
+
29
+ /**
30
+ * v3.2 — full-loop integration test for [OffloadProxy].
31
+ *
32
+ * Spins up two [MockWebServer] instances inside the JVM:
33
+ *
34
+ * - `backend` — stands in for the native backend's loopback HTTP
35
+ * server. Always replies with a JSON body containing
36
+ * `{"served_by":"backend"}` so tests can assert "did the proxy
37
+ * forward to here".
38
+ *
39
+ * - `peer` — stands in for a remote peer that the proxy might
40
+ * forward to. Replies with `{"served_by":"peer"}`.
41
+ *
42
+ * The OffloadProxy is constructed with the peerProvider lambda
43
+ * pointing at a fixed `Peer` whose baseUrl is `peer`'s URL. Tests
44
+ * make a real OkHttp request to the proxy's bound port and then
45
+ * inspect:
46
+ *
47
+ * - which MockWebServer received a request (`takeRequest()` blocks
48
+ * for up to 1s)
49
+ * - the response body so we can tell which path executed
50
+ * - the request headers received by the peer (HMAC signature
51
+ * headers should be present on offload-routed requests)
52
+ */
53
+ @RunWith(RobolectricTestRunner::class)
54
+ @Config(sdk = [34])
55
+ class OffloadProxyForwardingTest {
56
+
57
+ private lateinit var backend: MockWebServer
58
+ private lateinit var peer: MockWebServer
59
+ private val client = OkHttpClient.Builder()
60
+ .callTimeout(5, TimeUnit.SECONDS)
61
+ .build()
62
+
63
+ // Base64-url-encoded 256-bit pairing key (matches the v3.2.1 wire
64
+ // format — `OffloadProxy.signCanonical` decodes the key as
65
+ // base64-url before HMAC-keying, mirroring the TS Hub's
66
+ // `verifyHmac`). The test asserts header shape, not signature
67
+ // value, so the exact bytes don't matter — only that the string
68
+ // is decodable.
69
+ private val pairingKey = "vGzn8h_FNHkqL5Q1tN-rTu3pYWB7K0vGzn8h_FNHkqI"
70
+ private val peerDeviceId = "peer-device-id"
71
+
72
+ @Before
73
+ fun setup() {
74
+ backend = MockWebServer().apply { start() }
75
+ peer = MockWebServer().apply { start() }
76
+ }
77
+
78
+ @After
79
+ fun teardown() {
80
+ backend.shutdown()
81
+ peer.shutdown()
82
+ }
83
+
84
+ private fun makeProxy(
85
+ backendBaseUrlOverride: String? = backend.url("/").toString().trimEnd('/'),
86
+ offloadEnabled: Boolean = true,
87
+ peers: List<Peer> = emptyList(),
88
+ pairingPolicy: PairingPolicy? = null,
89
+ minLocalCapability: Double = 10.0,
90
+ ): OffloadProxy = OffloadProxy(
91
+ backendBaseUrl = backendBaseUrlOverride,
92
+ offloadConfig = OffloadConfig(
93
+ enabled = offloadEnabled,
94
+ minLocalCapability = minLocalCapability,
95
+ ),
96
+ pairingPolicy = pairingPolicy,
97
+ peerProvider = { peers },
98
+ appId = "co.test.app",
99
+ selfDeviceId = "self-device-id",
100
+ )
101
+
102
+ private fun fakePeer(
103
+ scoreForModel: Double = 30.0,
104
+ modelId: String = "test-model",
105
+ ) = Peer(
106
+ deviceId = peerDeviceId,
107
+ deviceName = "test-peer",
108
+ dvaiVersion = "3.2.0",
109
+ baseUrl = peer.url("/").toString().trimEnd('/'),
110
+ loadedModels = listOf(modelId),
111
+ capability = mapOf(modelId to scoreForModel),
112
+ via = PeerSource.MDNS,
113
+ )
114
+
115
+ /* ------------------------------------------------------------------ */
116
+ /* Local-only path */
117
+ /* ------------------------------------------------------------------ */
118
+
119
+ @Test
120
+ fun `chat-completion forwards to backend when no peers + offload disabled`() {
121
+ backend.enqueue(
122
+ MockResponse()
123
+ .setResponseCode(200)
124
+ .setHeader("Content-Type", "application/json")
125
+ .setBody("""{"served_by":"backend"}"""),
126
+ )
127
+
128
+ val proxy = makeProxy(offloadEnabled = false)
129
+ val proxyPort = proxy.start(basePort = freePort(), maxAttempts = 8)
130
+ try {
131
+ val resp = postJson("http://127.0.0.1:$proxyPort/v1/chat/completions", """{"model":"m"}""")
132
+ assertEquals(200, resp.code)
133
+ assertTrue(resp.body!!.contains(""""served_by":"backend""""))
134
+ } finally {
135
+ proxy.stop()
136
+ }
137
+
138
+ val recorded = backend.takeRequest(1, TimeUnit.SECONDS)
139
+ assertNotNull("backend should have received exactly one request", recorded)
140
+ assertEquals("/v1/chat/completions", recorded!!.path)
141
+
142
+ // No request should hit the peer.
143
+ assertNull(peer.takeRequest(100, TimeUnit.MILLISECONDS))
144
+ }
145
+
146
+ /* ------------------------------------------------------------------ */
147
+ /* Offload path — `prefer` (default) routes to peer when score high */
148
+ /* ------------------------------------------------------------------ */
149
+
150
+ @Test
151
+ fun `chat-completion forwards to peer when offload prefer + peer score above threshold`() {
152
+ peer.enqueue(
153
+ MockResponse()
154
+ .setResponseCode(200)
155
+ .setHeader("Content-Type", "application/json")
156
+ .setBody("""{"served_by":"peer"}"""),
157
+ )
158
+
159
+ val proxy = makeProxy(
160
+ peers = listOf(fakePeer(scoreForModel = 50.0)),
161
+ pairingPolicy = pairingPolicyWithKey(),
162
+ minLocalCapability = 10.0,
163
+ )
164
+ val proxyPort = proxy.start(basePort = freePort(), maxAttempts = 8)
165
+ try {
166
+ val resp = postJson(
167
+ "http://127.0.0.1:$proxyPort/v1/chat/completions",
168
+ """{"model":"test-model"}""",
169
+ )
170
+ assertEquals(200, resp.code)
171
+ assertTrue(resp.body!!.contains(""""served_by":"peer""""))
172
+ } finally {
173
+ proxy.stop()
174
+ }
175
+
176
+ // Peer received it; backend did not.
177
+ val peerReq = peer.takeRequest(1, TimeUnit.SECONDS)
178
+ assertNotNull("peer should have received the offloaded request", peerReq)
179
+ assertEquals("/v1/chat/completions", peerReq!!.path)
180
+
181
+ // Identity headers must be present on peer-bound forwards.
182
+ assertEquals("self-device-id", peerReq.getHeader("X-DVAI-Peer-Device-Id"))
183
+ assertEquals("co.test.app", peerReq.getHeader("X-DVAI-App-Id"))
184
+ assertNotNull(peerReq.getHeader("X-DVAI-Nonce"))
185
+ assertNotNull(peerReq.getHeader("X-DVAI-Signature"))
186
+ assertEquals("1", peerReq.getHeader("X-DVAI-Forwarded"))
187
+
188
+ assertNull(backend.takeRequest(100, TimeUnit.MILLISECONDS))
189
+ }
190
+
191
+ /* ------------------------------------------------------------------ */
192
+ /* `never` header forces local even with peers */
193
+ /* ------------------------------------------------------------------ */
194
+
195
+ @Test
196
+ fun `X-DVAI-Offload never forces local even with strong peer`() {
197
+ backend.enqueue(
198
+ MockResponse()
199
+ .setResponseCode(200)
200
+ .setHeader("Content-Type", "application/json")
201
+ .setBody("""{"served_by":"backend"}"""),
202
+ )
203
+
204
+ val proxy = makeProxy(
205
+ peers = listOf(fakePeer(scoreForModel = 100.0)),
206
+ pairingPolicy = pairingPolicyWithKey(),
207
+ )
208
+ val proxyPort = proxy.start(basePort = freePort(), maxAttempts = 8)
209
+ try {
210
+ val resp = postJson(
211
+ "http://127.0.0.1:$proxyPort/v1/chat/completions",
212
+ """{"model":"test-model"}""",
213
+ extraHeaders = mapOf("X-DVAI-Offload" to "never"),
214
+ )
215
+ assertEquals(200, resp.code)
216
+ assertTrue(resp.body!!.contains(""""served_by":"backend""""))
217
+ } finally {
218
+ proxy.stop()
219
+ }
220
+
221
+ assertNotNull(backend.takeRequest(1, TimeUnit.SECONDS))
222
+ assertNull(peer.takeRequest(100, TimeUnit.MILLISECONDS))
223
+ }
224
+
225
+ /* ------------------------------------------------------------------ */
226
+ /* `require` header without peers → 503 */
227
+ /* ------------------------------------------------------------------ */
228
+
229
+ @Test
230
+ fun `X-DVAI-Offload require with no peers returns no_capable_device`() {
231
+ val proxy = makeProxy(peers = emptyList())
232
+ val proxyPort = proxy.start(basePort = freePort(), maxAttempts = 8)
233
+ try {
234
+ val resp = postJson(
235
+ "http://127.0.0.1:$proxyPort/v1/chat/completions",
236
+ """{"model":"test-model"}""",
237
+ extraHeaders = mapOf("X-DVAI-Offload" to "require"),
238
+ )
239
+ assertEquals(503, resp.code)
240
+ assertTrue(resp.body!!.contains("no_capable_device"))
241
+ } finally {
242
+ proxy.stop()
243
+ }
244
+
245
+ assertNull(backend.takeRequest(100, TimeUnit.MILLISECONDS))
246
+ assertNull(peer.takeRequest(100, TimeUnit.MILLISECONDS))
247
+ }
248
+
249
+ /* ------------------------------------------------------------------ */
250
+ /* Offload-only mode (no backend) + no peers → 503 */
251
+ /* ------------------------------------------------------------------ */
252
+
253
+ @Test
254
+ fun `offload-only mode with no peers returns no_local_backend`() {
255
+ val proxy = makeProxy(
256
+ backendBaseUrlOverride = null,
257
+ peers = emptyList(),
258
+ )
259
+ val proxyPort = proxy.start(basePort = freePort(), maxAttempts = 8)
260
+ try {
261
+ val resp = postJson(
262
+ "http://127.0.0.1:$proxyPort/v1/chat/completions",
263
+ """{"model":"test-model"}""",
264
+ )
265
+ assertEquals(503, resp.code)
266
+ // either no_local_backend (no offload) or no_capable_device (offload but no peers)
267
+ assertTrue(
268
+ "expected 503 with structured error, got: ${resp.body}",
269
+ resp.body!!.contains("no_local_backend") || resp.body.contains("no_capable_device"),
270
+ )
271
+ } finally {
272
+ proxy.stop()
273
+ }
274
+ }
275
+
276
+ /* ------------------------------------------------------------------ */
277
+ /* Helpers */
278
+ /* ------------------------------------------------------------------ */
279
+
280
+ private data class HttpResponseSnapshot(val code: Int, val body: String?)
281
+
282
+ /** Ask the OS for a free port, then close the socket so the proxy
283
+ * can bind it on the next syscall. There's a tiny race window
284
+ * but it's acceptable for unit tests. */
285
+ private fun freePort(): Int {
286
+ val socket = java.net.ServerSocket(0)
287
+ val port = socket.localPort
288
+ socket.close()
289
+ return port
290
+ }
291
+
292
+ private fun postJson(
293
+ url: String,
294
+ body: String,
295
+ extraHeaders: Map<String, String> = emptyMap(),
296
+ ): HttpResponseSnapshot {
297
+ val req = Request.Builder()
298
+ .url(url)
299
+ .post(body.toRequestBody("application/json".toMediaType()))
300
+ .apply { for ((k, v) in extraHeaders) header(k, v) }
301
+ .build()
302
+ return client.newCall(req).execute().use {
303
+ HttpResponseSnapshot(it.code, it.body?.string())
304
+ }
305
+ }
306
+
307
+ private fun pairingPolicyWithKey(): PairingPolicy {
308
+ // PairingStore writes to context.cacheDir/dvai-bridge — Robolectric
309
+ // provides a real cache dir. Pre-seed a pairing for our fake peer
310
+ // so the proxy's HMAC sign step finds a key.
311
+ val ctx = RuntimeEnvironment.getApplication()
312
+ val store = PairingStore(ctx)
313
+ store.clear()
314
+ store.set(
315
+ Pairing(
316
+ peerDeviceId = peerDeviceId,
317
+ peerDeviceName = "test-peer",
318
+ pairingKey = pairingKey,
319
+ pairedAt = System.currentTimeMillis(),
320
+ lastUsedAt = System.currentTimeMillis(),
321
+ via = PairingSource.LAN_HANDSHAKE,
322
+ ),
323
+ )
324
+ return PairingPolicy(store)
325
+ }
326
+
327
+ }
@@ -0,0 +1,56 @@
1
+ package co.deepvoiceai.bridge
2
+
3
+ import kotlinx.coroutines.flow.first
4
+ import kotlinx.coroutines.flow.take
5
+ import kotlinx.coroutines.flow.toList
6
+ import kotlinx.coroutines.test.runTest
7
+ import org.junit.Assert.assertEquals
8
+ import org.junit.Assert.assertTrue
9
+ import org.junit.Test
10
+
11
+ class ProgressBroadcasterTest {
12
+ @Test
13
+ fun `flow and listener both receive emitted events`() = runTest {
14
+ val b = ProgressBroadcaster()
15
+ val captured = mutableListOf<ProgressEvent>()
16
+ b.addListener { captured.add(it) }
17
+
18
+ // Emit before any flow subscriber — the SharedFlow's replay buffer
19
+ // should make the most-recent event visible to a late subscriber.
20
+ b.emit(ProgressEvent.Started("phase-1"))
21
+
22
+ val replayed = b.flow.first()
23
+ assertTrue("late subscriber should see replay", replayed is ProgressEvent.Started)
24
+
25
+ b.emit(ProgressEvent.Progress("phase-1", percent = 0.5f, message = "halfway"))
26
+ b.emit(ProgressEvent.Completed("phase-1"))
27
+
28
+ // Listener saw all 3 events synchronously on the emit thread.
29
+ assertEquals(3, captured.size)
30
+ assertTrue(captured[0] is ProgressEvent.Started)
31
+ assertTrue(captured[1] is ProgressEvent.Progress)
32
+ assertTrue(captured[2] is ProgressEvent.Completed)
33
+ }
34
+
35
+ @Test
36
+ fun `removed listener stops receiving events`() {
37
+ val b = ProgressBroadcaster()
38
+ val captured = mutableListOf<ProgressEvent>()
39
+ val listener = ProgressListener { captured.add(it) }
40
+ b.addListener(listener)
41
+ b.emit(ProgressEvent.Started("p"))
42
+ b.removeListener(listener)
43
+ b.emit(ProgressEvent.Completed("p"))
44
+ assertEquals(1, captured.size)
45
+ }
46
+
47
+ @Test
48
+ fun `listener exception does not block other listeners`() {
49
+ val b = ProgressBroadcaster()
50
+ val good = mutableListOf<ProgressEvent>()
51
+ b.addListener { throw RuntimeException("misbehaving listener") }
52
+ b.addListener { good.add(it) }
53
+ b.emit(ProgressEvent.Started("p"))
54
+ assertEquals(1, good.size)
55
+ }
56
+ }