@mentra/acs-meeting 3.2.0-dev.217 → 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
@@ -2,12 +2,35 @@ import { NativeModule } from "expo";
2
2
  import type { AcsMeetingJoinOptions, AcsMeetingModuleEvents, AcsMeetingState } from "./AcsMeeting.types";
3
3
  declare class AcsMeetingNativeModule extends NativeModule<AcsMeetingModuleEvents> {
4
4
  join(options: AcsMeetingJoinOptions): Promise<AcsMeetingState>;
5
+ /** Sign in to ACS before SoftAP so Teams is not resolved through glasses DNS. */
6
+ prepareAgent(options: {
7
+ token: string;
8
+ displayName?: string;
9
+ }): Promise<AcsMeetingState>;
5
10
  leave(): Promise<void>;
11
+ /**
12
+ * Leave, and resolve only once the hang-up, the agent disposal, and the network releases have
13
+ * finished. `leave()` only queues that work, so awaiting it proves nothing about cleanup.
14
+ */
15
+ leaveAndAwait(options: {
16
+ timeoutMs: number;
17
+ }): Promise<{
18
+ completed: boolean;
19
+ }>;
6
20
  setMuted(muted: boolean): Promise<AcsMeetingState>;
7
21
  setAudioSource(source: "glasses" | "phone"): Promise<AcsMeetingState>;
8
22
  updateVideoSource(whepUrl: string): Promise<void>;
9
23
  /** Force a WHEP rebuild on the current URL (phone changed networks). */
10
24
  restartVideoSource(): Promise<void>;
25
+ /** SoftAP: join the glasses hotspot as a scoped network; resolves to the phone's IPv4 on it. Android only. */
26
+ joinScopedNetwork(ssid: string, passphrase: string): Promise<string>;
27
+ beginTrace(traceId: string): Promise<void>;
28
+ leaveScopedNetwork(): Promise<void>;
29
+ /** SoftAP: TCP-probe the hotspot gateway over the scoped network. */
30
+ probeScopedGateway(): Promise<{
31
+ reachable: boolean;
32
+ detail: string;
33
+ }>;
11
34
  getState(): Promise<AcsMeetingState>;
12
35
  }
13
36
  declare const _default: AcsMeetingNativeModule;
@@ -1 +1 @@
1
- {"version":3,"file":"AcsMeetingModule.d.ts","sourceRoot":"","sources":["../src/AcsMeetingModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,YAAY,EAAsB,MAAM,MAAM,CAAA;AAEtD,OAAO,KAAK,EAAC,qBAAqB,EAAE,sBAAsB,EAAE,eAAe,EAAC,MAAM,oBAAoB,CAAA;AAEtG,OAAO,OAAO,sBAAuB,SAAQ,YAAY,CAAC,sBAAsB,CAAC;IAC/E,IAAI,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,eAAe,CAAC;IAC9D,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,eAAe,CAAC;IAClD,cAAc,CAAC,MAAM,EAAE,SAAS,GAAG,OAAO,GAAG,OAAO,CAAC,eAAe,CAAC;IACrE,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IACjD,wEAAwE;IACxE,kBAAkB,IAAI,OAAO,CAAC,IAAI,CAAC;IACnC,QAAQ,IAAI,OAAO,CAAC,eAAe,CAAC;CACrC;;AAED,wBAA8E"}
1
+ {"version":3,"file":"AcsMeetingModule.d.ts","sourceRoot":"","sources":["../src/AcsMeetingModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,YAAY,EAAsB,MAAM,MAAM,CAAA;AAEtD,OAAO,KAAK,EAAC,qBAAqB,EAAE,sBAAsB,EAAE,eAAe,EAAC,MAAM,oBAAoB,CAAA;AAEtG,OAAO,OAAO,sBAAuB,SAAQ,YAAY,CAAC,sBAAsB,CAAC;IAC/E,IAAI,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,eAAe,CAAC;IAC9D,iFAAiF;IACjF,YAAY,CAAC,OAAO,EAAE;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC,eAAe,CAAC;IACtF,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IACtB;;;OAGG;IACH,aAAa,CAAC,OAAO,EAAE;QAAC,SAAS,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC;QAAC,SAAS,EAAE,OAAO,CAAA;KAAC,CAAC;IAC1E,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,eAAe,CAAC;IAClD,cAAc,CAAC,MAAM,EAAE,SAAS,GAAG,OAAO,GAAG,OAAO,CAAC,eAAe,CAAC;IACrE,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IACjD,wEAAwE;IACxE,kBAAkB,IAAI,OAAO,CAAC,IAAI,CAAC;IACnC,8GAA8G;IAC9G,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IACpE,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAC1C,kBAAkB,IAAI,OAAO,CAAC,IAAI,CAAC;IACnC,qEAAqE;IACrE,kBAAkB,IAAI,OAAO,CAAC;QAAC,SAAS,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAC,CAAC;IACnE,QAAQ,IAAI,OAAO,CAAC,eAAe,CAAC;CACrC;;AAED,wBAA8E"}
@@ -1 +1 @@
1
- {"version":3,"file":"AcsMeetingModule.js","sourceRoot":"","sources":["../src/AcsMeetingModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAe,mBAAmB,EAAC,MAAM,MAAM,CAAA;AAetD,eAAe,mBAAmB,CAAyB,kBAAkB,CAAC,CAAA","sourcesContent":["import {NativeModule, requireNativeModule} from \"expo\"\n\nimport type {AcsMeetingJoinOptions, AcsMeetingModuleEvents, AcsMeetingState} from \"./AcsMeeting.types\"\n\ndeclare class AcsMeetingNativeModule extends NativeModule<AcsMeetingModuleEvents> {\n join(options: AcsMeetingJoinOptions): Promise<AcsMeetingState>\n leave(): Promise<void>\n setMuted(muted: boolean): Promise<AcsMeetingState>\n setAudioSource(source: \"glasses\" | \"phone\"): Promise<AcsMeetingState>\n updateVideoSource(whepUrl: string): Promise<void>\n /** Force a WHEP rebuild on the current URL (phone changed networks). */\n restartVideoSource(): Promise<void>\n getState(): Promise<AcsMeetingState>\n}\n\nexport default requireNativeModule<AcsMeetingNativeModule>(\"MentraAcsMeeting\")\n"]}
1
+ {"version":3,"file":"AcsMeetingModule.js","sourceRoot":"","sources":["../src/AcsMeetingModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAe,mBAAmB,EAAC,MAAM,MAAM,CAAA;AA4BtD,eAAe,mBAAmB,CAAyB,kBAAkB,CAAC,CAAA","sourcesContent":["import {NativeModule, requireNativeModule} from \"expo\"\n\nimport type {AcsMeetingJoinOptions, AcsMeetingModuleEvents, AcsMeetingState} from \"./AcsMeeting.types\"\n\ndeclare class AcsMeetingNativeModule extends NativeModule<AcsMeetingModuleEvents> {\n join(options: AcsMeetingJoinOptions): Promise<AcsMeetingState>\n /** Sign in to ACS before SoftAP so Teams is not resolved through glasses DNS. */\n prepareAgent(options: {token: string; displayName?: string}): Promise<AcsMeetingState>\n leave(): Promise<void>\n /**\n * Leave, and resolve only once the hang-up, the agent disposal, and the network releases have\n * finished. `leave()` only queues that work, so awaiting it proves nothing about cleanup.\n */\n leaveAndAwait(options: {timeoutMs: number}): Promise<{completed: boolean}>\n setMuted(muted: boolean): Promise<AcsMeetingState>\n setAudioSource(source: \"glasses\" | \"phone\"): Promise<AcsMeetingState>\n updateVideoSource(whepUrl: string): Promise<void>\n /** Force a WHEP rebuild on the current URL (phone changed networks). */\n restartVideoSource(): Promise<void>\n /** SoftAP: join the glasses hotspot as a scoped network; resolves to the phone's IPv4 on it. Android only. */\n joinScopedNetwork(ssid: string, passphrase: string): Promise<string>\n beginTrace(traceId: string): Promise<void>\n leaveScopedNetwork(): Promise<void>\n /** SoftAP: TCP-probe the hotspot gateway over the scoped network. */\n probeScopedGateway(): Promise<{reachable: boolean; detail: string}>\n getState(): Promise<AcsMeetingState>\n}\n\nexport default requireNativeModule<AcsMeetingNativeModule>(\"MentraAcsMeeting\")\n"]}
@@ -1,11 +1,26 @@
1
1
  import type { AcsMeetingJoinOptions, AcsMeetingState } from "./AcsMeeting.types";
2
2
  declare const _default: {
3
3
  join(_options: AcsMeetingJoinOptions): Promise<AcsMeetingState>;
4
+ prepareAgent(_options: {
5
+ token: string;
6
+ displayName?: string;
7
+ }): Promise<AcsMeetingState>;
4
8
  leave(): Promise<void>;
9
+ leaveAndAwait(_options: {
10
+ timeoutMs: number;
11
+ }): Promise<{
12
+ completed: boolean;
13
+ }>;
5
14
  setMuted(_muted: boolean): Promise<AcsMeetingState>;
6
15
  setAudioSource(_source: "glasses" | "phone"): Promise<AcsMeetingState>;
7
16
  updateVideoSource(_whepUrl: string): Promise<void>;
8
17
  restartVideoSource(): Promise<void>;
18
+ joinScopedNetwork(_ssid: string, _passphrase: string): Promise<string>;
19
+ leaveScopedNetwork(): Promise<void>;
20
+ probeScopedGateway(): Promise<{
21
+ reachable: boolean;
22
+ detail: string;
23
+ }>;
9
24
  getState(): Promise<AcsMeetingState>;
10
25
  addListener(): {
11
26
  remove(): void;
@@ -1 +1 @@
1
- {"version":3,"file":"AcsMeetingModule.web.d.ts","sourceRoot":"","sources":["../src/AcsMeetingModule.web.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,qBAAqB,EAAE,eAAe,EAAC,MAAM,oBAAoB,CAAA;;mBAOvD,qBAAqB,GAAG,OAAO,CAAC,eAAe,CAAC;aAGtD,OAAO,CAAC,IAAI,CAAC;qBAGL,OAAO,GAAG,OAAO,CAAC,eAAe,CAAC;4BAG3B,SAAS,GAAG,OAAO,GAAG,OAAO,CAAC,eAAe,CAAC;gCAG1C,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;0BAG5B,OAAO,CAAC,IAAI,CAAC;gBAGvB,OAAO,CAAC,eAAe,CAAC;;;;;;AAnB5C,wBA0BC"}
1
+ {"version":3,"file":"AcsMeetingModule.web.d.ts","sourceRoot":"","sources":["../src/AcsMeetingModule.web.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,qBAAqB,EAAE,eAAe,EAAC,MAAM,oBAAoB,CAAA;;mBAOvD,qBAAqB,GAAG,OAAO,CAAC,eAAe,CAAC;2BAGxC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC,eAAe,CAAC;aAG9E,OAAO,CAAC,IAAI,CAAC;4BAGE;QAAC,SAAS,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC;QAAC,SAAS,EAAE,OAAO,CAAA;KAAC,CAAC;qBAG1D,OAAO,GAAG,OAAO,CAAC,eAAe,CAAC;4BAG3B,SAAS,GAAG,OAAO,GAAG,OAAO,CAAC,eAAe,CAAC;gCAG1C,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;0BAG5B,OAAO,CAAC,IAAI,CAAC;6BAGV,MAAM,eAAe,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;0BAGhD,OAAO,CAAC,IAAI,CAAC;0BACb,OAAO,CAAC;QAAC,SAAS,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAC,CAAC;gBAGvD,OAAO,CAAC,eAAe,CAAC;;;;;;AAhC5C,wBAuCC"}
@@ -5,9 +5,15 @@ export default {
5
5
  async join(_options) {
6
6
  unavailable();
7
7
  },
8
+ async prepareAgent(_options) {
9
+ unavailable();
10
+ },
8
11
  async leave() {
9
12
  unavailable();
10
13
  },
14
+ async leaveAndAwait(_options) {
15
+ unavailable();
16
+ },
11
17
  async setMuted(_muted) {
12
18
  unavailable();
13
19
  },
@@ -20,6 +26,13 @@ export default {
20
26
  async restartVideoSource() {
21
27
  unavailable();
22
28
  },
29
+ async joinScopedNetwork(_ssid, _passphrase) {
30
+ unavailable();
31
+ },
32
+ async leaveScopedNetwork() { },
33
+ async probeScopedGateway() {
34
+ return { reachable: false, detail: "no scoped network on web" };
35
+ },
23
36
  async getState() {
24
37
  return { state: "idle", muted: false };
25
38
  },
@@ -1 +1 @@
1
- {"version":3,"file":"AcsMeetingModule.web.js","sourceRoot":"","sources":["../src/AcsMeetingModule.web.ts"],"names":[],"mappings":"AAEA,SAAS,WAAW;IAClB,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAA;AACxD,CAAC;AAED,eAAe;IACb,KAAK,CAAC,IAAI,CAAC,QAA+B;QACxC,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,KAAK;QACT,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,QAAQ,CAAC,MAAe;QAC5B,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,cAAc,CAAC,OAA4B;QAC/C,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,iBAAiB,CAAC,QAAgB;QACtC,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,kBAAkB;QACtB,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,QAAQ;QACZ,OAAO,EAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,EAAC,CAAA;IACtC,CAAC;IACD,WAAW;QACT,OAAO,EAAC,MAAM,KAAI,CAAC,EAAC,CAAA;IACtB,CAAC;IACD,eAAe,KAAI,CAAC;CACrB,CAAA","sourcesContent":["import type {AcsMeetingJoinOptions, AcsMeetingState} from \"./AcsMeeting.types\"\n\nfunction unavailable(): never {\n throw new Error(\"ACS meeting is not available on web\")\n}\n\nexport default {\n async join(_options: AcsMeetingJoinOptions): Promise<AcsMeetingState> {\n unavailable()\n },\n async leave(): Promise<void> {\n unavailable()\n },\n async setMuted(_muted: boolean): Promise<AcsMeetingState> {\n unavailable()\n },\n async setAudioSource(_source: \"glasses\" | \"phone\"): Promise<AcsMeetingState> {\n unavailable()\n },\n async updateVideoSource(_whepUrl: string): Promise<void> {\n unavailable()\n },\n async restartVideoSource(): Promise<void> {\n unavailable()\n },\n async getState(): Promise<AcsMeetingState> {\n return {state: \"idle\", muted: false}\n },\n addListener() {\n return {remove() {}}\n },\n removeListeners() {},\n}\n"]}
1
+ {"version":3,"file":"AcsMeetingModule.web.js","sourceRoot":"","sources":["../src/AcsMeetingModule.web.ts"],"names":[],"mappings":"AAEA,SAAS,WAAW;IAClB,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAA;AACxD,CAAC;AAED,eAAe;IACb,KAAK,CAAC,IAAI,CAAC,QAA+B;QACxC,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,YAAY,CAAC,QAA+C;QAChE,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,KAAK;QACT,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,aAAa,CAAC,QAA6B;QAC/C,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,QAAQ,CAAC,MAAe;QAC5B,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,cAAc,CAAC,OAA4B;QAC/C,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,iBAAiB,CAAC,QAAgB;QACtC,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,kBAAkB;QACtB,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,iBAAiB,CAAC,KAAa,EAAE,WAAmB;QACxD,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,kBAAkB,KAAmB,CAAC;IAC5C,KAAK,CAAC,kBAAkB;QACtB,OAAO,EAAC,SAAS,EAAE,KAAK,EAAE,MAAM,EAAE,0BAA0B,EAAC,CAAA;IAC/D,CAAC;IACD,KAAK,CAAC,QAAQ;QACZ,OAAO,EAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,EAAC,CAAA;IACtC,CAAC;IACD,WAAW;QACT,OAAO,EAAC,MAAM,KAAI,CAAC,EAAC,CAAA;IACtB,CAAC;IACD,eAAe,KAAI,CAAC;CACrB,CAAA","sourcesContent":["import type {AcsMeetingJoinOptions, AcsMeetingState} from \"./AcsMeeting.types\"\n\nfunction unavailable(): never {\n throw new Error(\"ACS meeting is not available on web\")\n}\n\nexport default {\n async join(_options: AcsMeetingJoinOptions): Promise<AcsMeetingState> {\n unavailable()\n },\n async prepareAgent(_options: {token: string; displayName?: string}): Promise<AcsMeetingState> {\n unavailable()\n },\n async leave(): Promise<void> {\n unavailable()\n },\n async leaveAndAwait(_options: {timeoutMs: number}): Promise<{completed: boolean}> {\n unavailable()\n },\n async setMuted(_muted: boolean): Promise<AcsMeetingState> {\n unavailable()\n },\n async setAudioSource(_source: \"glasses\" | \"phone\"): Promise<AcsMeetingState> {\n unavailable()\n },\n async updateVideoSource(_whepUrl: string): Promise<void> {\n unavailable()\n },\n async restartVideoSource(): Promise<void> {\n unavailable()\n },\n async joinScopedNetwork(_ssid: string, _passphrase: string): Promise<string> {\n unavailable()\n },\n async leaveScopedNetwork(): Promise<void> {},\n async probeScopedGateway(): Promise<{reachable: boolean; detail: string}> {\n return {reachable: false, detail: \"no scoped network on web\"}\n },\n async getState(): Promise<AcsMeetingState> {\n return {state: \"idle\", muted: false}\n },\n addListener() {\n return {remove() {}}\n },\n removeListeners() {},\n}\n"]}
@@ -14,7 +14,7 @@ public class AcsMeetingModule: Module {
14
14
  AsyncFunction("join") { (options: [String: Any]) in
15
15
  let token = try requireString(options, "token")
16
16
  let meetingUrl = try requireString(options, "meetingUrl")
17
- let whepUrl = try requireString(options, "whepUrl")
17
+ let whepUrl = try requireWhepUrl(options)
18
18
  let displayName = options["displayName"] as? String
19
19
  let dumpWav = options["dumpPcmWav"] as? Bool ?? false
20
20
  let audioSource = options["audioSource"] as? String ?? "glasses"
@@ -30,10 +30,62 @@ public class AcsMeetingModule: Module {
30
30
  return meeting.snapshot()
31
31
  }
32
32
 
33
+ /// Refuses rather than no-ops. A silent success here would let the orchestrator go on to an ACS
34
+ /// join and a glasses publish that have no network to meet on, which surfaces as a black tile
35
+ /// several steps later instead of as the unsupported transport it is.
36
+ AsyncFunction("joinScopedNetwork") { (_: String, _: String) -> String in
37
+ throw NSError(
38
+ domain: "MentraAcsMeeting",
39
+ code: 2,
40
+ userInfo: [
41
+ NSLocalizedDescriptionKey:
42
+ "NOT_IMPLEMENTED: SoftAP calling is Android-only; iOS cannot join the glasses hotspot as a scoped network",
43
+ ]
44
+ )
45
+ }
46
+
47
+ AsyncFunction("leaveScopedNetwork") {
48
+ // Nothing was ever joined, so releasing is a no-op rather than an error: teardown must stay
49
+ // safe to run after a failed start.
50
+ }
51
+
52
+ AsyncFunction("probeScopedGateway") { () -> [String: Any] in
53
+ // No scoped network exists on iOS, so there is nothing to probe. Report it as such rather
54
+ // than as unreachable, which the orchestrator would read as a live network failure.
55
+ ["reachable": false, "detail": "no scoped network on iOS"]
56
+ }
57
+
33
58
  AsyncFunction("leave") {
34
59
  self.session?.leave()
35
60
  }
36
61
 
62
+ AsyncFunction("endForEveryone") { (promise: Promise) in
63
+ guard let session = self.session else {
64
+ promise.reject(NSError(
65
+ domain: "MentraAcsMeeting",
66
+ code: 3,
67
+ userInfo: [NSLocalizedDescriptionKey: "No active meeting to end"]
68
+ ))
69
+ return
70
+ }
71
+ session.endForEveryone { error in
72
+ // Local teardown has already run by the time this fires, so a rejection here means
73
+ // "the meeting may still be running", never "you are still in it".
74
+ if let error {
75
+ promise.reject(error)
76
+ } else {
77
+ promise.resolve(session.snapshot())
78
+ }
79
+ }
80
+ }
81
+
82
+ AsyncFunction("scopedNetworkInfo") { () -> [String: Any?] in
83
+ // No scoped network on iOS, so there is no prefix for the ICE-path proof to check
84
+ // against. Reported as unavailable rather than as an empty prefix, which the proof
85
+ // would read as "could not describe the network" on a platform that has none.
86
+ ["available": false, "localIpv4": nil, "prefix": nil]
87
+ }
88
+
37
89
  AsyncFunction("setMuted") { (muted: Bool) in
38
90
  self.session?.setMuted(muted) ?? ["state": "idle", "muted": muted]
39
91
  }
@@ -114,10 +166,16 @@ final class AcsMeetingSession {
114
166
  private static let mediaRestartBaseMs = 1000
115
167
  private static let mediaRestartMaxMs = 10000
116
168
  private var joinGeneration: UInt64 = 0
169
+ private var capabilitiesFeature: CapabilitiesCallFeature?
170
+ /// nil means "not reported yet", which the miniapp shows as End disabled rather than absent.
171
+ private var hangUpForEveryone: (allowed: Bool, reason: String)?
117
172
  private lazy var callDelegateProxy = AcsCallDelegateProxy(
118
173
  onStateChange: { [weak self] call in self?.handleCallStateChange(call) },
119
174
  onMuteChange: { [weak self] call in self?.handleCallMuteChange(call) }
120
175
  )
176
+ private lazy var capabilitiesDelegateProxy = AcsCapabilitiesDelegateProxy(
177
+ onChanged: { [weak self] in self?.refreshCapabilities() }
178
+ )
121
179
 
122
180
  init(onState: @escaping ([String: Any]) -> Void, onIncomingPcm: @escaping (String, Int, Int) -> Void) {
123
181
  self.onState = onState
@@ -134,6 +192,15 @@ final class AcsMeetingSession {
134
192
  "audioSafety": lastSafety.rawValue,
135
193
  "mediaSource": mediaSource.rawValue,
136
194
  ]
195
+ // Always present, so a host that simply has not heard from Teams yet is distinguishable
196
+ // from one that cannot report capabilities at all. Keys are omitted rather than sent as
197
+ // null while unknown; the host parses a missing key as unknown.
198
+ var hangUp: [String: Any] = [:]
199
+ if let capability = hangUpForEveryone {
200
+ hangUp["allowed"] = capability.allowed
201
+ hangUp["reason"] = capability.reason
202
+ }
203
+ result["capabilities"] = ["hangUpForEveryone": hangUp]
137
204
  if let meetingUrl { result["meetingUrl"] = meetingUrl }
138
205
  if let lastError { result["error"] = lastError }
139
206
  return result
@@ -314,6 +381,7 @@ final class AcsMeetingSession {
314
381
  }
315
382
  self.call = call
316
383
  call.delegate = callDelegateProxy
384
+ attachCapabilities(call)
317
385
 
318
386
  let bridge = PcmBridge(dumpWav: dumpWav)
319
387
  pcmBridge = bridge
@@ -423,6 +491,102 @@ final class AcsMeetingSession {
423
491
  queue.async { self.leaveLocked() }
424
492
  }
425
493
 
494
+ /**
495
+ End the meeting for everyone, then tear this device down.
496
+
497
+ Local teardown runs whether or not ACS accepted the hang-up, and the error is reported after
498
+ it. The wearer is out of the call either way; what the caller learns from a rejection is only
499
+ that the others may still be in it.
500
+
501
+ A known-denied capability is refused locally rather than sent. Teams only lets a presenter end
502
+ a meeting for everyone, ACS rejects the attempt with an opaque error, and the wearer would have
503
+ sat through a confirm sheet for nothing. An *unknown* capability is not a refusal: it is the
504
+ ordinary state before the first capabilities event, and letting ACS answer is more honest than
505
+ guessing.
506
+ */
507
+ func endForEveryone(completion: @escaping (Error?) -> Void) {
508
+ queue.async { [weak self] in
509
+ guard let self else { return }
510
+ if let refusal = self.hangUpForEveryoneRefusal() {
511
+ completion(NSError(
512
+ domain: "MentraAcsMeeting",
513
+ code: 3,
514
+ userInfo: [NSLocalizedDescriptionKey: refusal]
515
+ ))
516
+ return
517
+ }
518
+ guard let active = self.call else {
519
+ self.leaveLocked()
520
+ completion(NSError(
521
+ domain: "MentraAcsMeeting",
522
+ code: 3,
523
+ userInfo: [NSLocalizedDescriptionKey: "No active meeting to end"]
524
+ ))
525
+ return
526
+ }
527
+ let options = HangUpOptions()
528
+ options.forEveryone = true
529
+ active.hangUp(options: options) { [weak self] error in
530
+ guard let self else {
531
+ completion(error)
532
+ return
533
+ }
534
+ if let error {
535
+ NSLog("ACS-SPIKE endForEveryone failed: \(error)")
536
+ }
537
+ self.queue.async {
538
+ self.leaveLocked()
539
+ completion(error)
540
+ }
541
+ }
542
+ }
543
+ }
544
+
545
+ /// The refusal to report, or nil when the End should be attempted.
546
+ private func hangUpForEveryoneRefusal() -> String? {
547
+ guard let capability = readHangUpForEveryone(), !capability.allowed else { return nil }
548
+ return "hang_up_for_everyone_not_allowed:\(capability.reason)"
549
+ }
550
+
551
+ /**
552
+ Subscribe to the runtime capability that decides whether End is offered.
553
+
554
+ Subscribed rather than read once: Teams can grant the capability after admission (a wearer
555
+ promoted to presenter mid-call), and an End button that never turns on is the same bug as one
556
+ that lies about what it does.
557
+ */
558
+ private func attachCapabilities(_ call: Call) {
559
+ let feature = call.feature(Features.capabilities)
560
+ capabilitiesFeature = feature
561
+ feature.delegate = capabilitiesDelegateProxy
562
+ refreshCapabilities()
563
+ }
564
+
565
+ fileprivate func refreshCapabilities() {
566
+ queue.async { [weak self] in
567
+ guard let self else { return }
568
+ let next = self.readHangUpForEveryone()
569
+ guard next?.allowed != self.hangUpForEveryone?.allowed
570
+ || next?.reason != self.hangUpForEveryone?.reason else { return }
571
+ self.hangUpForEveryone = next
572
+ NSLog("ACS-SPIKE hangUpForEveryone allowed=\(next?.allowed.description ?? "unknown") reason=\(next?.reason ?? "-")")
573
+ self.onState(self.snapshot())
574
+ }
575
+ }
576
+
577
+ /// Drop the capability so the next call starts unknown instead of inheriting the last one's.
578
+ private func detachCapabilities() {
579
+ capabilitiesFeature?.delegate = nil
580
+ capabilitiesFeature = nil
581
+ hangUpForEveryone = nil
582
+ }
583
+
584
+ private func readHangUpForEveryone() -> (allowed: Bool, reason: String)? {
585
+ guard let feature = capabilitiesFeature else { return nil }
586
+ guard let capability = feature.capabilities.first(where: { $0.type == .hangUpForEveryone }) else { return nil }
587
+ return (capability.isAllowed, String(describing: capability.reason))
588
+ }
589
+
426
590
  fileprivate func applyAudioPolicy(_ reason: String) {
427
591
  queue.async { self.applyAudioPolicyOnQueue(reason) }
428
592
  }
@@ -469,6 +633,7 @@ final class AcsMeetingSession {
469
633
  // Detach before stop so the teardown's own idle transition does not emit a
470
634
  // snapshot (or schedule a rebuild) for a call that is going away.
471
635
  cancelMediaRestart()
636
+ detachCapabilities()
472
637
  whep?.onStateChange = nil
473
638
  mediaSource = .idle
474
639
  whep?.stop()
@@ -644,6 +809,21 @@ private final class AcsCallDelegateProxy: NSObject, CallDelegate {
644
809
  }
645
810
  }
646
811
 
812
+ private final class AcsCapabilitiesDelegateProxy: NSObject, CapabilitiesCallFeatureDelegate {
813
+ private let onChanged: () -> Void
814
+
815
+ init(onChanged: @escaping () -> Void) {
816
+ self.onChanged = onChanged
817
+ }
818
+
819
+ func capabilitiesCallFeature(
820
+ _: CapabilitiesCallFeature,
821
+ didChangeCapabilities _: CapabilitiesChangedEventArgs
822
+ ) {
823
+ onChanged()
824
+ }
825
+ }
826
+
647
827
  private struct AcsMeetingError: LocalizedError {
648
828
  let message: String
649
829
 
@@ -675,7 +855,7 @@ struct AcsOutgoingVideo {
675
855
  let fps: Int
676
856
  let maxBitrateBps: Int
677
857
 
678
- static let hd = AcsOutgoingVideo(width: 1280, height: 720, fps: 15, maxBitrateBps: 2_500_000)
858
+ static let hd = AcsOutgoingVideo(width: 1280, height: 720, fps: 15, maxBitrateBps: 3_000_000)
679
859
  static let allowedSizes: Set<String> = ["1280x720", "960x540"]
680
860
  }
681
861
 
@@ -686,6 +866,37 @@ private func requireString(_ options: [String: Any], _ key: String) throws -> St
686
866
  return value
687
867
  }
688
868
 
869
+ /// Resolves the WHEP URL from either the `videoSource` union or a legacy bare `whepUrl`.
870
+ ///
871
+ /// SoftAP is refused here with `NOT_IMPLEMENTED` rather than allowed to fall through. Joining
872
+ /// without a usable video source would produce a call that connects and shows the remote
873
+ /// participant a black tile, with nothing in the logs to explain it. iOS cannot serve the local
874
+ /// WHIP endpoint yet — see the SoftAP plan's iOS spike — so saying so plainly is the correct
875
+ /// behaviour until it can.
876
+ private func requireWhepUrl(_ options: [String: Any]) throws -> String {
877
+ guard let source = options["videoSource"] as? [String: Any] else {
878
+ return try requireString(options, "whepUrl")
879
+ }
880
+ let type = (source["type"] as? String)?.lowercased() ?? ""
881
+ switch type {
882
+ case "whep":
883
+ if let url = source["url"] as? String, !url.isEmpty { return url }
884
+ return try requireString(options, "whepUrl")
885
+ case "softap":
886
+ throw NSError(
887
+ domain: "MentraAcsMeeting",
888
+ code: 2,
889
+ userInfo: [NSLocalizedDescriptionKey: "NOT_IMPLEMENTED: SoftAP calling is Android-only"]
890
+ )
891
+ default:
892
+ throw NSError(
893
+ domain: "MentraAcsMeeting",
894
+ code: 1,
895
+ userInfo: [NSLocalizedDescriptionKey: "unsupported videoSource.type: \(type)"]
896
+ )
897
+ }
898
+ }
899
+
689
900
  private func parseAcsOutgoingVideo(_ raw: Any?) throws -> AcsOutgoingVideo {
690
901
  guard let raw else { return .hd }
691
902
  guard let map = raw as? [String: Any] else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/acs-meeting",
3
- "version": "3.2.0-dev.217",
3
+ "version": "3.2.0-dev.222",
4
4
  "description": "MentraOS native ACS Teams meeting module (WHEP decode → ACS raw media)",
5
5
  "main": "build/index.js",
6
6
  "react-native": "src/index.ts",
@@ -48,6 +48,11 @@ export type AcsMeetingJoinOptions = {
48
48
  displayName?: string
49
49
  /** "glasses" sends WHEP PCM. "phone" uses the ACS local mic (handset or BT). */
50
50
  audioSource?: AcsAudioSource
51
+ /**
52
+ * Hold outgoing PCM this many ms before ACS. SoftAP+LC3 sets this in the host
53
+ * because BLE audio reaches the phone before SoftAP video.
54
+ */
55
+ audioDelayMs?: number
51
56
  /** Dump WHEP PCM to a WAV in cache for P4 verification. */
52
57
  dumpPcmWav?: boolean
53
58
  /** When omitted, native keeps 1280×720@15 / 2.5 Mbps. */
@@ -4,12 +4,25 @@ import type {AcsMeetingJoinOptions, AcsMeetingModuleEvents, AcsMeetingState} fro
4
4
 
5
5
  declare class AcsMeetingNativeModule extends NativeModule<AcsMeetingModuleEvents> {
6
6
  join(options: AcsMeetingJoinOptions): Promise<AcsMeetingState>
7
+ /** Sign in to ACS before SoftAP so Teams is not resolved through glasses DNS. */
8
+ prepareAgent(options: {token: string; displayName?: string}): Promise<AcsMeetingState>
7
9
  leave(): Promise<void>
10
+ /**
11
+ * Leave, and resolve only once the hang-up, the agent disposal, and the network releases have
12
+ * finished. `leave()` only queues that work, so awaiting it proves nothing about cleanup.
13
+ */
14
+ leaveAndAwait(options: {timeoutMs: number}): Promise<{completed: boolean}>
8
15
  setMuted(muted: boolean): Promise<AcsMeetingState>
9
16
  setAudioSource(source: "glasses" | "phone"): Promise<AcsMeetingState>
10
17
  updateVideoSource(whepUrl: string): Promise<void>
11
18
  /** Force a WHEP rebuild on the current URL (phone changed networks). */
12
19
  restartVideoSource(): Promise<void>
20
+ /** SoftAP: join the glasses hotspot as a scoped network; resolves to the phone's IPv4 on it. Android only. */
21
+ joinScopedNetwork(ssid: string, passphrase: string): Promise<string>
22
+ beginTrace(traceId: string): Promise<void>
23
+ leaveScopedNetwork(): Promise<void>
24
+ /** SoftAP: TCP-probe the hotspot gateway over the scoped network. */
25
+ probeScopedGateway(): Promise<{reachable: boolean; detail: string}>
13
26
  getState(): Promise<AcsMeetingState>
14
27
  }
15
28
 
@@ -8,9 +8,15 @@ export default {
8
8
  async join(_options: AcsMeetingJoinOptions): Promise<AcsMeetingState> {
9
9
  unavailable()
10
10
  },
11
+ async prepareAgent(_options: {token: string; displayName?: string}): Promise<AcsMeetingState> {
12
+ unavailable()
13
+ },
11
14
  async leave(): Promise<void> {
12
15
  unavailable()
13
16
  },
17
+ async leaveAndAwait(_options: {timeoutMs: number}): Promise<{completed: boolean}> {
18
+ unavailable()
19
+ },
14
20
  async setMuted(_muted: boolean): Promise<AcsMeetingState> {
15
21
  unavailable()
16
22
  },
@@ -23,6 +29,13 @@ export default {
23
29
  async restartVideoSource(): Promise<void> {
24
30
  unavailable()
25
31
  },
32
+ async joinScopedNetwork(_ssid: string, _passphrase: string): Promise<string> {
33
+ unavailable()
34
+ },
35
+ async leaveScopedNetwork(): Promise<void> {},
36
+ async probeScopedGateway(): Promise<{reachable: boolean; detail: string}> {
37
+ return {reachable: false, detail: "no scoped network on web"}
38
+ },
26
39
  async getState(): Promise<AcsMeetingState> {
27
40
  return {state: "idle", muted: false}
28
41
  },