@mentra/acs-meeting 3.2.0-dev.221 → 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.
- package/README.md +4 -2
- package/android/build.gradle +23 -0
- package/android/src/androidTest/java/com/mentra/acsmeeting/network/SoftApFeasibilityGateTest.kt +291 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/mentra/acsmeeting/AbandonedCallAgent.kt +36 -0
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingModule.kt +392 -22
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingSession.kt +909 -56
- package/android/src/main/java/com/mentra/acsmeeting/CallCapabilities.kt +33 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/AcsAudioPolicy.kt +21 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/AudioUplinkChain.kt +126 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/PcmBridge.kt +27 -4
- package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkPacer.kt +45 -10
- package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkSender.kt +110 -5
- package/android/src/main/java/com/mentra/acsmeeting/network/InternetHold.kt +501 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/Ipv4Prefix.kt +51 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/NetworkFacts.kt +81 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetector.kt +355 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkError.kt +109 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkObserver.kt +160 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkReadiness.kt +103 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpec.kt +53 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkState.kt +127 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedSoftApNetwork.kt +573 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/CloudflareWhepSource.kt +10 -101
- package/android/src/main/java/com/mentra/acsmeeting/source/DecodedTrackRelay.kt +147 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/GlassesMediaSource.kt +47 -5
- package/android/src/main/java/com/mentra/acsmeeting/source/LocalWhipIngestSource.kt +772 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpec.kt +73 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SdpAdapter.kt +17 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SelectedIcePair.kt +139 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIceDiagnosis.kt +144 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIcePolicy.kt +46 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SoftApSdpGuard.kt +160 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/TrackRegistry.kt +3 -1
- package/android/src/main/java/com/mentra/acsmeeting/source/VideoSourceArm.kt +69 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestProtocol.kt +207 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestServer.kt +356 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/AvSyncProbe.kt +155 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineStats.kt +94 -3
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineTicker.kt +33 -5
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/RingPercentile.kt +15 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoQuality.kt +178 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoRateVerdict.kt +145 -0
- package/android/src/main/java/com/mentra/acsmeeting/trace/SoftApTrace.kt +143 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/AcsFrameSender.kt +23 -4
- package/android/src/main/java/com/mentra/acsmeeting/video/FramePacer.kt +70 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/I420FormatSpec.kt +1 -1
- package/android/src/main/java/com/mentra/acsmeeting/video/VideoProfile.kt +36 -3
- package/android/src/test/java/com/mentra/acsmeeting/AbandonedCallAgentTest.kt +74 -0
- package/android/src/test/java/com/mentra/acsmeeting/CallCapabilitiesTest.kt +47 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/AudioUplinkChainTest.kt +250 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/CapturePolicyTest.kt +44 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkPacerTest.kt +34 -4
- package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkSenderTest.kt +96 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldLifecycleTest.kt +102 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldTest.kt +148 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/Ipv4PrefixTest.kt +59 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/NetworkFactsTest.kt +59 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedLocalNetworkPermissionTest.kt +35 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetectorTest.kt +165 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkErrorTest.kt +96 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkLifecycleTest.kt +187 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkObserverTest.kt +172 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkReadinessTest.kt +141 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpecTest.kt +69 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/AcsInvestigationTest.kt +40 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/DecodedTrackRelayTest.kt +41 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/GlassesMediaSourceTest.kt +26 -8
- package/android/src/test/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpecTest.kt +109 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/SelectedIcePairTest.kt +165 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIceDiagnosisTest.kt +148 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIcePolicyTest.kt +76 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/SoftApSdpGuardTest.kt +275 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/TrackRegistryTest.kt +9 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestProtocolTest.kt +356 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestServerLoopbackTest.kt +411 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/AvSyncProbeTest.kt +83 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/PipelineStatsTest.kt +69 -3
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoQualityTest.kt +182 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoRateVerdictTest.kt +149 -0
- package/android/src/test/java/com/mentra/acsmeeting/trace/SoftApTraceTest.kt +97 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/AcsTimestampTest.kt +34 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/FramePacerTest.kt +105 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/I420FormatSpecTest.kt +5 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/VideoProfileTest.kt +38 -3
- package/build/AcsMeeting.types.d.ts +5 -0
- package/build/AcsMeeting.types.d.ts.map +1 -1
- package/build/AcsMeeting.types.js.map +1 -1
- package/build/AcsMeetingModule.d.ts +23 -0
- package/build/AcsMeetingModule.d.ts.map +1 -1
- package/build/AcsMeetingModule.js.map +1 -1
- package/build/AcsMeetingModule.web.d.ts +15 -0
- package/build/AcsMeetingModule.web.d.ts.map +1 -1
- package/build/AcsMeetingModule.web.js +13 -0
- package/build/AcsMeetingModule.web.js.map +1 -1
- package/ios/AcsMeetingModule.swift +213 -2
- package/package.json +1 -1
- package/src/AcsMeeting.types.ts +5 -0
- package/src/AcsMeetingModule.ts +13 -0
- 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 /
|
|
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
|
```
|
package/android/build.gradle
CHANGED
|
@@ -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
|
}
|
package/android/src/androidTest/java/com/mentra/acsmeeting/network/SoftApFeasibilityGateTest.kt
ADDED
|
@@ -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
|
+
}
|