@mentra/acs-meeting 3.2.0-dev.222 → 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.
- package/README.md +14 -3
- package/android/build.gradle +1 -0
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingModule.kt +10 -10
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingSession.kt +37 -37
- package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkSender.kt +4 -4
- package/android/src/main/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpec.kt +3 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/AcsFrameSender.kt +12 -10
- package/android/src/main/java/com/mentra/acsmeeting/video/I420FormatSpec.kt +2 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/Nv12Packer.kt +2 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/{AcsInvestigationTest.kt → MediaDiagnosticsTest.kt} +10 -10
- package/android/src/test/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpecTest.kt +3 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoQualityTest.kt +2 -2
- package/android/src/test/java/com/mentra/acsmeeting/video/I420FormatSpecTest.kt +2 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/VideoProfileTest.kt +2 -2
- package/build/AcsMeeting.types.d.ts +10 -1
- package/build/AcsMeeting.types.d.ts.map +1 -1
- package/build/AcsMeeting.types.js.map +1 -1
- package/build/AcsMeetingModule.d.ts +2 -2
- package/build/AcsMeetingModule.d.ts.map +1 -1
- package/build/AcsMeetingModule.js.map +1 -1
- package/ios/AcsMeeting.podspec +1 -1
- package/ios/AcsMeetingModule.swift +284 -148
- package/ios/PolicyKit/Package.swift +2 -1
- package/ios/PolicyKit/Sources/AcsAudioPolicy/CallbackOperation.swift +60 -0
- package/ios/PolicyKit/Sources/AcsAudioPolicy/CapturePolicy.swift +5 -0
- package/ios/PolicyKit/Tests/AcsAudioPolicyTests/AcsAudioPolicyTests.swift +1 -0
- package/ios/PolicyKit/Tests/AcsAudioPolicyTests/CallAgentRetirementTests.swift +113 -0
- package/package.json +3 -2
- package/src/AcsMeeting.types.ts +4 -1
- package/src/AcsMeetingModule.ts +2 -2
- package/android/src/androidTest/java/com/mentra/acsmeeting/network/SoftApFeasibilityGateTest.kt +0 -291
- package/android/src/main/java/com/mentra/acsmeeting/network/InternetHold.kt +0 -501
- package/android/src/main/java/com/mentra/acsmeeting/network/Ipv4Prefix.kt +0 -51
- package/android/src/main/java/com/mentra/acsmeeting/network/NetworkFacts.kt +0 -81
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetector.kt +0 -355
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkError.kt +0 -109
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkObserver.kt +0 -160
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkReadiness.kt +0 -103
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpec.kt +0 -53
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkState.kt +0 -127
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedSoftApNetwork.kt +0 -573
- package/android/src/main/java/com/mentra/acsmeeting/source/BitmapFont.kt +0 -99
- package/android/src/main/java/com/mentra/acsmeeting/source/CloudflareWhepSource.kt +0 -487
- package/android/src/main/java/com/mentra/acsmeeting/source/DecodedTrackRelay.kt +0 -147
- package/android/src/main/java/com/mentra/acsmeeting/source/FirstFrameGate.kt +0 -45
- package/android/src/main/java/com/mentra/acsmeeting/source/GlassesMediaSource.kt +0 -143
- package/android/src/main/java/com/mentra/acsmeeting/source/LocalWhipIngestSource.kt +0 -772
- package/android/src/main/java/com/mentra/acsmeeting/source/MediaListeners.kt +0 -57
- package/android/src/main/java/com/mentra/acsmeeting/source/SdpAdapter.kt +0 -17
- package/android/src/main/java/com/mentra/acsmeeting/source/SelectedIcePair.kt +0 -139
- package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIceDiagnosis.kt +0 -144
- package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIcePolicy.kt +0 -46
- package/android/src/main/java/com/mentra/acsmeeting/source/SoftApSdpGuard.kt +0 -160
- package/android/src/main/java/com/mentra/acsmeeting/source/SyntheticFrameFactory.kt +0 -205
- package/android/src/main/java/com/mentra/acsmeeting/source/SyntheticI420Source.kt +0 -214
- package/android/src/main/java/com/mentra/acsmeeting/source/TrackRegistry.kt +0 -33
- package/android/src/main/java/com/mentra/acsmeeting/source/VideoSourceArm.kt +0 -110
- package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestProtocol.kt +0 -207
- package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestServer.kt +0 -356
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/AvSyncProbe.kt +0 -155
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/ChromaProbe.kt +0 -78
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineStats.kt +0 -372
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineTicker.kt +0 -79
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/ProcessCpu.kt +0 -15
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/RingPercentile.kt +0 -48
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoQuality.kt +0 -178
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoRateVerdict.kt +0 -145
- package/android/src/main/java/com/mentra/acsmeeting/trace/SoftApTrace.kt +0 -143
- package/android/src/main/java/com/mentra/acsmeeting/video/FrameGeometry.kt +0 -22
- package/android/src/main/java/com/mentra/acsmeeting/video/I420Packer.kt +0 -72
- package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldLifecycleTest.kt +0 -102
- package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldTest.kt +0 -148
- package/android/src/test/java/com/mentra/acsmeeting/network/Ipv4PrefixTest.kt +0 -59
- package/android/src/test/java/com/mentra/acsmeeting/network/NetworkFactsTest.kt +0 -59
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedLocalNetworkPermissionTest.kt +0 -35
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetectorTest.kt +0 -165
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkErrorTest.kt +0 -96
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkLifecycleTest.kt +0 -187
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkObserverTest.kt +0 -172
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkReadinessTest.kt +0 -141
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpecTest.kt +0 -69
- package/android/src/test/java/com/mentra/acsmeeting/source/BitmapFontTest.kt +0 -22
- package/android/src/test/java/com/mentra/acsmeeting/source/DecodedTrackRelayTest.kt +0 -41
- package/android/src/test/java/com/mentra/acsmeeting/source/FirstFrameGateTest.kt +0 -60
- package/android/src/test/java/com/mentra/acsmeeting/source/GlassesMediaSourceTest.kt +0 -151
- package/android/src/test/java/com/mentra/acsmeeting/source/I420PlanesTest.kt +0 -67
- package/android/src/test/java/com/mentra/acsmeeting/source/SelectedIcePairTest.kt +0 -165
- package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIceDiagnosisTest.kt +0 -148
- package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIcePolicyTest.kt +0 -76
- package/android/src/test/java/com/mentra/acsmeeting/source/SoftApSdpGuardTest.kt +0 -275
- package/android/src/test/java/com/mentra/acsmeeting/source/SyntheticFrameFactoryTest.kt +0 -193
- package/android/src/test/java/com/mentra/acsmeeting/source/SyntheticI420SourceTest.kt +0 -176
- package/android/src/test/java/com/mentra/acsmeeting/source/TrackRegistryTest.kt +0 -45
- package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestProtocolTest.kt +0 -356
- package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestServerLoopbackTest.kt +0 -411
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/AvSyncProbeTest.kt +0 -83
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/ChromaProbeTest.kt +0 -61
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/PipelineStatsTest.kt +0 -322
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/ProcessCpuTest.kt +0 -18
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/RingPercentileTest.kt +0 -49
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoRateVerdictTest.kt +0 -149
- package/android/src/test/java/com/mentra/acsmeeting/trace/SoftApTraceTest.kt +0 -97
- package/android/src/test/java/com/mentra/acsmeeting/video/FrameGeometryTest.kt +0 -33
- package/android/src/test/java/com/mentra/acsmeeting/video/I420PackerTest.kt +0 -126
- package/ios/PolicyKit/Sources/AcsAudioPolicy/GlassesMediaSource.swift +0 -68
- 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,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.
|
|
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",
|
package/src/AcsMeeting.types.ts
CHANGED
|
@@ -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
|
|
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
|
package/src/AcsMeetingModule.ts
CHANGED
|
@@ -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
|
|
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
|
|
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>
|
package/android/src/androidTest/java/com/mentra/acsmeeting/network/SoftApFeasibilityGateTest.kt
DELETED
|
@@ -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
|
-
}
|