@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.
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
package/README.md CHANGED
@@ -30,11 +30,12 @@ advertise and send biplanar NV12 and read `codec=` on the ladder. Revert unless
30
30
  `codecName` leaves `h264 sw`.
31
31
 
32
32
  Mentra Call's persisted default is `720p15` (`VideoProfile.DEFAULT = HD`,
33
- 1280×720@15 / 2.5 Mbps). `540p15` (960×540@15 / 1.5 Mbps) is a user-selectable
33
+ 1280×720@15 / 3 Mbps). `540p15` (960×540@15 / 3 Mbps) is a user-selectable
34
34
  preset on the Home settings picker. It is not migrated onto existing installs
35
35
  and is not the native default. Miniapp joins pass width, height, fps, and
36
36
  `maxBitrateBps` through to native, so a selected `540p15` reaches both glasses
37
- WHIP and ACS without changing `VideoProfile.DEFAULT`.
37
+ WHIP and ACS without changing `VideoProfile.DEFAULT`. The ACS ceiling sits
38
+ above the glasses→phone WHIP default (2.5 Mbps) so Teams is not the bottleneck.
38
39
 
39
40
  ## The pipeline in one picture
40
41
 
@@ -94,6 +95,7 @@ android/src/main/java/com/mentra/acsmeeting/
94
95
  └── telemetry/ The measurement ladder
95
96
  ├── PipelineStats.kt Every counter, and the 1 Hz "P6 ladder" line
96
97
  ├── PipelineTicker.kt Emits that line on a timer
98
+ ├── AvSyncProbe.kt Clap correlator: audio lead vs video luma at ingest
97
99
  ├── RingPercentile.kt p50/p95 over a fixed ring of samples
98
100
  └── ChromaProbe.kt Plane averages, to catch a mis-packed frame
99
101
  ```
@@ -32,6 +32,7 @@ android {
32
32
  defaultConfig {
33
33
  versionCode 1
34
34
  versionName "0.1.0"
35
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
35
36
  }
36
37
  compileOptions {
37
38
  coreLibraryDesugaringEnabled true
@@ -45,10 +46,25 @@ android {
45
46
  testOptions {
46
47
  unitTests {
47
48
  includeAndroidResources = true
49
+ // WhipIngestServer is exercised over a real loopback socket in JVM tests, and its stage
50
+ // logging reaches android.util.Log. Without this the stubbed android.jar throws "Stub!"
51
+ // and the socket behaviour we actually want to assert is unreachable.
52
+ returnDefaultValues = true
48
53
  }
49
54
  }
50
55
  }
51
56
 
57
+ // Expo assemble uses --configure-on-demand --build-cache. Incremental Kotlin in this
58
+ // module has already produced two poisoned outputs (a phantom canReuseSource overload,
59
+ // then WhenMappings "already exists"). A full recompile of this library is cheap; a
60
+ // half-written runtime_library_classes_dir is not. Do not delete AGP's dest in doFirst —
61
+ // that leaves bundleLibRuntimeToDirDebug as a missing directory on the next assemble.
62
+ afterEvaluate {
63
+ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
64
+ incremental = false
65
+ }
66
+ }
67
+
52
68
  dependencies {
53
69
  coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
54
70
  implementation 'com.squareup.okhttp3:okhttp:4.12.0'
@@ -60,5 +76,12 @@ dependencies {
60
76
  // of shipping two WebRTC stacks. P4 confirms remote AudioTrackSink.
61
77
  implementation 'io.github.webrtc-sdk:android:137.7151.05'
62
78
  testImplementation 'junit:junit:4.13.2'
79
+ testImplementation 'org.mockito:mockito-core:5.11.0'
63
80
  testImplementation 'org.assertj:assertj-core:3.24.2'
81
+ // Phase 0.5 SoftAP feasibility gate. These run on a device, not in CI: the gate needs a real
82
+ // phone with cellular active and the glasses hotspot up. See SoftApFeasibilityGateTest.
83
+ androidTestImplementation 'androidx.test:runner:1.5.2'
84
+ androidTestImplementation 'androidx.test:rules:1.5.0'
85
+ androidTestImplementation 'androidx.test.ext:junit:1.1.5'
86
+ androidTestImplementation 'org.assertj:assertj-core:3.24.2'
64
87
  }
@@ -0,0 +1,291 @@
1
+ package com.mentra.acsmeeting.network
2
+
3
+ import android.content.Context
4
+ import android.net.ConnectivityManager
5
+ import android.net.NetworkCapabilities
6
+ import androidx.test.platform.app.InstrumentationRegistry
7
+ import java.net.DatagramPacket
8
+ import java.net.DatagramSocket
9
+ import java.net.InetAddress
10
+ import java.net.InetSocketAddress
11
+ import java.net.ServerSocket
12
+ import java.net.Socket
13
+ import java.util.concurrent.CountDownLatch
14
+ import java.util.concurrent.TimeUnit
15
+ import org.assertj.core.api.Assertions.assertThat
16
+ import org.junit.Assume.assumeTrue
17
+ import org.junit.Before
18
+ import org.junit.Test
19
+ import org.webrtc.DefaultVideoDecoderFactory
20
+ import org.webrtc.DefaultVideoEncoderFactory
21
+ import org.webrtc.EglBase
22
+ import org.webrtc.IceCandidate
23
+ import org.webrtc.MediaConstraints
24
+ import org.webrtc.PeerConnection
25
+ import org.webrtc.PeerConnectionFactory
26
+ import org.webrtc.SdpObserver
27
+ import org.webrtc.SessionDescription
28
+
29
+ /**
30
+ * PHASE 0.5 HARD GATE — the six facts the SoftAP architecture depends on.
31
+ *
32
+ * This is a real-device test, deliberately excluded from CI: it needs a phone with cellular data
33
+ * active and the glasses hotspot broadcasting. Run it before building the WHIP ingest server. If
34
+ * [libwebrtc emits a hotspot host candidate][iceGatheringProducesAHotspotHostCandidate] fails, stock
35
+ * libwebrtc is not enumerating the no-INTERNET network and [ScopedNetworkChangeDetector] must be
36
+ * installed and this re-run before Phase 3 proceeds.
37
+ *
38
+ * ```
39
+ * cd mobile/android && ./gradlew :mentra-acs-meeting:connectedDebugAndroidTest \
40
+ * -Pandroid.testInstrumentationRunnerArguments.softApSsid=MentraLive-1234 \
41
+ * -Pandroid.testInstrumentationRunnerArguments.softApPassphrase=<passphrase>
42
+ * ```
43
+ *
44
+ * Without those arguments every test is skipped rather than failing, so an accidental CI run is
45
+ * inert rather than red.
46
+ *
47
+ * The three glasses-initiated facts (inbound TCP, inbound UDP, cleartext WHIP POST) are the reason
48
+ * gallery sync is not sufficient evidence: sync only proves phone -> glasses, and here the glasses
49
+ * connect *to* a server on the phone. AP client isolation, firewall rules, or Android's inbound
50
+ * local-network policy can each break that while sync keeps working. This class proves the phone
51
+ * side accepts inbound connections on the hotspot address; driving the glasses to actually dial in
52
+ * is the Phase 7 device proof.
53
+ */
54
+ class SoftApFeasibilityGateTest {
55
+
56
+ private lateinit var context: Context
57
+ private lateinit var scoped: ScopedSoftApNetwork
58
+ private var ssid: String = ""
59
+ private var passphrase: String = ""
60
+
61
+ @Before
62
+ fun setUp() {
63
+ val arguments = InstrumentationRegistry.getArguments()
64
+ ssid = arguments.getString("softApSsid").orEmpty()
65
+ passphrase = arguments.getString("softApPassphrase").orEmpty()
66
+ assumeTrue("softApSsid instrumentation argument is required", ssid.isNotEmpty())
67
+
68
+ context = InstrumentationRegistry.getInstrumentation().targetContext
69
+ scoped = ScopedSoftApNetwork(context)
70
+ }
71
+
72
+ // -----------------------------------------------------------------
73
+ // (6) ACS/internet traffic stays on cellular
74
+ // -----------------------------------------------------------------
75
+
76
+ @Test
77
+ fun scopedJoinKeepsCellularAsTheDefaultNetwork() {
78
+ scoped.join(ssid, passphrase)
79
+ try {
80
+ assertThat(scoped.isAvailable()).isTrue()
81
+ assertThat(scoped.localIpv4()).isNotNull()
82
+
83
+ val manager = context.getSystemService(ConnectivityManager::class.java)
84
+ val default = requireNotNull(manager.activeNetwork) { "no default network" }
85
+ val capabilities = requireNotNull(manager.getNetworkCapabilities(default))
86
+
87
+ assertThat(capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR))
88
+ .describedAs("default network must remain cellular after the scoped join")
89
+ .isTrue()
90
+ assertThat(capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET))
91
+ .isTrue()
92
+ } finally {
93
+ scoped.release()
94
+ }
95
+ }
96
+
97
+ // -----------------------------------------------------------------
98
+ // (1) inbound TCP on the hotspot address
99
+ // -----------------------------------------------------------------
100
+
101
+ @Test
102
+ fun phoneAcceptsInboundTcpOnTheHotspotAddress() {
103
+ scoped.join(ssid, passphrase)
104
+ try {
105
+ val local = InetAddress.getByName(requireNotNull(scoped.localIpv4()))
106
+ val accepted = CountDownLatch(1)
107
+
108
+ ServerSocket().use { server ->
109
+ server.reuseAddress = true
110
+ server.bind(InetSocketAddress(local, 0))
111
+
112
+ Thread {
113
+ runCatching { server.accept().use { accepted.countDown() } }
114
+ }
115
+ .apply { isDaemon = true }
116
+ .start()
117
+
118
+ Socket().use { client ->
119
+ client.connect(InetSocketAddress(local, server.localPort), CONNECT_TIMEOUT_MS)
120
+ }
121
+
122
+ assertThat(accepted.await(5, TimeUnit.SECONDS))
123
+ .describedAs("inbound TCP on %s was not accepted", local)
124
+ .isTrue()
125
+ }
126
+ } finally {
127
+ scoped.release()
128
+ }
129
+ }
130
+
131
+ // -----------------------------------------------------------------
132
+ // (2) inbound UDP on the hotspot address
133
+ // -----------------------------------------------------------------
134
+
135
+ @Test
136
+ fun phoneReceivesUdpOnTheHotspotAddress() {
137
+ scoped.join(ssid, passphrase)
138
+ try {
139
+ val local = InetAddress.getByName(requireNotNull(scoped.localIpv4()))
140
+
141
+ DatagramSocket(null).use { receiver ->
142
+ receiver.reuseAddress = true
143
+ receiver.bind(InetSocketAddress(local, 0))
144
+ receiver.soTimeout = 5_000
145
+
146
+ val payload = "softap-gate".toByteArray()
147
+ DatagramSocket().use { sender ->
148
+ sender.send(DatagramPacket(payload, payload.size, local, receiver.localPort))
149
+ }
150
+
151
+ val buffer = ByteArray(64)
152
+ val packet = DatagramPacket(buffer, buffer.size)
153
+ receiver.receive(packet)
154
+
155
+ assertThat(String(packet.data, 0, packet.length)).isEqualTo("softap-gate")
156
+ }
157
+ } finally {
158
+ scoped.release()
159
+ }
160
+ }
161
+
162
+ // -----------------------------------------------------------------
163
+ // (4) + (5) libwebrtc gathers, and can select, a hotspot host candidate
164
+ // -----------------------------------------------------------------
165
+
166
+ /**
167
+ * The reviewer's primary risk. A scoped `Network` alone does not make libwebrtc's internally
168
+ * created UDP sockets use that interface, and its network monitor only watches
169
+ * internet-capable networks. If this fails, install [ScopedNetworkChangeDetector].
170
+ */
171
+ @Test
172
+ fun iceGatheringProducesAHotspotHostCandidate() {
173
+ scoped.join(ssid, passphrase)
174
+ val localIpv4 = requireNotNull(scoped.localIpv4())
175
+
176
+ PeerConnectionFactory.initialize(
177
+ PeerConnectionFactory.InitializationOptions.builder(context)
178
+ .createInitializationOptions(),
179
+ )
180
+ val eglBase = EglBase.create()
181
+ val factory =
182
+ PeerConnectionFactory.builder()
183
+ .setVideoEncoderFactory(DefaultVideoEncoderFactory(eglBase.eglBaseContext, true, true))
184
+ .setVideoDecoderFactory(DefaultVideoDecoderFactory(eglBase.eglBaseContext))
185
+ .createPeerConnectionFactory()
186
+
187
+ val candidates = mutableListOf<IceCandidate>()
188
+ val gathered = CountDownLatch(1)
189
+
190
+ // No ICE servers: host-only, matching what the ingest source will use.
191
+ val config = PeerConnection.RTCConfiguration(emptyList())
192
+ config.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN
193
+ config.continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_ONCE
194
+
195
+ val peer =
196
+ requireNotNull(
197
+ factory.createPeerConnection(
198
+ config,
199
+ object : NoOpPeerConnectionObserver() {
200
+ override fun onIceCandidate(candidate: IceCandidate) {
201
+ synchronized(candidates) { candidates.add(candidate) }
202
+ }
203
+
204
+ override fun onIceGatheringChange(
205
+ newState: PeerConnection.IceGatheringState,
206
+ ) {
207
+ if (newState == PeerConnection.IceGatheringState.COMPLETE) {
208
+ gathered.countDown()
209
+ }
210
+ }
211
+ },
212
+ ),
213
+ )
214
+
215
+ try {
216
+ peer.addTransceiver(org.webrtc.MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO)
217
+ peer.createOffer(
218
+ object : NoOpSdpObserver() {
219
+ override fun onCreateSuccess(sdp: SessionDescription) {
220
+ peer.setLocalDescription(NoOpSdpObserver(), sdp)
221
+ }
222
+ },
223
+ MediaConstraints(),
224
+ )
225
+
226
+ assertThat(gathered.await(15, TimeUnit.SECONDS))
227
+ .describedAs("ICE gathering did not complete")
228
+ .isTrue()
229
+
230
+ val hotspotCandidates =
231
+ synchronized(candidates) {
232
+ candidates.filter { it.sdp.contains("typ host") && it.sdp.contains(localIpv4) }
233
+ }
234
+
235
+ assertThat(hotspotCandidates)
236
+ .describedAs(
237
+ "libwebrtc gathered no host candidate on the SoftAP address %s. " +
238
+ "All candidates: %s. Install ScopedNetworkChangeDetector before Phase 3.",
239
+ localIpv4,
240
+ synchronized(candidates) { candidates.map { it.sdp } },
241
+ )
242
+ .isNotEmpty()
243
+ } finally {
244
+ peer.dispose()
245
+ factory.dispose()
246
+ eglBase.release()
247
+ scoped.release()
248
+ }
249
+ }
250
+
251
+ private open class NoOpPeerConnectionObserver : PeerConnection.Observer {
252
+ override fun onSignalingChange(state: PeerConnection.SignalingState) {}
253
+
254
+ override fun onIceConnectionChange(state: PeerConnection.IceConnectionState) {}
255
+
256
+ override fun onIceConnectionReceivingChange(receiving: Boolean) {}
257
+
258
+ override fun onIceGatheringChange(newState: PeerConnection.IceGatheringState) {}
259
+
260
+ override fun onIceCandidate(candidate: IceCandidate) {}
261
+
262
+ override fun onIceCandidatesRemoved(candidates: Array<out IceCandidate>) {}
263
+
264
+ override fun onAddStream(stream: org.webrtc.MediaStream) {}
265
+
266
+ override fun onRemoveStream(stream: org.webrtc.MediaStream) {}
267
+
268
+ override fun onDataChannel(channel: org.webrtc.DataChannel) {}
269
+
270
+ override fun onRenegotiationNeeded() {}
271
+
272
+ override fun onAddTrack(
273
+ receiver: org.webrtc.RtpReceiver,
274
+ streams: Array<out org.webrtc.MediaStream>,
275
+ ) {}
276
+ }
277
+
278
+ private open class NoOpSdpObserver : SdpObserver {
279
+ override fun onCreateSuccess(sdp: SessionDescription) {}
280
+
281
+ override fun onSetSuccess() {}
282
+
283
+ override fun onCreateFailure(error: String?) {}
284
+
285
+ override fun onSetFailure(error: String?) {}
286
+ }
287
+
288
+ private companion object {
289
+ const val CONNECT_TIMEOUT_MS = 5_000
290
+ }
291
+ }
@@ -1,5 +1,7 @@
1
1
  <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
2
  <uses-permission android:name="android.permission.INTERNET" />
3
3
  <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
4
+ <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
5
+ <uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" android:usesPermissionFlags="neverForLocation" />
4
6
  <uses-permission android:name="android.permission.RECORD_AUDIO" />
5
7
  </manifest>
@@ -0,0 +1,36 @@
1
+ package com.mentra.acsmeeting
2
+
3
+ import java.util.concurrent.Future
4
+ import java.util.concurrent.TimeUnit
5
+
6
+ /**
7
+ * Recover a [com.azure.android.communication.calling.CallAgent] from a `createCallAgent` Future
8
+ * that we stopped waiting on.
9
+ *
10
+ * ACS still finishes signing in after the wait is abandoned. Cancelling that Future drops the only
11
+ * Java handle, so the next join dies with "an instance of CallAgent associated with this identity
12
+ * already exists". Take the completed value — never cancel — and dispose it.
13
+ */
14
+ internal object AbandonedCallAgent {
15
+ fun <T> takeIfDone(pending: Future<T>): T? {
16
+ if (!pending.isDone) return null
17
+ return runCatching { pending.get() }.getOrNull()
18
+ }
19
+
20
+ fun <T> take(pending: Future<T>, waitMs: Long): T? {
21
+ if (waitMs <= 0L) return takeIfDone(pending)
22
+ return runCatching { pending.get(waitMs, TimeUnit.MILLISECONDS) }.getOrNull()
23
+ }
24
+
25
+ fun isExistingAgentError(error: Throwable): Boolean {
26
+ var current: Throwable? = error
27
+ while (current != null) {
28
+ val message = current.message.orEmpty()
29
+ if (message.contains("CallAgent associated with this identity already exists", ignoreCase = true)) {
30
+ return true
31
+ }
32
+ current = current.cause
33
+ }
34
+ return false
35
+ }
36
+ }