@mentra/acs-meeting 3.2.0-dev.221 → 3.2.0-dev.223
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/android/build.gradle +23 -0
- package/android/src/androidTest/java/com/mentra/acsmeeting/network/SoftApFeasibilityGateTest.kt +291 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/mentra/acsmeeting/AbandonedCallAgent.kt +36 -0
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingModule.kt +392 -22
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingSession.kt +909 -56
- package/android/src/main/java/com/mentra/acsmeeting/CallCapabilities.kt +33 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/AcsAudioPolicy.kt +21 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/AudioUplinkChain.kt +126 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/PcmBridge.kt +27 -4
- package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkPacer.kt +45 -10
- package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkSender.kt +110 -5
- package/android/src/main/java/com/mentra/acsmeeting/network/InternetHold.kt +501 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/Ipv4Prefix.kt +51 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/NetworkFacts.kt +81 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetector.kt +355 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkError.kt +109 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkObserver.kt +160 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkReadiness.kt +103 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpec.kt +53 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkState.kt +127 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedSoftApNetwork.kt +573 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/CloudflareWhepSource.kt +10 -101
- package/android/src/main/java/com/mentra/acsmeeting/source/DecodedTrackRelay.kt +147 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/GlassesMediaSource.kt +47 -5
- package/android/src/main/java/com/mentra/acsmeeting/source/LocalWhipIngestSource.kt +772 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpec.kt +73 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SdpAdapter.kt +17 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SelectedIcePair.kt +139 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIceDiagnosis.kt +144 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIcePolicy.kt +46 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SoftApSdpGuard.kt +160 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/TrackRegistry.kt +3 -1
- package/android/src/main/java/com/mentra/acsmeeting/source/VideoSourceArm.kt +69 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestProtocol.kt +207 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestServer.kt +356 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/AvSyncProbe.kt +155 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineStats.kt +94 -3
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineTicker.kt +33 -5
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/RingPercentile.kt +15 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoQuality.kt +178 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoRateVerdict.kt +145 -0
- package/android/src/main/java/com/mentra/acsmeeting/trace/SoftApTrace.kt +143 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/AcsFrameSender.kt +23 -4
- package/android/src/main/java/com/mentra/acsmeeting/video/FramePacer.kt +70 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/I420FormatSpec.kt +1 -1
- package/android/src/main/java/com/mentra/acsmeeting/video/VideoProfile.kt +36 -3
- package/android/src/test/java/com/mentra/acsmeeting/AbandonedCallAgentTest.kt +74 -0
- package/android/src/test/java/com/mentra/acsmeeting/CallCapabilitiesTest.kt +47 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/AudioUplinkChainTest.kt +250 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/CapturePolicyTest.kt +44 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkPacerTest.kt +34 -4
- package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkSenderTest.kt +96 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldLifecycleTest.kt +102 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldTest.kt +148 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/Ipv4PrefixTest.kt +59 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/NetworkFactsTest.kt +59 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedLocalNetworkPermissionTest.kt +35 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetectorTest.kt +165 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkErrorTest.kt +96 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkLifecycleTest.kt +187 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkObserverTest.kt +172 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkReadinessTest.kt +141 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpecTest.kt +69 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/AcsInvestigationTest.kt +40 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/DecodedTrackRelayTest.kt +41 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/GlassesMediaSourceTest.kt +26 -8
- package/android/src/test/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpecTest.kt +109 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/SelectedIcePairTest.kt +165 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIceDiagnosisTest.kt +148 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIcePolicyTest.kt +76 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/SoftApSdpGuardTest.kt +275 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/TrackRegistryTest.kt +9 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestProtocolTest.kt +356 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestServerLoopbackTest.kt +411 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/AvSyncProbeTest.kt +83 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/PipelineStatsTest.kt +69 -3
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoQualityTest.kt +182 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoRateVerdictTest.kt +149 -0
- package/android/src/test/java/com/mentra/acsmeeting/trace/SoftApTraceTest.kt +97 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/AcsTimestampTest.kt +34 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/FramePacerTest.kt +105 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/I420FormatSpecTest.kt +5 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/VideoProfileTest.kt +38 -3
- package/build/AcsMeeting.types.d.ts +5 -0
- package/build/AcsMeeting.types.d.ts.map +1 -1
- package/build/AcsMeeting.types.js.map +1 -1
- package/build/AcsMeetingModule.d.ts +23 -0
- package/build/AcsMeetingModule.d.ts.map +1 -1
- package/build/AcsMeetingModule.js.map +1 -1
- package/build/AcsMeetingModule.web.d.ts +15 -0
- package/build/AcsMeetingModule.web.d.ts.map +1 -1
- package/build/AcsMeetingModule.web.js +13 -0
- package/build/AcsMeetingModule.web.js.map +1 -1
- package/ios/AcsMeetingModule.swift +213 -2
- package/package.json +1 -1
- package/src/AcsMeeting.types.ts +5 -0
- package/src/AcsMeetingModule.ts +13 -0
- 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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
|