@mentra/acs-meeting 3.2.0-dev.233 → 3.2.0-dev.245

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.
@@ -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.content.Intent
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
- // 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.glassesmedia.trace.SoftApTrace.stage("wifi_enable_wait", "timeoutMs" to ScopedSoftApNetwork.WIFI_ENABLE_WAIT_MS)
151
- val enabled = scoped.awaitWifiEnabled()
152
- com.mentra.glassesmedia.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)
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,