@mentra/acs-meeting 3.2.0-dev.221 → 3.2.0-dev.222

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 (100) hide show
  1. package/README.md +4 -2
  2. package/android/build.gradle +23 -0
  3. package/android/src/androidTest/java/com/mentra/acsmeeting/network/SoftApFeasibilityGateTest.kt +291 -0
  4. package/android/src/main/AndroidManifest.xml +2 -0
  5. package/android/src/main/java/com/mentra/acsmeeting/AbandonedCallAgent.kt +36 -0
  6. package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingModule.kt +392 -22
  7. package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingSession.kt +909 -56
  8. package/android/src/main/java/com/mentra/acsmeeting/CallCapabilities.kt +33 -0
  9. package/android/src/main/java/com/mentra/acsmeeting/audio/AcsAudioPolicy.kt +21 -0
  10. package/android/src/main/java/com/mentra/acsmeeting/audio/AudioUplinkChain.kt +126 -0
  11. package/android/src/main/java/com/mentra/acsmeeting/audio/PcmBridge.kt +27 -4
  12. package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkPacer.kt +45 -10
  13. package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkSender.kt +110 -5
  14. package/android/src/main/java/com/mentra/acsmeeting/network/InternetHold.kt +501 -0
  15. package/android/src/main/java/com/mentra/acsmeeting/network/Ipv4Prefix.kt +51 -0
  16. package/android/src/main/java/com/mentra/acsmeeting/network/NetworkFacts.kt +81 -0
  17. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetector.kt +355 -0
  18. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkError.kt +109 -0
  19. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkObserver.kt +160 -0
  20. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkReadiness.kt +103 -0
  21. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpec.kt +53 -0
  22. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkState.kt +127 -0
  23. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedSoftApNetwork.kt +573 -0
  24. package/android/src/main/java/com/mentra/acsmeeting/source/CloudflareWhepSource.kt +10 -101
  25. package/android/src/main/java/com/mentra/acsmeeting/source/DecodedTrackRelay.kt +147 -0
  26. package/android/src/main/java/com/mentra/acsmeeting/source/GlassesMediaSource.kt +47 -5
  27. package/android/src/main/java/com/mentra/acsmeeting/source/LocalWhipIngestSource.kt +772 -0
  28. package/android/src/main/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpec.kt +73 -0
  29. package/android/src/main/java/com/mentra/acsmeeting/source/SdpAdapter.kt +17 -0
  30. package/android/src/main/java/com/mentra/acsmeeting/source/SelectedIcePair.kt +139 -0
  31. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIceDiagnosis.kt +144 -0
  32. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIcePolicy.kt +46 -0
  33. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApSdpGuard.kt +160 -0
  34. package/android/src/main/java/com/mentra/acsmeeting/source/TrackRegistry.kt +3 -1
  35. package/android/src/main/java/com/mentra/acsmeeting/source/VideoSourceArm.kt +69 -0
  36. package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestProtocol.kt +207 -0
  37. package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestServer.kt +356 -0
  38. package/android/src/main/java/com/mentra/acsmeeting/telemetry/AvSyncProbe.kt +155 -0
  39. package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineStats.kt +94 -3
  40. package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineTicker.kt +33 -5
  41. package/android/src/main/java/com/mentra/acsmeeting/telemetry/RingPercentile.kt +15 -0
  42. package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoQuality.kt +178 -0
  43. package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoRateVerdict.kt +145 -0
  44. package/android/src/main/java/com/mentra/acsmeeting/trace/SoftApTrace.kt +143 -0
  45. package/android/src/main/java/com/mentra/acsmeeting/video/AcsFrameSender.kt +23 -4
  46. package/android/src/main/java/com/mentra/acsmeeting/video/FramePacer.kt +70 -0
  47. package/android/src/main/java/com/mentra/acsmeeting/video/I420FormatSpec.kt +1 -1
  48. package/android/src/main/java/com/mentra/acsmeeting/video/VideoProfile.kt +36 -3
  49. package/android/src/test/java/com/mentra/acsmeeting/AbandonedCallAgentTest.kt +74 -0
  50. package/android/src/test/java/com/mentra/acsmeeting/CallCapabilitiesTest.kt +47 -0
  51. package/android/src/test/java/com/mentra/acsmeeting/audio/AudioUplinkChainTest.kt +250 -0
  52. package/android/src/test/java/com/mentra/acsmeeting/audio/CapturePolicyTest.kt +44 -0
  53. package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkPacerTest.kt +34 -4
  54. package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkSenderTest.kt +96 -0
  55. package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldLifecycleTest.kt +102 -0
  56. package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldTest.kt +148 -0
  57. package/android/src/test/java/com/mentra/acsmeeting/network/Ipv4PrefixTest.kt +59 -0
  58. package/android/src/test/java/com/mentra/acsmeeting/network/NetworkFactsTest.kt +59 -0
  59. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedLocalNetworkPermissionTest.kt +35 -0
  60. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetectorTest.kt +165 -0
  61. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkErrorTest.kt +96 -0
  62. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkLifecycleTest.kt +187 -0
  63. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkObserverTest.kt +172 -0
  64. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkReadinessTest.kt +141 -0
  65. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpecTest.kt +69 -0
  66. package/android/src/test/java/com/mentra/acsmeeting/source/AcsInvestigationTest.kt +40 -0
  67. package/android/src/test/java/com/mentra/acsmeeting/source/DecodedTrackRelayTest.kt +41 -0
  68. package/android/src/test/java/com/mentra/acsmeeting/source/GlassesMediaSourceTest.kt +26 -8
  69. package/android/src/test/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpecTest.kt +109 -0
  70. package/android/src/test/java/com/mentra/acsmeeting/source/SelectedIcePairTest.kt +165 -0
  71. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIceDiagnosisTest.kt +148 -0
  72. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIcePolicyTest.kt +76 -0
  73. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApSdpGuardTest.kt +275 -0
  74. package/android/src/test/java/com/mentra/acsmeeting/source/TrackRegistryTest.kt +9 -0
  75. package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestProtocolTest.kt +356 -0
  76. package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestServerLoopbackTest.kt +411 -0
  77. package/android/src/test/java/com/mentra/acsmeeting/telemetry/AvSyncProbeTest.kt +83 -0
  78. package/android/src/test/java/com/mentra/acsmeeting/telemetry/PipelineStatsTest.kt +69 -3
  79. package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoQualityTest.kt +182 -0
  80. package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoRateVerdictTest.kt +149 -0
  81. package/android/src/test/java/com/mentra/acsmeeting/trace/SoftApTraceTest.kt +97 -0
  82. package/android/src/test/java/com/mentra/acsmeeting/video/AcsTimestampTest.kt +34 -0
  83. package/android/src/test/java/com/mentra/acsmeeting/video/FramePacerTest.kt +105 -0
  84. package/android/src/test/java/com/mentra/acsmeeting/video/I420FormatSpecTest.kt +5 -0
  85. package/android/src/test/java/com/mentra/acsmeeting/video/VideoProfileTest.kt +38 -3
  86. package/build/AcsMeeting.types.d.ts +5 -0
  87. package/build/AcsMeeting.types.d.ts.map +1 -1
  88. package/build/AcsMeeting.types.js.map +1 -1
  89. package/build/AcsMeetingModule.d.ts +23 -0
  90. package/build/AcsMeetingModule.d.ts.map +1 -1
  91. package/build/AcsMeetingModule.js.map +1 -1
  92. package/build/AcsMeetingModule.web.d.ts +15 -0
  93. package/build/AcsMeetingModule.web.d.ts.map +1 -1
  94. package/build/AcsMeetingModule.web.js +13 -0
  95. package/build/AcsMeetingModule.web.js.map +1 -1
  96. package/ios/AcsMeetingModule.swift +213 -2
  97. package/package.json +1 -1
  98. package/src/AcsMeeting.types.ts +5 -0
  99. package/src/AcsMeetingModule.ts +13 -0
  100. package/src/AcsMeetingModule.web.ts +13 -0
@@ -1,5 +1,16 @@
1
1
  package com.mentra.acsmeeting
2
2
 
3
+ import android.content.Context
4
+ import android.content.Intent
5
+ import android.os.Build
6
+ import android.os.SystemClock
7
+ import android.provider.Settings
8
+ import com.mentra.acsmeeting.network.InternetHold
9
+ import com.mentra.acsmeeting.network.ScopedNetworkChangeDetector
10
+ import com.mentra.acsmeeting.network.ScopedNetworkError
11
+ import com.mentra.acsmeeting.network.ScopedSoftApNetwork
12
+ import com.mentra.acsmeeting.source.MeetingVideoSourceSpec
13
+ import com.mentra.acsmeeting.trace.SoftApTrace
3
14
  import com.mentra.acsmeeting.video.VideoProfile
4
15
  import expo.modules.kotlin.modules.Module
5
16
  import expo.modules.kotlin.modules.ModuleDefinition
@@ -7,35 +18,380 @@ import expo.modules.kotlin.modules.ModuleDefinition
7
18
  class AcsMeetingModule : Module() {
8
19
  private var session: AcsMeetingSession? = null
9
20
 
21
+ /**
22
+ * Wrap one `AsyncFunction` body in an entry/exit trace.
23
+ *
24
+ * Every function here is a boundary the host awaits, and the host cannot see which side of it a
25
+ * stall is on. An entry line with no exit line names the native call that never came back, which
26
+ * is the one diagnosis the JS-side duration alone can never give.
27
+ */
28
+ private inline fun <T> traced(name: String, vararg fields: Pair<String, Any?>, body: () -> T): T {
29
+ val startedAt = SystemClock.elapsedRealtime()
30
+ SoftApTrace.stage("native_${name}_begin", *fields)
31
+ try {
32
+ val result = body()
33
+ SoftApTrace.stage("native_${name}_end", "durationMs" to (SystemClock.elapsedRealtime() - startedAt))
34
+ return result
35
+ } catch (error: Throwable) {
36
+ SoftApTrace.failure(
37
+ "native_${name}_failed",
38
+ "durationMs" to (SystemClock.elapsedRealtime() - startedAt),
39
+ "reason" to "${error.javaClass.simpleName}: ${error.message ?: ""}",
40
+ )
41
+ throw error
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Held by the module, not the session, because the orchestrator joins the hotspot *before* the
47
+ * ACS join: the ingest source has to bind to a network that already exists. One instance means
48
+ * the session's ingest source and this join agree on the same [android.net.Network].
49
+ */
50
+ private var scopedNetwork: ScopedSoftApNetwork? = null
51
+
52
+ // prepareAgent creates the session before joinScopedNetwork runs. Give it the same holder
53
+ // now, so its ingest source sees the network populated later instead of retaining null.
54
+ @Synchronized
55
+ private fun getOrCreateScopedNetwork(context: Context): ScopedSoftApNetwork =
56
+ scopedNetwork ?: ScopedSoftApNetwork(context.applicationContext).also { scopedNetwork = it }
57
+
58
+ /**
59
+ * Holds cellular up across the hotspot join, and reports what the default network became.
60
+ *
61
+ * Module-scoped for the same reason as [scopedNetwork]: the hold has to outlive the individual
62
+ * `joinScopedNetwork` call and be releasable by the same teardown that releases the scoped join.
63
+ */
64
+ private var internetHold: InternetHold? = null
65
+
66
+ /**
67
+ * Put the system Wi-Fi toggle in front of the user.
68
+ *
69
+ * SoftAP calling needs the station radio, but `WifiManager.setWifiEnabled` has been a no-op for
70
+ * non-privileged apps since Android 10, so the app cannot turn it on itself. The inline settings
71
+ * panel overlays the call UI, which keeps a one-tap recovery in the same screen instead of only
72
+ * reporting a failure the user has to go fix elsewhere.
73
+ */
74
+ private fun promptToEnableWifi() {
75
+ val intent =
76
+ Intent(
77
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) Settings.Panel.ACTION_WIFI
78
+ else Settings.ACTION_WIFI_SETTINGS,
79
+ )
80
+ val activity = appContext.currentActivity
81
+ runCatching {
82
+ if (activity != null) {
83
+ activity.startActivity(intent)
84
+ } else {
85
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
86
+ appContext.reactContext?.startActivity(intent)
87
+ }
88
+ }
89
+ }
90
+
10
91
  override fun definition() = ModuleDefinition {
11
92
  Name("MentraAcsMeeting")
12
- Events("onState", "onIncomingPcm")
93
+ // `onScopedNetworkLost` fires only for a hotspot that went away while we still wanted it: the
94
+ // scoped state machine drops the framework's `onLost` for a network we released ourselves, so a
95
+ // normal Leave or End cannot manufacture a mid-call network error.
96
+ Events("onState", "onIncomingPcm", "onScopedNetworkLost")
13
97
 
14
- AsyncFunction("join") { options: Map<String, Any?> ->
15
- val token = options["token"] as? String ?: throw IllegalArgumentException("token is required")
16
- val meetingUrl = options["meetingUrl"] as? String ?: throw IllegalArgumentException("meetingUrl is required")
17
- val whepUrl = options["whepUrl"] as? String ?: throw IllegalArgumentException("whepUrl is required")
18
- val displayName = options["displayName"] as? String
19
- val dumpWav = options["dumpPcmWav"] as? Boolean ?: false
20
- val audioSource = options["audioSource"] as? String ?: "glasses"
21
- val video = parseVideo(options["video"])
22
- val context = appContext.reactContext ?: throw IllegalStateException("no react context")
23
- val meeting = session ?: AcsMeetingSession(
24
- context.applicationContext,
25
- onState = { sendEvent("onState", it) },
26
- onIncomingPcm = { base64, rate, channels ->
27
- sendEvent(
28
- "onIncomingPcm",
29
- mapOf("base64" to base64, "sampleRate" to rate, "channels" to channels),
98
+ // Before any PeerConnectionFactory exists in this process: libwebrtc's network monitor only
99
+ // reads the detector factory when it (re)starts, and without this detector it never sees the
100
+ // internet-less hotspot network, so the phone's WHIP answer has no host candidate.
101
+ OnCreate {
102
+ ScopedNetworkChangeDetector.install { scopedNetwork }
103
+ }
104
+
105
+ AsyncFunction("beginTrace") { traceId: String ->
106
+ require(traceId.matches(Regex("[A-Za-z0-9]*"))) { "invalid trace id" }
107
+ com.mentra.acsmeeting.trace.SoftApTrace.begin(traceId)
108
+ Unit
109
+ }
110
+
111
+ /**
112
+ * Join the glasses hotspot as a scoped, internet-less network and return this phone's address
113
+ * on it. The address is what the WHIP listener binds to, so a join that produces no address is
114
+ * a failure rather than a network worth keeping.
115
+ */
116
+ AsyncFunction("joinScopedNetwork") { ssid: String, passphrase: String ->
117
+ traced("join_scoped_network", "ssid" to ssid) {
118
+ val context = appContext.reactContext ?: throw IllegalStateException("no react context")
119
+ val scoped = getOrCreateScopedNetwork(context)
120
+ // Cellular first, and validated, because the next line is what takes office Wi-Fi away. A
121
+ // phone whose cellular cannot carry TLS strands the ACS join for its whole timeout with
122
+ // device-wide DNS failures, which reads as a hotspot problem and is not one.
123
+ val hold = internetHold ?: InternetHold(context.applicationContext).also { internetHold = it }
124
+ val cellular = hold.awaitValidatedCellular()
125
+ if (!cellular.validated) {
126
+ hold.release()
127
+ throw IllegalStateException(
128
+ "SOFTAP_NO_CELLULAR_INTERNET: this phone's mobile data did not come up, so Teams would " +
129
+ "lose its connection the moment we join the glasses hotspot. Turn mobile data on and retry.",
30
130
  )
31
- },
32
- ).also { session = it }
33
- meeting.join(token, meetingUrl, whepUrl, displayName, dumpWav, audioSource, video)
34
- meeting.getState()
131
+ }
132
+ val listener = object : ScopedSoftApNetwork.Listener {
133
+ override fun onAvailable(network: android.net.Network, localIpv4: String) = Unit
134
+
135
+ override fun onLost(error: ScopedNetworkError) {
136
+ com.mentra.acsmeeting.trace.SoftApTrace.failure("scoped_network_lost_midcall", "code" to error.code)
137
+ sendEvent(
138
+ "onScopedNetworkLost",
139
+ mapOf("code" to error.code, "message" to (error.message ?: "The glasses hotspot went away")),
140
+ )
141
+ }
142
+ }
143
+ try {
144
+ scoped.join(ssid, passphrase, listener)
145
+ } catch (error: ScopedNetworkError.WifiDisabled) {
146
+ // Open the panel and *wait*. Throwing here used to tear the hotspot down in ~20ms,
147
+ // then a retry reminted ACS after the user turned Wi-Fi on — and Android often made
148
+ // that new Wi-Fi the default route with no internet, so token mint hung on DNS.
149
+ promptToEnableWifi()
150
+ com.mentra.acsmeeting.trace.SoftApTrace.stage("wifi_enable_wait", "timeoutMs" to ScopedSoftApNetwork.WIFI_ENABLE_WAIT_MS)
151
+ val enabled = scoped.awaitWifiEnabled()
152
+ com.mentra.acsmeeting.trace.SoftApTrace.stage(
153
+ "wifi_enable_wait_done",
154
+ "enabled" to enabled,
155
+ )
156
+ if (!enabled) throw error
157
+ Thread.sleep(ScopedSoftApNetwork.WIFI_ENABLE_SETTLE_MS)
158
+ scoped.join(ssid, passphrase, listener)
159
+ } catch (error: ScopedNetworkError.Unavailable) {
160
+ // The first specifier steals wlan0 from the phone's current Wi-Fi (iPhone X, office
161
+ // AP). Samsung then assoc-rejects the glasses SoftAP (status 1025) and fires
162
+ // onUnavailable. After that request dies the STA is idle — the same join from idle
163
+ // is what succeeded at 17:43:20 after a failed switch.
164
+ com.mentra.acsmeeting.trace.SoftApTrace.stage(
165
+ "scoped_join_unavailable_retry",
166
+ "ssid" to ssid,
167
+ "settleMs" to ScopedSoftApNetwork.UNAVAILABLE_RETRY_SETTLE_MS,
168
+ )
169
+ Thread.sleep(ScopedSoftApNetwork.UNAVAILABLE_RETRY_SETTLE_MS)
170
+ scoped.join(ssid, passphrase, listener)
171
+ }
172
+ scoped.localIpv4() ?: throw IllegalStateException("scoped network has no IPv4 address")
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Undo for the `scopedJoin` step. Drops only the hotspot bind.
178
+ *
179
+ * The cellular pin is a preflight resource (`prepareAgent`), not a scoped-join one. Releasing
180
+ * it here is what turned Cancel-during-hotspot into `Request failed (503)`: the AP was still
181
+ * up, Android made it the default Wi-Fi, and the next Teams create had no internet. The pin
182
+ * is dropped by [leaveAndAwait] / [leave] once a validated default route exists.
183
+ */
184
+ AsyncFunction("leaveScopedNetwork") {
185
+ traced("leave_scoped_network", "hasScopedNetwork" to (scopedNetwork != null)) {
186
+ scopedNetwork?.release()
187
+ }
188
+ }
189
+
190
+ /**
191
+ * The app's validated default network, waited for before the ACS join.
192
+ *
193
+ * Separate from the cellular hold on purpose: holding a request asks Android to bring cellular
194
+ * up, it does not say when this app's default route switches to it. Only this answers that, and
195
+ * it is the precondition for a join that has to reach the internet.
196
+ */
197
+ AsyncFunction("awaitValidatedDefaultNetwork") {
198
+ traced("await_validated_default_network") {
199
+ val context = appContext.reactContext ?: throw IllegalStateException("no react context")
200
+ val hold = internetHold ?: InternetHold(context.applicationContext).also { internetHold = it }
201
+ val network = hold.awaitValidatedDefault()
202
+ mapOf(
203
+ "transport" to network.transport,
204
+ "validated" to network.validated,
205
+ "present" to network.present,
206
+ "usable" to network.usable,
207
+ "detail" to network.toString(),
208
+ )
209
+ }
210
+ }
211
+
212
+ /**
213
+ * The joined hotspot as the framework describes it. `prefix` is the invariant the media path is
214
+ * checked against: the selected ICE candidate must sit inside it.
215
+ */
216
+ AsyncFunction("scopedNetworkInfo") {
217
+ val scoped = scopedNetwork
218
+ mapOf(
219
+ "available" to (scoped?.isAvailable() == true),
220
+ "localIpv4" to scoped?.localIpv4(),
221
+ "prefix" to scoped?.scopedPrefix()?.toString(),
222
+ )
223
+ }
224
+
225
+ // Blocking TCP probe (<= ~6s worst case); AsyncFunction runs it off the JS thread.
226
+ AsyncFunction("probeScopedGateway") {
227
+ val scoped = scopedNetwork ?: return@AsyncFunction mapOf("reachable" to false, "detail" to "no scoped network")
228
+ val verdict = scoped.probeGateway()
229
+ mapOf("reachable" to verdict.reachable, "detail" to verdict.detail)
230
+ }
231
+
232
+ /**
233
+ * Sign in to ACS before the glasses hotspot is up. SoftAP DNS cannot resolve Teams hosts, so
234
+ * [join] after the scoped network is a media bind, not another `createCallAgent`.
235
+ *
236
+ * Pinned to cellular for the duration, because "before the hotspot" means "on whatever Wi-Fi
237
+ * the phone is already on", and a slow AP is enough to blow the sign-in deadline. The pin is
238
+ * dropped before [join] opens the WHIP listener — see [InternetHold.bindProcessToCellular].
239
+ */
240
+ AsyncFunction("prepareAgent") { options: Map<String, Any?> ->
241
+ traced("prepare_agent", "hasSession" to (session != null)) {
242
+ val token = options["token"] as? String ?: throw IllegalArgumentException("token is required")
243
+ val displayName = options["displayName"] as? String
244
+ val context = appContext.reactContext ?: throw IllegalStateException("no react context")
245
+ val meeting = session ?: AcsMeetingSession(
246
+ context.applicationContext,
247
+ onState = { sendEvent("onState", it) },
248
+ onIncomingPcm = { base64, rate, channels ->
249
+ sendEvent(
250
+ "onIncomingPcm",
251
+ mapOf("base64" to base64, "sampleRate" to rate, "channels" to channels),
252
+ )
253
+ },
254
+ scopedNetwork = getOrCreateScopedNetwork(context),
255
+ ).also { session = it }
256
+ val hold = internetHold ?: InternetHold(context.applicationContext).also { internetHold = it }
257
+ // Request cellular rather than trusting it to be up: a phone sitting on Wi-Fi may have the
258
+ // radio asleep, and there is nothing to pin to until it is validated. An unvalidated result
259
+ // is not fatal here — the current default network may still sign in fine, and the scoped
260
+ // join is where a phone with no mobile data gets told so by name.
261
+ hold.awaitValidatedCellular()
262
+ hold.bindProcessToCellular()
263
+ // Deliberately still pinned on the way out. The pin has to survive the hotspot join that
264
+ // follows, because ACS re-binds its signalling when the network moves and keeps whatever
265
+ // route it got. Teardown's `leaveAndAwait` is what drops it, and only once a validated
266
+ // default internet exists — not `leaveScopedNetwork`, which used to unpin onto SoftAP.
267
+ meeting.prepareAgent(token, displayName)
268
+ meeting.snapshot()
269
+ }
270
+ }
271
+
272
+ AsyncFunction("join") { options: Map<String, Any?> ->
273
+ traced("join", "hasSession" to (session != null)) {
274
+ val token = options["token"] as? String ?: throw IllegalArgumentException("token is required")
275
+ val meetingUrl = options["meetingUrl"] as? String ?: throw IllegalArgumentException("meetingUrl is required")
276
+ // Accepts the videoSource union and still honours a bare whepUrl, so a host that predates
277
+ // the union keeps joining unchanged.
278
+ val videoSource = MeetingVideoSourceSpec.parse(
279
+ options["videoSource"] as? Map<*, *>,
280
+ options["whepUrl"] as? String,
281
+ )
282
+ val displayName = options["displayName"] as? String
283
+ val dumpWav = options["dumpPcmWav"] as? Boolean ?: false
284
+ val audioSource = options["audioSource"] as? String ?: "glasses"
285
+ val audioDelayMs = (options["audioDelayMs"] as? Number)?.toInt()
286
+ val video = parseVideo(options["video"])
287
+ SoftApTrace.stage(
288
+ "native_join_options",
289
+ "transport" to videoSource.kind,
290
+ "audioSource" to audioSource,
291
+ "audioDelayMs" to (audioDelayMs ?: -1),
292
+ "video" to "${video.width}x${video.height}@${video.fps}",
293
+ "dumpWav" to dumpWav,
294
+ )
295
+ val context = appContext.reactContext ?: throw IllegalStateException("no react context")
296
+ val meeting = session ?: AcsMeetingSession(
297
+ context.applicationContext,
298
+ onState = { sendEvent("onState", it) },
299
+ onIncomingPcm = { base64, rate, channels ->
300
+ sendEvent(
301
+ "onIncomingPcm",
302
+ mapOf("base64" to base64, "sampleRate" to rate, "channels" to channels),
303
+ )
304
+ },
305
+ scopedNetwork = getOrCreateScopedNetwork(context),
306
+ ).also { session = it }
307
+ // The process is already pinned to cellular by now (sign-in and the scoped join both pin, and
308
+ // ACS keeps the route its sockets were created with). Lift it only across the WHIP listener
309
+ // bind, which is the one socket that must stay on the hotspot.
310
+ val joined = meeting.join(
311
+ token,
312
+ meetingUrl,
313
+ videoSource,
314
+ displayName,
315
+ dumpWav,
316
+ audioSource,
317
+ video,
318
+ audioDelayMs,
319
+ bindIngestUnpinned = { bind ->
320
+ val hold = internetHold
321
+ if (hold == null) {
322
+ // No hold means no pin to lift, so the listener binds on whatever the default route
323
+ // is. Worth naming: that is also the state in which ACS's own sockets are unpinned.
324
+ SoftApTrace.stage("native_ingest_bind", "unpinned" to false, "reason" to "no cellular hold")
325
+ bind()
326
+ } else {
327
+ hold.unbindProcess()
328
+ try {
329
+ SoftApTrace.stage("native_ingest_bind", "unpinned" to true)
330
+ bind()
331
+ } finally {
332
+ hold.bindProcessToCellular()
333
+ }
334
+ }
335
+ },
336
+ )
337
+ // Prefer the join snapshot: getState() can race a leave from a respawned miniapp
338
+ // restore and drop the URL the orchestrator needs to tell the glasses.
339
+ joined + buildMap {
340
+ meeting.softApIngestUrl()?.let { put("ingestUrl", it) }
341
+ }
342
+ }
35
343
  }
36
344
 
37
345
  AsyncFunction("leave") {
38
- session?.leave()
346
+ traced("leave", "hasSession" to (session != null)) {
347
+ session?.leave()
348
+ internetHold?.releaseWhenDefaultInternetReady()
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Leave, and resolve only once the cleanup has actually finished.
354
+ *
355
+ * `leave` queues its work and returns, which is fine for a wearer who is done but useless to a
356
+ * host that has to know when the next call may safely start. This also drops the cellular
357
+ * process pin — but only once the phone has a validated default internet again. Unpinning
358
+ * onto a leftover glasses hotspot is what made the next `teams:create` return 503.
359
+ */
360
+ AsyncFunction("leaveAndAwait") { options: Map<String, Any?> ->
361
+ val timeoutMs = (options["timeoutMs"] as? Number)?.toLong() ?: 20_000L
362
+ traced("leave_and_await", "timeoutMs" to timeoutMs, "hasSession" to (session != null)) {
363
+ val completed = try {
364
+ session?.leaveAndAwait(timeoutMs) ?: true
365
+ } finally {
366
+ // In the `finally` so a cleanup that timed out still drops the pin. The pin is the one
367
+ // resource whose leak breaks the *next* call rather than this one.
368
+ internetHold?.releaseWhenDefaultInternetReady()
369
+ }
370
+ mapOf("completed" to completed)
371
+ }
372
+ }
373
+
374
+ /**
375
+ * End the Teams group call for everyone. Rejects when there is no call, when this participant
376
+ * is known not to be allowed to, or when ACS refuses — and tears this device down regardless,
377
+ * so a rejection means "we could not end it for the others", never "you are still in it".
378
+ */
379
+ AsyncFunction("endForEveryone") {
380
+ traced("end_for_everyone", "hasSession" to (session != null)) {
381
+ val meeting = session ?: throw IllegalStateException("no_active_call")
382
+ meeting.endForEveryone()
383
+ }
384
+ }
385
+
386
+ /**
387
+ * One buffer of glasses microphone PCM, decoded from BLE LC3 by the host.
388
+ *
389
+ * Synchronous by design. This runs at ~100 calls/s for the length of a call, and a promise per
390
+ * buffer would put more work on the JS thread than the audio itself costs. Returns whether the
391
+ * buffer entered the uplink so the host can count drops rather than guess at them.
392
+ */
393
+ Function("pushOutgoingPcm") { base64: String, sampleRate: Int, channels: Int ->
394
+ session?.pushOutgoingPcm(base64, sampleRate, channels) ?: false
39
395
  }
40
396
 
41
397
  AsyncFunction("setMuted") { muted: Boolean ->
@@ -59,8 +415,22 @@ class AcsMeetingModule : Module() {
59
415
  }
60
416
 
61
417
  OnDestroy {
418
+ // The one teardown no wearer asked for: a reloaded JS bundle or a killed module drops the
419
+ // session while a call may still be up, and nothing on the host side will ever report it.
420
+ SoftApTrace.stage(
421
+ "native_module_destroyed",
422
+ "hasSession" to (session != null),
423
+ "hasScopedNetwork" to (scopedNetwork != null),
424
+ "hasCellularHold" to (internetHold != null),
425
+ )
62
426
  session?.leave()
63
427
  session = null
428
+ scopedNetwork?.release()
429
+ scopedNetwork = null
430
+ // Destruction has no owner left to release a retained request later. Close permanently
431
+ // so a validation callback from the old module cannot re-pin a replacement module.
432
+ internetHold?.close()
433
+ internetHold = null
64
434
  }
65
435
  }
66
436