@mentra/acs-meeting 3.2.0-dev.223 → 3.2.0-dev.225

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 (106) hide show
  1. package/README.md +14 -3
  2. package/android/build.gradle +1 -0
  3. package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingModule.kt +10 -10
  4. package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingSession.kt +37 -37
  5. package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkSender.kt +4 -4
  6. package/android/src/main/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpec.kt +3 -0
  7. package/android/src/main/java/com/mentra/acsmeeting/video/AcsFrameSender.kt +12 -10
  8. package/android/src/main/java/com/mentra/acsmeeting/video/I420FormatSpec.kt +2 -0
  9. package/android/src/main/java/com/mentra/acsmeeting/video/Nv12Packer.kt +2 -0
  10. package/android/src/test/java/com/mentra/acsmeeting/source/{AcsInvestigationTest.kt → MediaDiagnosticsTest.kt} +10 -10
  11. package/android/src/test/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpecTest.kt +3 -0
  12. package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoQualityTest.kt +2 -2
  13. package/android/src/test/java/com/mentra/acsmeeting/video/I420FormatSpecTest.kt +2 -0
  14. package/android/src/test/java/com/mentra/acsmeeting/video/VideoProfileTest.kt +2 -2
  15. package/build/AcsMeeting.types.d.ts +10 -1
  16. package/build/AcsMeeting.types.d.ts.map +1 -1
  17. package/build/AcsMeeting.types.js.map +1 -1
  18. package/build/AcsMeetingModule.d.ts +2 -2
  19. package/build/AcsMeetingModule.d.ts.map +1 -1
  20. package/build/AcsMeetingModule.js.map +1 -1
  21. package/ios/AcsMeeting.podspec +1 -1
  22. package/ios/AcsMeetingModule.swift +284 -148
  23. package/ios/PolicyKit/Package.swift +2 -1
  24. package/ios/PolicyKit/Sources/AcsAudioPolicy/CallbackOperation.swift +60 -0
  25. package/ios/PolicyKit/Sources/AcsAudioPolicy/CapturePolicy.swift +5 -0
  26. package/ios/PolicyKit/Tests/AcsAudioPolicyTests/AcsAudioPolicyTests.swift +1 -0
  27. package/ios/PolicyKit/Tests/AcsAudioPolicyTests/CallAgentRetirementTests.swift +113 -0
  28. package/package.json +3 -2
  29. package/src/AcsMeeting.types.ts +4 -1
  30. package/src/AcsMeetingModule.ts +2 -2
  31. package/android/src/androidTest/java/com/mentra/acsmeeting/network/SoftApFeasibilityGateTest.kt +0 -291
  32. package/android/src/main/java/com/mentra/acsmeeting/network/InternetHold.kt +0 -501
  33. package/android/src/main/java/com/mentra/acsmeeting/network/Ipv4Prefix.kt +0 -51
  34. package/android/src/main/java/com/mentra/acsmeeting/network/NetworkFacts.kt +0 -81
  35. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetector.kt +0 -355
  36. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkError.kt +0 -109
  37. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkObserver.kt +0 -160
  38. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkReadiness.kt +0 -103
  39. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpec.kt +0 -53
  40. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkState.kt +0 -127
  41. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedSoftApNetwork.kt +0 -573
  42. package/android/src/main/java/com/mentra/acsmeeting/source/BitmapFont.kt +0 -99
  43. package/android/src/main/java/com/mentra/acsmeeting/source/CloudflareWhepSource.kt +0 -487
  44. package/android/src/main/java/com/mentra/acsmeeting/source/DecodedTrackRelay.kt +0 -147
  45. package/android/src/main/java/com/mentra/acsmeeting/source/FirstFrameGate.kt +0 -45
  46. package/android/src/main/java/com/mentra/acsmeeting/source/GlassesMediaSource.kt +0 -143
  47. package/android/src/main/java/com/mentra/acsmeeting/source/LocalWhipIngestSource.kt +0 -772
  48. package/android/src/main/java/com/mentra/acsmeeting/source/MediaListeners.kt +0 -57
  49. package/android/src/main/java/com/mentra/acsmeeting/source/SdpAdapter.kt +0 -17
  50. package/android/src/main/java/com/mentra/acsmeeting/source/SelectedIcePair.kt +0 -139
  51. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIceDiagnosis.kt +0 -144
  52. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIcePolicy.kt +0 -46
  53. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApSdpGuard.kt +0 -160
  54. package/android/src/main/java/com/mentra/acsmeeting/source/SyntheticFrameFactory.kt +0 -205
  55. package/android/src/main/java/com/mentra/acsmeeting/source/SyntheticI420Source.kt +0 -214
  56. package/android/src/main/java/com/mentra/acsmeeting/source/TrackRegistry.kt +0 -33
  57. package/android/src/main/java/com/mentra/acsmeeting/source/VideoSourceArm.kt +0 -110
  58. package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestProtocol.kt +0 -207
  59. package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestServer.kt +0 -356
  60. package/android/src/main/java/com/mentra/acsmeeting/telemetry/AvSyncProbe.kt +0 -155
  61. package/android/src/main/java/com/mentra/acsmeeting/telemetry/ChromaProbe.kt +0 -78
  62. package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineStats.kt +0 -372
  63. package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineTicker.kt +0 -79
  64. package/android/src/main/java/com/mentra/acsmeeting/telemetry/ProcessCpu.kt +0 -15
  65. package/android/src/main/java/com/mentra/acsmeeting/telemetry/RingPercentile.kt +0 -48
  66. package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoQuality.kt +0 -178
  67. package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoRateVerdict.kt +0 -145
  68. package/android/src/main/java/com/mentra/acsmeeting/trace/SoftApTrace.kt +0 -143
  69. package/android/src/main/java/com/mentra/acsmeeting/video/FrameGeometry.kt +0 -22
  70. package/android/src/main/java/com/mentra/acsmeeting/video/I420Packer.kt +0 -72
  71. package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldLifecycleTest.kt +0 -102
  72. package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldTest.kt +0 -148
  73. package/android/src/test/java/com/mentra/acsmeeting/network/Ipv4PrefixTest.kt +0 -59
  74. package/android/src/test/java/com/mentra/acsmeeting/network/NetworkFactsTest.kt +0 -59
  75. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedLocalNetworkPermissionTest.kt +0 -35
  76. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetectorTest.kt +0 -165
  77. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkErrorTest.kt +0 -96
  78. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkLifecycleTest.kt +0 -187
  79. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkObserverTest.kt +0 -172
  80. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkReadinessTest.kt +0 -141
  81. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpecTest.kt +0 -69
  82. package/android/src/test/java/com/mentra/acsmeeting/source/BitmapFontTest.kt +0 -22
  83. package/android/src/test/java/com/mentra/acsmeeting/source/DecodedTrackRelayTest.kt +0 -41
  84. package/android/src/test/java/com/mentra/acsmeeting/source/FirstFrameGateTest.kt +0 -60
  85. package/android/src/test/java/com/mentra/acsmeeting/source/GlassesMediaSourceTest.kt +0 -151
  86. package/android/src/test/java/com/mentra/acsmeeting/source/I420PlanesTest.kt +0 -67
  87. package/android/src/test/java/com/mentra/acsmeeting/source/SelectedIcePairTest.kt +0 -165
  88. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIceDiagnosisTest.kt +0 -148
  89. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIcePolicyTest.kt +0 -76
  90. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApSdpGuardTest.kt +0 -275
  91. package/android/src/test/java/com/mentra/acsmeeting/source/SyntheticFrameFactoryTest.kt +0 -193
  92. package/android/src/test/java/com/mentra/acsmeeting/source/SyntheticI420SourceTest.kt +0 -176
  93. package/android/src/test/java/com/mentra/acsmeeting/source/TrackRegistryTest.kt +0 -45
  94. package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestProtocolTest.kt +0 -356
  95. package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestServerLoopbackTest.kt +0 -411
  96. package/android/src/test/java/com/mentra/acsmeeting/telemetry/AvSyncProbeTest.kt +0 -83
  97. package/android/src/test/java/com/mentra/acsmeeting/telemetry/ChromaProbeTest.kt +0 -61
  98. package/android/src/test/java/com/mentra/acsmeeting/telemetry/PipelineStatsTest.kt +0 -322
  99. package/android/src/test/java/com/mentra/acsmeeting/telemetry/ProcessCpuTest.kt +0 -18
  100. package/android/src/test/java/com/mentra/acsmeeting/telemetry/RingPercentileTest.kt +0 -49
  101. package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoRateVerdictTest.kt +0 -149
  102. package/android/src/test/java/com/mentra/acsmeeting/trace/SoftApTraceTest.kt +0 -97
  103. package/android/src/test/java/com/mentra/acsmeeting/video/FrameGeometryTest.kt +0 -33
  104. package/android/src/test/java/com/mentra/acsmeeting/video/I420PackerTest.kt +0 -126
  105. package/ios/PolicyKit/Sources/AcsAudioPolicy/GlassesMediaSource.swift +0 -68
  106. package/ios/WhepVideoSource.swift +0 -356
@@ -49,3 +49,63 @@ public enum CallbackOperationError: Error {
49
49
  case missingResult
50
50
  case timedOut
51
51
  }
52
+
53
+ /// Retires one call agent when hang-up completes or its deadline expires. The
54
+ /// session invokes finish on its serial queue; disposal must precede opening the
55
+ /// barrier, and a late SDK callback must not release a newer call's reservation.
56
+ final class CallAgentRetirement {
57
+ private let group: DispatchGroup
58
+ private var dispose: (() -> Void)?
59
+
60
+ init(group: DispatchGroup, queue: DispatchQueue, timeout: TimeInterval = 10, dispose: @escaping () -> Void) {
61
+ self.group = group
62
+ self.dispose = dispose
63
+ group.enter()
64
+ queue.asyncAfter(deadline: .now() + timeout) { self.finish() }
65
+ }
66
+
67
+ func finish() {
68
+ guard let dispose else { return }
69
+ self.dispose = nil
70
+ dispose()
71
+ group.leave()
72
+ }
73
+ }
74
+
75
+ /// Keeps an in-flight join's agent alive after cancellation until its returned
76
+ /// call is hung up. All methods run on the session queue, as do hang-up callbacks.
77
+ final class CallJoinRetirement<Call> {
78
+ private let group: DispatchGroup
79
+ private let queue: DispatchQueue
80
+ private let timeout: TimeInterval
81
+ private let dispose: () -> Void
82
+ private let hangUp: (Call, @escaping () -> Void) -> Void
83
+ private var retirement: CallAgentRetirement?
84
+ private var completed = false
85
+
86
+ init(group: DispatchGroup, queue: DispatchQueue, timeout: TimeInterval = 10,
87
+ dispose: @escaping () -> Void, hangUp: @escaping (Call, @escaping () -> Void) -> Void)
88
+ {
89
+ self.group = group
90
+ self.queue = queue
91
+ self.timeout = timeout
92
+ self.dispose = dispose
93
+ self.hangUp = hangUp
94
+ }
95
+
96
+ func cancel() {
97
+ guard !completed, retirement == nil else { return }
98
+ retirement = CallAgentRetirement(group: group, queue: queue, timeout: timeout, dispose: dispose)
99
+ }
100
+
101
+ /// Returns true only when the session may adopt this join result.
102
+ func receive(_ call: Call?) -> Bool {
103
+ guard !completed else { return false }
104
+ completed = true
105
+ guard let retirement else { return true }
106
+ if let call {
107
+ hangUp(call) { self.queue.async { retirement.finish() } }
108
+ } else { retirement.finish() }
109
+ return false
110
+ }
111
+ }
@@ -0,0 +1,5 @@
1
+ public enum CapturePolicy {
2
+ public static func captureGlassesMic(_ source: AudioSourceKind) -> Bool {
3
+ source == .glasses
4
+ }
5
+ }
@@ -1,3 +1,4 @@
1
+ import GlassesMediaCore
1
2
  import XCTest
2
3
  @testable import AcsAudioPolicy
3
4
 
@@ -0,0 +1,113 @@
1
+ @testable import AcsAudioPolicy
2
+ import Foundation
3
+ import XCTest
4
+
5
+ final class CallAgentRetirementTests: XCTestCase {
6
+ func testCancelledJoinWithNoCallbackStillRetiresAtDeadline() {
7
+ let queue = DispatchQueue(label: "retirement.missing-join")
8
+ let group = DispatchGroup()
9
+ var disposals = 0
10
+ queue.sync {
11
+ let join = CallJoinRetirement<Int>(group: group, queue: queue, timeout: 0.01, dispose: { disposals += 1 }) { _, _ in
12
+ XCTFail("No call was returned to hang up")
13
+ }
14
+ join.cancel()
15
+ XCTAssertEqual(group.wait(timeout: .now()), .timedOut)
16
+ }
17
+ XCTAssertEqual(group.wait(timeout: .now() + 1), .success)
18
+ queue.sync { XCTAssertEqual(disposals, 1) }
19
+ }
20
+
21
+ func testSuccessfulJoinTransfersOwnershipWithoutDisposal() {
22
+ let queue = DispatchQueue(label: "retirement.successful-join")
23
+ let group = DispatchGroup()
24
+ queue.sync {
25
+ let join = CallJoinRetirement<Int>(group: group, queue: queue, dispose: { XCTFail("Session owns this agent") }) { _, _ in
26
+ XCTFail("Session owns this call")
27
+ }
28
+ XCTAssertTrue(join.receive(42))
29
+ join.cancel()
30
+ XCTAssertEqual(group.wait(timeout: .now()), .success)
31
+ }
32
+ }
33
+
34
+ func testCancelledJoinRetainsAgentUntilLateCallFinishesHangingUp() {
35
+ let queue = DispatchQueue(label: "retirement.cancelled-join")
36
+ let group = DispatchGroup()
37
+ let joinReturned = expectation(description: "cancelled join returned a call")
38
+ var disposals = 0
39
+ var hangUpCompleted: (() -> Void)?
40
+ queue.sync {
41
+ let join = CallJoinRetirement<Int>(group: group, queue: queue, dispose: { disposals += 1 }) { call, finished in
42
+ XCTAssertEqual(call, 42)
43
+ XCTAssertEqual(disposals, 0)
44
+ hangUpCompleted = finished
45
+ joinReturned.fulfill()
46
+ }
47
+ join.cancel()
48
+ join.cancel()
49
+ XCTAssertFalse(join.receive(42))
50
+ }
51
+ wait(for: [joinReturned], timeout: 1)
52
+ queue.sync {
53
+ XCTAssertEqual(group.wait(timeout: .now()), .timedOut)
54
+ XCTAssertEqual(disposals, 0)
55
+ hangUpCompleted?()
56
+ }
57
+ XCTAssertEqual(group.wait(timeout: .now() + 1), .success)
58
+ queue.sync { XCTAssertEqual(disposals, 1) }
59
+ }
60
+
61
+ func testMissingHangUpCallbackDisposesAgentBeforeUnblockingLeave() {
62
+ let queue = DispatchQueue(label: "retirement.timeout")
63
+ let group = DispatchGroup()
64
+ var disposals = 0
65
+ queue.sync {
66
+ _ = CallAgentRetirement(group: group, queue: queue, timeout: 0.01) {
67
+ XCTAssertEqual(group.wait(timeout: .now()), .timedOut)
68
+ disposals += 1
69
+ }
70
+ XCTAssertEqual(group.wait(timeout: .now()), .timedOut)
71
+ }
72
+ XCTAssertEqual(group.wait(timeout: .now() + 1), .success)
73
+ queue.sync { XCTAssertEqual(disposals, 1) }
74
+ }
75
+
76
+ func testLateCallbackCannotReleaseNextCallsCleanupBarrier() {
77
+ let queue = DispatchQueue(label: "retirement.late")
78
+ let group = DispatchGroup()
79
+ var old: CallAgentRetirement!
80
+ var disposals = 0
81
+ queue.sync {
82
+ old = CallAgentRetirement(group: group, queue: queue, timeout: 0.01) { disposals += 1 }
83
+ }
84
+ XCTAssertEqual(group.wait(timeout: .now() + 1), .success)
85
+ queue.sync {
86
+ let next = CallAgentRetirement(group: group, queue: queue) { disposals += 1 }
87
+ old.finish()
88
+ XCTAssertEqual(disposals, 1)
89
+ XCTAssertEqual(group.wait(timeout: .now()), .timedOut)
90
+ next.finish()
91
+ XCTAssertEqual(disposals, 2)
92
+ XCTAssertEqual(group.wait(timeout: .now()), .success)
93
+ }
94
+ }
95
+
96
+ func testHangUpCompletionAndDeadlineDisposeExactlyOnce() {
97
+ let queue = DispatchQueue(label: "retirement.completed")
98
+ let group = DispatchGroup()
99
+ let deadlinePassed = expectation(description: "deadline fired after callback")
100
+ var disposals = 0
101
+ queue.sync {
102
+ let retirement = CallAgentRetirement(group: group, queue: queue, timeout: 0.01) { disposals += 1 }
103
+ retirement.finish()
104
+ retirement.finish()
105
+ XCTAssertEqual(group.wait(timeout: .now()), .success)
106
+ queue.asyncAfter(deadline: .now() + 0.03) {
107
+ XCTAssertEqual(disposals, 1)
108
+ deadlinePassed.fulfill()
109
+ }
110
+ }
111
+ wait(for: [deadlinePassed], timeout: 1)
112
+ }
113
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/acs-meeting",
3
- "version": "3.2.0-dev.223",
3
+ "version": "3.2.0-dev.225",
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",
@@ -31,7 +31,8 @@
31
31
  "access": "public"
32
32
  },
33
33
  "dependencies": {
34
- "@expo/config-plugins": "~55.0.10"
34
+ "@expo/config-plugins": "~55.0.10",
35
+ "@mentra/glasses-media": "3.2.0-dev.225"
35
36
  },
36
37
  "devDependencies": {
37
38
  "expo-module-scripts": "^55.0.2",
@@ -30,6 +30,8 @@ export type AcsMeetingState = {
30
30
  activeStream?: AcsActiveStream
31
31
  audioSafety?: AcsAudioSafety
32
32
  mediaSource?: AcsMediaSourceState
33
+ /** Local WHIP endpoint, available after a SoftAP join. */
34
+ ingestUrl?: string
33
35
  /** Remote roster (Android emits this; iOS does not yet). */
34
36
  participants?: AcsMeetingParticipant[]
35
37
  }
@@ -44,7 +46,8 @@ export type AcsOutgoingVideo = {
44
46
  export type AcsMeetingJoinOptions = {
45
47
  meetingUrl: string
46
48
  token: string
47
- whepUrl: string
49
+ whepUrl?: string
50
+ videoSource?: {type: "whep"; url: string} | {type: "softap"; bindAddress: string}
48
51
  displayName?: string
49
52
  /** "glasses" sends WHEP PCM. "phone" uses the ACS local mic (handset or BT). */
50
53
  audioSource?: AcsAudioSource
@@ -9,7 +9,7 @@ declare class AcsMeetingNativeModule extends NativeModule<AcsMeetingModuleEvents
9
9
  leave(): Promise<void>
10
10
  /**
11
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.
12
+ * finished. Use this explicit barrier across platforms; Android `leave()` only queues cleanup.
13
13
  */
14
14
  leaveAndAwait(options: {timeoutMs: number}): Promise<{completed: boolean}>
15
15
  setMuted(muted: boolean): Promise<AcsMeetingState>
@@ -17,7 +17,7 @@ declare class AcsMeetingNativeModule extends NativeModule<AcsMeetingModuleEvents
17
17
  updateVideoSource(whepUrl: string): Promise<void>
18
18
  /** Force a WHEP rebuild on the current URL (phone changed networks). */
19
19
  restartVideoSource(): Promise<void>
20
- /** SoftAP: join the glasses hotspot as a scoped network; resolves to the phone's IPv4 on it. Android only. */
20
+ /** SoftAP: join the glasses hotspot; resolves to the phone's IPv4 on it. */
21
21
  joinScopedNetwork(ssid: string, passphrase: string): Promise<string>
22
22
  beginTrace(traceId: string): Promise<void>
23
23
  leaveScopedNetwork(): Promise<void>
@@ -1,291 +0,0 @@
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
- }