@mentra/acs-meeting 3.2.0-dev.235 → 3.2.0-dev.247
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/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingModule.kt +51 -41
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingSession.kt +400 -5
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/CallDiagnostics.kt +315 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/WireEpisodeTracker.kt +151 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/AcsFrameSender.kt +10 -1
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/CallDiagnosticsTest.kt +330 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/WireEpisodeTrackerTest.kt +180 -0
- package/package.json +2 -2
|
@@ -4,4 +4,7 @@
|
|
|
4
4
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
|
5
5
|
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" android:usesPermissionFlags="neverForLocation" />
|
|
6
6
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
|
7
|
+
<!-- ACS refuses to publish a video stream without it, even though our frames come from the
|
|
8
|
+
glasses over WHIP rather than from this phone's own camera sensor. -->
|
|
9
|
+
<uses-permission android:name="android.permission.CAMERA" />
|
|
7
10
|
</manifest>
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
package com.mentra.acsmeeting
|
|
2
2
|
|
|
3
3
|
import android.content.Context
|
|
4
|
-
import android.
|
|
5
|
-
import android.os.Build
|
|
4
|
+
import android.net.wifi.WifiManager
|
|
6
5
|
import android.os.SystemClock
|
|
7
|
-
import android.provider.Settings
|
|
8
6
|
import com.mentra.glassesmedia.network.InternetHold
|
|
9
7
|
import com.mentra.glassesmedia.network.ScopedNetworkChangeDetector
|
|
10
8
|
import com.mentra.glassesmedia.network.ScopedNetworkError
|
|
@@ -63,31 +61,6 @@ class AcsMeetingModule : Module() {
|
|
|
63
61
|
*/
|
|
64
62
|
private var internetHold: InternetHold? = null
|
|
65
63
|
|
|
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
|
-
|
|
91
64
|
override fun definition() = ModuleDefinition {
|
|
92
65
|
Name("MentraAcsMeeting")
|
|
93
66
|
// `onScopedNetworkLost` fires only for a hotspot that went away while we still wanted it: the
|
|
@@ -108,6 +81,20 @@ class AcsMeetingModule : Module() {
|
|
|
108
81
|
Unit
|
|
109
82
|
}
|
|
110
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Is the station radio on? The host's preflight, asked before the glasses are told anything.
|
|
86
|
+
*
|
|
87
|
+
* Read-only on purpose: `WifiManager.setWifiEnabled` has been a no-op for non-privileged apps
|
|
88
|
+
* since Android 10, so the only honest move is to report the state and let the UI explain the
|
|
89
|
+
* two taps. We never open the Wi-Fi panel or a network list — the wearer does not need to pick
|
|
90
|
+
* a network, and being shown one invites them to join the wrong thing.
|
|
91
|
+
*/
|
|
92
|
+
AsyncFunction("isWifiEnabled") {
|
|
93
|
+
val context = appContext.reactContext ?: throw IllegalStateException("no react context")
|
|
94
|
+
val manager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
|
|
95
|
+
manager?.isWifiEnabled == true
|
|
96
|
+
}
|
|
97
|
+
|
|
111
98
|
/**
|
|
112
99
|
* Join the glasses hotspot as a scoped, internet-less network and return this phone's address
|
|
113
100
|
* on it. The address is what the WHIP listener binds to, so a join that produces no address is
|
|
@@ -143,19 +130,13 @@ class AcsMeetingModule : Module() {
|
|
|
143
130
|
try {
|
|
144
131
|
scoped.join(ssid, passphrase, listener)
|
|
145
132
|
} catch (error: ScopedNetworkError.WifiDisabled) {
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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)
|
|
133
|
+
// The host asks `isWifiEnabled` before it raises the hotspot, so reaching here means the
|
|
134
|
+
// radio went off inside the join itself. Nothing native can do about that is better than
|
|
135
|
+
// saying so: the panel-and-wait this replaced held the call for up to 90s behind a
|
|
136
|
+
// system sheet with no explanation, and the Wi-Fi the user then picked often became the
|
|
137
|
+
// default route with no internet, hanging the ACS token mint on DNS.
|
|
138
|
+
com.mentra.glassesmedia.trace.SoftApTrace.failure("wifi_disabled_at_join")
|
|
139
|
+
throw error
|
|
159
140
|
} catch (error: ScopedNetworkError.Unavailable) {
|
|
160
141
|
// The first specifier steals wlan0 from the phone's current Wi-Fi (iPhone X, office
|
|
161
142
|
// AP). Samsung then assoc-rejects the glasses SoftAP (status 1025) and fires
|
|
@@ -284,12 +265,17 @@ class AcsMeetingModule : Module() {
|
|
|
284
265
|
val audioSource = options["audioSource"] as? String ?: "glasses"
|
|
285
266
|
val audioDelayMs = (options["audioDelayMs"] as? Number)?.toInt()
|
|
286
267
|
val video = parseVideo(options["video"])
|
|
268
|
+
// Diagnostic only, and deliberately not validated into an enum: an origin this build does
|
|
269
|
+
// not recognise is better recorded verbatim than collapsed into the wrong bucket.
|
|
270
|
+
val origin = (options["origin"] as? String)?.takeIf { it.isNotBlank() } ?: "unknown"
|
|
287
271
|
SoftApTrace.stage(
|
|
288
272
|
"native_join_options",
|
|
289
273
|
"transport" to videoSource.kind,
|
|
290
274
|
"audioSource" to audioSource,
|
|
291
275
|
"audioDelayMs" to (audioDelayMs ?: -1),
|
|
292
276
|
"video" to "${video.width}x${video.height}@${video.fps}",
|
|
277
|
+
"maxBitrateBps" to video.maxBitrateBps,
|
|
278
|
+
"origin" to origin,
|
|
293
279
|
"dumpWav" to dumpWav,
|
|
294
280
|
)
|
|
295
281
|
val context = appContext.reactContext ?: throw IllegalStateException("no react context")
|
|
@@ -316,6 +302,7 @@ class AcsMeetingModule : Module() {
|
|
|
316
302
|
audioSource,
|
|
317
303
|
video,
|
|
318
304
|
audioDelayMs,
|
|
305
|
+
origin,
|
|
319
306
|
bindIngestUnpinned = { bind ->
|
|
320
307
|
val hold = internetHold
|
|
321
308
|
if (hold == null) {
|
|
@@ -371,6 +358,29 @@ class AcsMeetingModule : Module() {
|
|
|
371
358
|
}
|
|
372
359
|
}
|
|
373
360
|
|
|
361
|
+
/**
|
|
362
|
+
* Wait for the SoftAP WHIP listener's port to be released.
|
|
363
|
+
*
|
|
364
|
+
* The listener outlives `leave` on purpose — it answers `410` for a few seconds so an in-flight
|
|
365
|
+
* request from the glasses gets a status rather than a reset. That grace is invisible from JS,
|
|
366
|
+
* and a Start inside it fails on a port this process still holds. `closed: false` means the
|
|
367
|
+
* host must force the close, not that it may carry on.
|
|
368
|
+
*/
|
|
369
|
+
AsyncFunction("awaitIngestClosed") { options: Map<String, Any?> ->
|
|
370
|
+
val timeoutMs = (options["timeoutMs"] as? Number)?.toLong() ?: 3_500L
|
|
371
|
+
traced("await_ingest_closed", "timeoutMs" to timeoutMs, "hasSession" to (session != null)) {
|
|
372
|
+
mapOf("closed" to (session?.awaitIngestClosed(timeoutMs) ?: true))
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Skip the tombstone and drop the listener now. The host's forced path after a timed-out wait. */
|
|
377
|
+
AsyncFunction("forceCloseIngest") {
|
|
378
|
+
traced("force_close_ingest", "hasSession" to (session != null)) {
|
|
379
|
+
session?.forceCloseIngest()
|
|
380
|
+
Unit
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
374
384
|
/**
|
|
375
385
|
* End the Teams group call for everyone. Rejects when there is no call, when this participant
|
|
376
386
|
* is known not to be allowed to, or when ACS refuses — and tears this device down regardless,
|