@mentra/acs-meeting 3.2.0-dev.217 → 3.2.0-dev.222
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
package com.mentra.acsmeeting
|
|
2
2
|
|
|
3
3
|
import android.content.Context
|
|
4
|
+
import android.os.SystemClock
|
|
5
|
+
import android.util.Base64
|
|
4
6
|
import android.util.Log
|
|
5
7
|
import com.azure.android.communication.calling.AudioStreamBufferDuration
|
|
6
8
|
import com.azure.android.communication.calling.AudioStreamChannelMode
|
|
@@ -8,12 +10,16 @@ import com.azure.android.communication.calling.AudioStreamFormat
|
|
|
8
10
|
import com.azure.android.communication.calling.AudioStreamSampleRate
|
|
9
11
|
import com.azure.android.communication.calling.AudioStreamState
|
|
10
12
|
import com.azure.android.communication.calling.AudioStreamType
|
|
13
|
+
import com.azure.android.communication.calling.CapabilitiesCallFeature
|
|
14
|
+
import com.azure.android.communication.calling.CapabilitiesChangedListener
|
|
11
15
|
import com.azure.android.communication.calling.Call
|
|
12
16
|
import com.azure.android.communication.calling.CallAgent
|
|
13
17
|
import com.azure.android.communication.calling.CallAgentOptions
|
|
14
18
|
import com.azure.android.communication.calling.CallClient
|
|
15
19
|
import com.azure.android.communication.calling.CallState
|
|
16
20
|
import com.azure.android.communication.calling.Features
|
|
21
|
+
import com.azure.android.communication.calling.HangUpOptions
|
|
22
|
+
import com.azure.android.communication.calling.ParticipantCapabilityType
|
|
17
23
|
import com.azure.android.communication.calling.DiagnosticFlagChangedListener
|
|
18
24
|
import com.azure.android.communication.calling.DiagnosticQualityChangedListener
|
|
19
25
|
import com.azure.android.communication.calling.LocalUserDiagnosticsCallFeature
|
|
@@ -44,8 +50,10 @@ import com.mentra.acsmeeting.audio.AudioPolicyApplier
|
|
|
44
50
|
import com.mentra.acsmeeting.audio.AudioSafety
|
|
45
51
|
import com.mentra.acsmeeting.audio.AudioSourceKind
|
|
46
52
|
import com.mentra.acsmeeting.audio.AudioStreamController
|
|
53
|
+
import com.mentra.acsmeeting.audio.AudioUplinkChain
|
|
47
54
|
import com.mentra.acsmeeting.audio.CallGuard
|
|
48
55
|
import com.mentra.acsmeeting.audio.ExecutorPolicyScheduler
|
|
56
|
+
import com.mentra.acsmeeting.audio.GlassesPcmRouting
|
|
49
57
|
import com.mentra.acsmeeting.audio.AcsUplinkTransport
|
|
50
58
|
import com.mentra.acsmeeting.audio.IncomingAudioPump
|
|
51
59
|
import com.mentra.acsmeeting.audio.IncomingRateProbe
|
|
@@ -56,23 +64,33 @@ import com.mentra.acsmeeting.audio.UplinkSender
|
|
|
56
64
|
import com.mentra.acsmeeting.source.AcsInvestigation
|
|
57
65
|
import com.mentra.acsmeeting.source.CloudflareWhepSource
|
|
58
66
|
import com.mentra.acsmeeting.source.DecoderMode
|
|
67
|
+
import com.mentra.acsmeeting.source.OutgoingRateArm
|
|
59
68
|
import com.mentra.acsmeeting.source.PixelFormatArm
|
|
60
69
|
import com.mentra.acsmeeting.source.GlassesMediaController
|
|
70
|
+
import com.mentra.acsmeeting.network.ScopedSoftApNetwork
|
|
61
71
|
import com.mentra.acsmeeting.source.GlassesMediaSourceFactory
|
|
72
|
+
import com.mentra.acsmeeting.source.LocalWhipIngestSource
|
|
73
|
+
import com.mentra.acsmeeting.source.MeetingVideoSourceSpec
|
|
62
74
|
import com.mentra.acsmeeting.source.SourceConfig
|
|
63
75
|
import com.mentra.acsmeeting.source.SourceKind
|
|
64
76
|
import com.mentra.acsmeeting.source.SourceState
|
|
65
77
|
import com.mentra.acsmeeting.source.SyntheticI420Source
|
|
66
78
|
import com.mentra.acsmeeting.source.TargetSize
|
|
67
79
|
import com.mentra.acsmeeting.source.VideoSourceArm
|
|
80
|
+
import com.mentra.acsmeeting.telemetry.AvSyncProbe
|
|
68
81
|
import com.mentra.acsmeeting.telemetry.PipelineStats
|
|
69
82
|
import com.mentra.acsmeeting.telemetry.PipelineTicker
|
|
83
|
+
import com.mentra.acsmeeting.trace.SoftApTrace
|
|
70
84
|
import com.mentra.acsmeeting.video.AcsFrameSender
|
|
71
85
|
import com.mentra.acsmeeting.video.VideoProfile
|
|
86
|
+
import java.util.concurrent.CountDownLatch
|
|
72
87
|
import java.util.concurrent.Executors
|
|
88
|
+
import java.util.concurrent.Future
|
|
73
89
|
import java.util.concurrent.ScheduledExecutorService
|
|
74
90
|
import java.util.concurrent.ScheduledFuture
|
|
75
91
|
import java.util.concurrent.TimeUnit
|
|
92
|
+
import java.util.concurrent.TimeoutException
|
|
93
|
+
import java.util.concurrent.atomic.AtomicReference
|
|
76
94
|
import java.util.concurrent.atomic.AtomicBoolean
|
|
77
95
|
import java.util.concurrent.atomic.AtomicInteger
|
|
78
96
|
import kotlin.math.roundToInt
|
|
@@ -82,29 +100,66 @@ class AcsMeetingSession(
|
|
|
82
100
|
private val onState: (Map<String, Any>) -> Unit,
|
|
83
101
|
private val onIncomingPcm: (String, Int, Int) -> Unit,
|
|
84
102
|
mediaSourceFactory: GlassesMediaSourceFactory? = null,
|
|
103
|
+
/**
|
|
104
|
+
* The joined glasses hotspot, when the call is a SoftAP call. Held so libwebrtc can be shown a
|
|
105
|
+
* network Android hides from it; null for every Cloudflare call.
|
|
106
|
+
*/
|
|
107
|
+
private val scopedNetwork: ScopedSoftApNetwork? = null,
|
|
85
108
|
) {
|
|
86
109
|
internal val stats = PipelineStats()
|
|
87
|
-
private val
|
|
110
|
+
private val avSync = AvSyncProbe()
|
|
111
|
+
private val ticker = PipelineTicker(stats, avSync) {
|
|
88
112
|
Log.i(TAG, it)
|
|
89
113
|
}
|
|
90
114
|
private val executor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* How long a task sat on [executor] before it started running.
|
|
118
|
+
*
|
|
119
|
+
* Everything here is serialized onto one thread, so a `join` that appears to take 40 s may have
|
|
120
|
+
* spent 38 of them queued behind the previous call's `leaveLocked`. Without this there is no way
|
|
121
|
+
* to tell that apart from a slow ACS, and the two have opposite fixes.
|
|
122
|
+
*/
|
|
123
|
+
private fun traceQueued(stage: String, submittedAt: Long, vararg fields: Pair<String, Any?>) {
|
|
124
|
+
SoftApTrace.stage(stage, *fields, "queuedMs" to (SystemClock.elapsedRealtime() - submittedAt))
|
|
125
|
+
}
|
|
91
126
|
private val scheduler = ExecutorPolicyScheduler(executor)
|
|
92
127
|
private val outgoingReady = AtomicBoolean(false)
|
|
93
128
|
private val muted = AtomicBoolean(false)
|
|
94
|
-
private val frameSender = AcsFrameSender(stats)
|
|
129
|
+
private val frameSender = AcsFrameSender(stats, avSync)
|
|
95
130
|
private var profile = VideoProfile.DEFAULT
|
|
96
|
-
private val resolvedFactory = mediaSourceFactory ?: GlassesMediaSourceFactory { video, pcm ->
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
VideoSourceArm.
|
|
131
|
+
private val resolvedFactory = mediaSourceFactory ?: GlassesMediaSourceFactory { video, pcm, config ->
|
|
132
|
+
// The synthetic diagnostic arm overrides everything; otherwise the requested kind decides.
|
|
133
|
+
when {
|
|
134
|
+
AcsInvestigation.videoArm == VideoSourceArm.SYNTHETIC ->
|
|
135
|
+
SyntheticI420Source(video, stats, frameSender::isReady)
|
|
136
|
+
|
|
137
|
+
config.kind == SourceKind.SOFTAP ->
|
|
138
|
+
LocalWhipIngestSource(context, video, pcm, stats, scopedNetwork)
|
|
139
|
+
|
|
140
|
+
else -> CloudflareWhepSource(context, video, pcm, stats)
|
|
100
141
|
}
|
|
101
142
|
}
|
|
102
143
|
@Volatile private var lastGatedLogMs = 0L
|
|
103
144
|
private var pcmBridge: PcmBridge? = null
|
|
145
|
+
/**
|
|
146
|
+
* Ingest, delay, resample and pacing behind one lock, so mute means the same thing at every
|
|
147
|
+
* stage. Built by [join] alongside the bridge it owns.
|
|
148
|
+
*/
|
|
149
|
+
@Volatile private var uplinkChain: AudioUplinkChain? = null
|
|
150
|
+
/**
|
|
151
|
+
* Whether the *host* is feeding this call's outgoing audio, rather than the decoded glasses
|
|
152
|
+
* track. Set by the audio policy for a SoftAP call on the glasses microphone: the wearer's voice
|
|
153
|
+
* arrives over BLE LC3 through [pushOutgoingPcm], and the WHIP peer publishes video only.
|
|
154
|
+
*
|
|
155
|
+
* Also a drop gate. Without it a host that pushes PCM at a call which is taking audio from the
|
|
156
|
+
* media relay would put the same room on the call twice.
|
|
157
|
+
*/
|
|
158
|
+
private val externalPcmEnabled = AtomicBoolean(false)
|
|
104
159
|
private val incomingProbe = IncomingRateProbe()
|
|
105
160
|
// Clock-domain adapter: the WebRTC audio thread only ever fills the pacer,
|
|
106
161
|
// and a dedicated monotonic-deadline thread drains it into ACS.
|
|
107
|
-
private val pacer = UplinkPacer()
|
|
162
|
+
private val pacer = UplinkPacer(log = { Log.i(TAG, it) })
|
|
108
163
|
@Volatile private var uplinkSender: UplinkSender? = null
|
|
109
164
|
private val phoneMic = PhoneMicCapturer { pcm, rate, channels -> feedOutgoingPcm(pcm, rate, channels) }
|
|
110
165
|
// Emits already-normalized 16 kHz mono; the host opens its PCM player with
|
|
@@ -126,7 +181,16 @@ class AcsMeetingSession(
|
|
|
126
181
|
private val media = GlassesMediaController(resolvedFactory)
|
|
127
182
|
private var mediaStatsListener: MediaStatisticsReportReceivedListener? = null
|
|
128
183
|
private var mediaStatsFeature: MediaStatisticsCallFeature? = null
|
|
184
|
+
private var capabilitiesFeature: CapabilitiesCallFeature? = null
|
|
185
|
+
private var capabilitiesListener: CapabilitiesChangedListener? = null
|
|
186
|
+
/**
|
|
187
|
+
* Whether this participant may end the meeting for everyone. Teams grants it to presenters only,
|
|
188
|
+
* and ACS can deliver it after admission, so it is a live value rather than a join-time fact.
|
|
189
|
+
*/
|
|
190
|
+
@Volatile private var hangUpForEveryone = CapabilityStatus()
|
|
129
191
|
private val mediaStatsReports = AtomicInteger(0)
|
|
192
|
+
/** Last wire size reported by ACS, so adaptation is logged on transition rather than every 1 Hz report. */
|
|
193
|
+
@Volatile private var lastWireSizeKey: String? = null
|
|
130
194
|
private var netDiagnostics: NetworkDiagnostics? = null
|
|
131
195
|
private var sendQualityListener: DiagnosticQualityChangedListener? = null
|
|
132
196
|
private var reconnectListener: DiagnosticQualityChangedListener? = null
|
|
@@ -143,12 +207,37 @@ class AcsMeetingSession(
|
|
|
143
207
|
@Volatile private var phase = "idle"
|
|
144
208
|
@Volatile private var lastError: String? = null
|
|
145
209
|
@Volatile private var audioSource = "glasses"
|
|
210
|
+
@Volatile private var configuredAudioDelayMs = AcsInvestigation.acsAudioDelayMs
|
|
146
211
|
@Volatile private var lastSafety = AudioSafety.DEGRADED
|
|
147
212
|
// Health of the glasses WHEP feed, reported alongside the ACS phase so the host
|
|
148
213
|
// can tell "call is up, glasses video is dead" from a healthy call.
|
|
149
214
|
@Volatile private var mediaSource = SourceState.IDLE
|
|
215
|
+
// The transport of the active call. A SoftAP source must not be auto-rebuilt: its URL is an
|
|
216
|
+
// output of binding, so a rebuild rebinds a new port and strands the glasses on the old one.
|
|
217
|
+
@Volatile private var currentSourceKind = SourceKind.WHEP
|
|
150
218
|
private var mediaRestartAttempts = 0
|
|
151
219
|
private var mediaRestartTask: ScheduledFuture<*>? = null
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Bumped by every join and every leave, so a bounded ACS operation that completes late cannot
|
|
223
|
+
* attach its result to a session that has already moved on.
|
|
224
|
+
*
|
|
225
|
+
* Same pattern as the ingest source's generation. It exists because `createCallAgent` is a future
|
|
226
|
+
* with no timeout of its own: on device it stalled for 30 s while cellular was still validating,
|
|
227
|
+
* and a cancelled join that later succeeds would otherwise leave a live agent nobody owns.
|
|
228
|
+
*/
|
|
229
|
+
private val joinGeneration = AtomicInteger(0)
|
|
230
|
+
/**
|
|
231
|
+
* `createCallAgent` Future we stopped waiting on. ACS still finishes signing in; this is the only
|
|
232
|
+
* handle that can dispose that leftover agent before the next join.
|
|
233
|
+
*/
|
|
234
|
+
private var abandonedAgent: Future<CallAgent>? = null
|
|
235
|
+
/**
|
|
236
|
+
* Agent signed in before the glasses hotspot came up. SoftAP DNS cannot resolve ACS hosts, so
|
|
237
|
+
* `createCallAgent` after the scoped join stalls until the hotspot is torn down. Join reuses this
|
|
238
|
+
* instead of signing in again on the broken resolver.
|
|
239
|
+
*/
|
|
240
|
+
@Volatile private var agentPrepared = false
|
|
152
241
|
private val controller = SessionAudioController()
|
|
153
242
|
private val applier = AudioPolicyApplier(controller, scheduler) { Log.i(TAG, it) }
|
|
154
243
|
|
|
@@ -162,23 +251,101 @@ class AcsMeetingSession(
|
|
|
162
251
|
"audioSafety" to lastSafety.name.lowercase(),
|
|
163
252
|
"mediaSource" to mediaSource.name.lowercase(),
|
|
164
253
|
"participants" to roster.snapshot(),
|
|
254
|
+
// Nullable members inside, so the miniapp can tell "denied" from "not known yet" and only
|
|
255
|
+
// offer End when it is actually allowed.
|
|
256
|
+
"capabilities" to mapOf("hangUpForEveryone" to hangUpForEveryone.toMap()),
|
|
165
257
|
)
|
|
166
258
|
meetingUrl?.let { result["meetingUrl"] = it }
|
|
167
259
|
lastError?.let { result["error"] = it }
|
|
260
|
+
media.ingestUrl?.let { result["ingestUrl"] = it }
|
|
168
261
|
describeEndReason(call).forEach { (key, value) ->
|
|
169
262
|
if (value != null) result["endReason_$key"] = value
|
|
170
263
|
}
|
|
171
264
|
return result
|
|
172
265
|
}
|
|
173
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Sign in to ACS before the glasses hotspot exists.
|
|
269
|
+
*
|
|
270
|
+
* On device, `createCallAgent` after the scoped join sat for the full 20 s deadline and only
|
|
271
|
+
* completed once SoftAP was released. Token mint already proved the internet works *before* that
|
|
272
|
+
* join. Doing this step on that same network is what makes the later SoftAP join a media bind
|
|
273
|
+
* plus a Teams meeting join, not another sign-in through glasses dnsmasq.
|
|
274
|
+
*
|
|
275
|
+
* Waits [PREPARE_AGENT_WAIT_MS], not [CALL_AGENT_WAIT_MS]. The tight budget only ever existed
|
|
276
|
+
* because sign-in used to run inside the SoftAP join, where overrunning cost the wearer the
|
|
277
|
+
* hotspot too. Here nothing is torn down while we wait, and a cold sign-in on a slow AP measured
|
|
278
|
+
* 35 s on device — under the old 20 s cap that agent arrived just in time to be thrown away.
|
|
279
|
+
*/
|
|
280
|
+
fun prepareAgent(token: String, displayName: String?) {
|
|
281
|
+
val done = CountDownLatch(1)
|
|
282
|
+
val error = AtomicReference<Exception?>(null)
|
|
283
|
+
phase = "connecting"
|
|
284
|
+
lastError = null
|
|
285
|
+
val submittedAt = SystemClock.elapsedRealtime()
|
|
286
|
+
executor.execute {
|
|
287
|
+
traceQueued("session_prepare_agent_begin", submittedAt)
|
|
288
|
+
val startedAt = SystemClock.elapsedRealtime()
|
|
289
|
+
try {
|
|
290
|
+
leaveLocked(emitIdle = false)
|
|
291
|
+
val generation = joinGeneration.get()
|
|
292
|
+
lastError = null
|
|
293
|
+
emit("connecting")
|
|
294
|
+
val credential = CommunicationTokenCredential(token)
|
|
295
|
+
callClient = CallClient()
|
|
296
|
+
val agentOptions = CallAgentOptions()
|
|
297
|
+
agentOptions.displayName = displayName ?: "Mentra Call"
|
|
298
|
+
callAgent = obtainCallAgent(callClient!!, credential, agentOptions, generation, PREPARE_AGENT_WAIT_MS)
|
|
299
|
+
agentPrepared = true
|
|
300
|
+
Log.i(TAG, "ACS call agent prepared before SoftAP")
|
|
301
|
+
SoftApTrace.stage(
|
|
302
|
+
"session_prepare_agent_end",
|
|
303
|
+
"durationMs" to (SystemClock.elapsedRealtime() - startedAt),
|
|
304
|
+
)
|
|
305
|
+
} catch (failed: Exception) {
|
|
306
|
+
agentPrepared = false
|
|
307
|
+
lastError = formatAcsError(failed)
|
|
308
|
+
Log.e(TAG, "prepare agent failed $lastError", failed)
|
|
309
|
+
SoftApTrace.failure(
|
|
310
|
+
"session_prepare_agent_failed",
|
|
311
|
+
"durationMs" to (SystemClock.elapsedRealtime() - startedAt),
|
|
312
|
+
"reason" to lastError,
|
|
313
|
+
)
|
|
314
|
+
leaveLocked(emitIdle = false)
|
|
315
|
+
emit("error")
|
|
316
|
+
error.set(failed)
|
|
317
|
+
} finally {
|
|
318
|
+
done.countDown()
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (!done.await(PREPARE_AGENT_WAIT_MS + ABANDONED_AGENT_REJOIN_WAIT_MS + 5_000L, TimeUnit.MILLISECONDS)) {
|
|
322
|
+
SoftApTrace.failure(
|
|
323
|
+
"session_prepare_agent_stuck",
|
|
324
|
+
"waitedMs" to (SystemClock.elapsedRealtime() - submittedAt),
|
|
325
|
+
)
|
|
326
|
+
throw IllegalStateException(
|
|
327
|
+
"ACS_AGENT_TIMEOUT: Teams did not finish signing this phone in within " +
|
|
328
|
+
"${PREPARE_AGENT_WAIT_MS / 1000}s.",
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
error.get()?.let { throw it }
|
|
332
|
+
}
|
|
333
|
+
|
|
174
334
|
fun join(
|
|
175
335
|
token: String,
|
|
176
336
|
teamsUrl: String,
|
|
177
|
-
|
|
337
|
+
videoSource: MeetingVideoSourceSpec,
|
|
178
338
|
displayName: String?,
|
|
179
339
|
dumpWav: Boolean,
|
|
180
340
|
audioSource: String = "glasses",
|
|
181
341
|
video: VideoProfile = VideoProfile.DEFAULT,
|
|
342
|
+
audioDelayMs: Int? = null,
|
|
343
|
+
/**
|
|
344
|
+
* Runs the WHIP listener bind, and exists so the caller can lift a process-wide network pin
|
|
345
|
+
* across exactly that call. Takes the block rather than being a pair of before/after hooks so
|
|
346
|
+
* the pin cannot be left off if the bind throws. See [network.InternetHold.bindProcessToCellular].
|
|
347
|
+
*/
|
|
348
|
+
bindIngestUnpinned: (() -> Unit) -> Unit = { bind -> bind() },
|
|
182
349
|
): Map<String, Any> {
|
|
183
350
|
// Both glasses and phone feed RawOutgoingAudioStream so ACS never owns
|
|
184
351
|
// the phone audio route (no MODE_IN_COMMUNICATION, no forced speaker).
|
|
@@ -194,23 +361,80 @@ class AcsMeetingSession(
|
|
|
194
361
|
phase = "connecting"
|
|
195
362
|
lastError = null
|
|
196
363
|
meetingUrl = teamsUrl
|
|
364
|
+
// SoftAP needs the WHIP listener bound before this method returns: the JS
|
|
365
|
+
// orchestrator reads ingestUrl off the join result and tears the scoped
|
|
366
|
+
// network down if it is missing. ACS join itself stays on the executor.
|
|
367
|
+
val softApReady = if (videoSource is MeetingVideoSourceSpec.SoftAp) CountDownLatch(1) else null
|
|
368
|
+
val softApBindError = AtomicReference<Exception?>(null)
|
|
369
|
+
val submittedAt = SystemClock.elapsedRealtime()
|
|
197
370
|
executor.execute {
|
|
371
|
+
traceQueued("session_join_begin", submittedAt, "transport" to videoSource.kind)
|
|
372
|
+
val startedAt = SystemClock.elapsedRealtime()
|
|
198
373
|
try {
|
|
199
374
|
// Tear down any previous call without announcing idle: the caller already
|
|
200
375
|
// holds a "connecting" snapshot, and an idle event landing after it made
|
|
201
376
|
// the host and miniapp flash out of "joining" on every join.
|
|
202
|
-
|
|
203
|
-
|
|
377
|
+
// A SoftAP join that already signed in on cellular must keep that agent:
|
|
378
|
+
// recreating it on the glasses hotspot is the ACS_AGENT_TIMEOUT we just hit.
|
|
379
|
+
val reuseAgent = agentPrepared && callAgent != null
|
|
380
|
+
leaveLocked(emitIdle = false, keepAgent = reuseAgent)
|
|
381
|
+
// After the teardown, because that teardown bumps the generation itself. Everything that
|
|
382
|
+
// moves it runs on this executor, so the value is stable for the rest of this join.
|
|
383
|
+
val generation = joinGeneration.get()
|
|
384
|
+
val requested = video
|
|
385
|
+
this.profile = when (AcsInvestigation.outgoingRate) {
|
|
386
|
+
OutgoingRateArm.CLAMP_TO_SOFTWARE_CEILING -> requested.forSoftwareEncoder()
|
|
387
|
+
OutgoingRateArm.ADVERTISE_REQUESTED -> requested
|
|
388
|
+
}
|
|
389
|
+
if (this.profile.fps != requested.fps) {
|
|
390
|
+
Log.i(
|
|
391
|
+
TAG,
|
|
392
|
+
"ACS software-encoder clamp ${requested.width}x${requested.height}@${requested.fps} -> " +
|
|
393
|
+
"@${this.profile.fps} (h264 sw holds ~${VideoProfile.SOFTWARE_ENCODER_FPS} fps; " +
|
|
394
|
+
"advertising faster starves the wire)",
|
|
395
|
+
)
|
|
396
|
+
} else {
|
|
397
|
+
Log.i(
|
|
398
|
+
TAG,
|
|
399
|
+
"ACS rate arm=${AcsInvestigation.outgoingRate} advertising " +
|
|
400
|
+
"${this.profile.width}x${this.profile.height}@${this.profile.fps} unclamped; " +
|
|
401
|
+
"P7 rate names what binds",
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
// Read by the 1 Hz verdict, which scores the wire against what we declared.
|
|
405
|
+
stats.advertisedFps = this.profile.fps.toDouble()
|
|
406
|
+
stats.budgetBps = this.profile.maxBitrateBps
|
|
204
407
|
this.audioSource = if (parsed == AudioSourceKind.PHONE) "phone" else "glasses"
|
|
205
408
|
meetingUrl = teamsUrl
|
|
206
409
|
lastError = null
|
|
207
410
|
emit("connecting")
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
411
|
+
val bridge = PcmBridge(context.cacheDir, dumpWav)
|
|
412
|
+
pcmBridge = bridge
|
|
413
|
+
val delayMs = (audioDelayMs ?: AcsInvestigation.acsAudioDelayMs)
|
|
414
|
+
.coerceIn(0, AudioUplinkChain.MAX_DELAY_MS)
|
|
415
|
+
configuredAudioDelayMs = delayMs
|
|
416
|
+
val headroom = if (videoSource.kind == SourceKind.SOFTAP && parsed != AudioSourceKind.PHONE) {
|
|
417
|
+
UplinkPacer.LC3_TARGET_MS
|
|
418
|
+
} else UplinkPacer.TARGET_MS
|
|
419
|
+
pacer.configureTarget(headroom)
|
|
420
|
+
avSync.reset()
|
|
421
|
+
uplinkChain = AudioUplinkChain(
|
|
422
|
+
bridge,
|
|
423
|
+
pacer,
|
|
424
|
+
// Move delay into a jitter buffer that can absorb BLE bursts, rather than adding latency.
|
|
425
|
+
(delayMs - (headroom - UplinkPacer.TARGET_MS)).coerceAtLeast(0),
|
|
426
|
+
onIngest = { pcm, nowNs -> avSync.onAudio(pcm, nowNs) },
|
|
427
|
+
)
|
|
428
|
+
if (reuseAgent) {
|
|
429
|
+
Log.i(TAG, "reusing call agent prepared before SoftAP")
|
|
430
|
+
agentPrepared = false
|
|
431
|
+
} else {
|
|
432
|
+
val credential = CommunicationTokenCredential(token)
|
|
433
|
+
callClient = CallClient()
|
|
434
|
+
val agentOptions = CallAgentOptions()
|
|
435
|
+
agentOptions.displayName = displayName ?: "Mentra Call"
|
|
436
|
+
callAgent = obtainCallAgent(callClient!!, credential, agentOptions, generation)
|
|
437
|
+
}
|
|
214
438
|
|
|
215
439
|
val videoOptions = RawOutgoingVideoStreamOptions()
|
|
216
440
|
videoOptions.formats = listOf(AcsFrameSender.outgoingFormat(profile))
|
|
@@ -306,18 +530,11 @@ class AcsMeetingSession(
|
|
|
306
530
|
.setMuted(false)
|
|
307
531
|
joinOptions.setIncomingAudioOptions(ia)
|
|
308
532
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
joined.addOnStateChangedListener { pushCallState(joined.state) }
|
|
314
|
-
joined.addOnOutgoingAudioStateChangedListener {
|
|
315
|
-
Log.i(TAG, "outgoing audio state changed muted=${joined.isOutgoingAudioMuted}")
|
|
316
|
-
applyAudioPolicy("outgoing-audio-state")
|
|
533
|
+
stats.arm = when {
|
|
534
|
+
synthetic -> "synthetic"
|
|
535
|
+
videoSource is MeetingVideoSourceSpec.SoftAp -> "softap"
|
|
536
|
+
else -> "whep"
|
|
317
537
|
}
|
|
318
|
-
pushCallState(joined.state)
|
|
319
|
-
|
|
320
|
-
stats.arm = if (synthetic) "synthetic" else "whep"
|
|
321
538
|
stats.pathMode = if (AcsInvestigation.decoderMode == DecoderMode.BYTE_BUFFER) "bytebuf" else "texture"
|
|
322
539
|
stats.pathCopy = when {
|
|
323
540
|
AcsInvestigation.pixelFormat == PixelFormatArm.NV12 -> "nv12"
|
|
@@ -327,21 +544,50 @@ class AcsMeetingSession(
|
|
|
327
544
|
stats.pix = AcsInvestigation.pixelFormat.name.lowercase()
|
|
328
545
|
stats.zcOn = if (AcsInvestigation.zeroCopy) 1 else 0
|
|
329
546
|
mediaRestartAttempts = 0
|
|
547
|
+
currentSourceKind = if (synthetic) SourceKind.DIRECT else videoSource.kind
|
|
330
548
|
media.setStateListener { state, reason ->
|
|
331
549
|
// Fired from WebRTC/OkHttp threads; hop to the session executor so it
|
|
332
550
|
// serializes with join/leave/policy like everything else.
|
|
333
551
|
executor.execute { onMediaSourceState(state, reason) }
|
|
334
552
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
553
|
+
// SoftAP: bind the listener before the Teams join so the Expo promise can
|
|
554
|
+
// return an ingest URL while 192.168.43.x is still assigned. WHEP still
|
|
555
|
+
// attaches after the call exists — it has a URL going in, not coming out.
|
|
556
|
+
if (videoSource is MeetingVideoSourceSpec.SoftAp) {
|
|
557
|
+
// Unpinned for the bind only: this socket is a ServerSocket on 192.168.43.79 and cannot
|
|
558
|
+
// be re-scoped afterwards, so a cellular mark here leaves the glasses' SYN unanswered.
|
|
559
|
+
// The pin is back on by the time the Teams join below runs.
|
|
560
|
+
bindIngestUnpinned {
|
|
561
|
+
media.attach(
|
|
562
|
+
video = { planes ->
|
|
563
|
+
frameSender.sendPlanes(planes)
|
|
564
|
+
},
|
|
565
|
+
pcm = { pcm, rate, channels -> feedOutgoingPcm(pcm, rate, channels) },
|
|
566
|
+
config = videoSource.toConfig(),
|
|
567
|
+
)
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
val locator = TeamsMeetingLinkLocator(teamsUrl)
|
|
572
|
+
val joined = callAgent!!.join(context, locator, joinOptions)
|
|
573
|
+
call = joined
|
|
574
|
+
roster.attach(joined)
|
|
575
|
+
joined.addOnStateChangedListener { pushCallState(joined.state) }
|
|
576
|
+
joined.addOnOutgoingAudioStateChangedListener {
|
|
577
|
+
Log.i(TAG, "outgoing audio state changed muted=${joined.isOutgoingAudioMuted}")
|
|
578
|
+
applyAudioPolicy("outgoing-audio-state")
|
|
579
|
+
}
|
|
580
|
+
pushCallState(joined.state)
|
|
581
|
+
|
|
582
|
+
if (videoSource !is MeetingVideoSourceSpec.SoftAp) {
|
|
583
|
+
media.attach(
|
|
584
|
+
video = { planes ->
|
|
585
|
+
frameSender.sendPlanes(planes)
|
|
586
|
+
},
|
|
587
|
+
pcm = { pcm, rate, channels -> feedOutgoingPcm(pcm, rate, channels) },
|
|
588
|
+
config = if (synthetic) SourceConfig("", SourceKind.DIRECT) else videoSource.toConfig(),
|
|
589
|
+
)
|
|
590
|
+
}
|
|
345
591
|
media.setTargetSize(TargetSize(profile.width, profile.height))
|
|
346
592
|
ticker.start()
|
|
347
593
|
Log.i(
|
|
@@ -362,22 +608,61 @@ class AcsMeetingSession(
|
|
|
362
608
|
"source=${this.audioSource} audio=${if (synthetic) "off" else "on"} " +
|
|
363
609
|
"armVirtual=${plan.armVirtual} transportMuted=${plan.transportMuted}",
|
|
364
610
|
)
|
|
611
|
+
SoftApTrace.stage(
|
|
612
|
+
"session_join_end",
|
|
613
|
+
"durationMs" to (SystemClock.elapsedRealtime() - startedAt),
|
|
614
|
+
"reusedAgent" to reuseAgent,
|
|
615
|
+
"ingestUrl" to (media.ingestUrl ?: "none"),
|
|
616
|
+
)
|
|
617
|
+
if (generation != joinGeneration.get()) {
|
|
618
|
+
throw IllegalStateException("The meeting was cancelled before it finished joining")
|
|
619
|
+
}
|
|
620
|
+
softApReady?.countDown()
|
|
365
621
|
} catch (error: Exception) {
|
|
366
622
|
val message = formatAcsError(error)
|
|
367
623
|
Log.e(TAG, "join failed $message", error)
|
|
624
|
+
SoftApTrace.failure(
|
|
625
|
+
"session_join_failed",
|
|
626
|
+
"durationMs" to (SystemClock.elapsedRealtime() - startedAt),
|
|
627
|
+
"reason" to message,
|
|
628
|
+
"ingestUrl" to (media.ingestUrl ?: "none"),
|
|
629
|
+
)
|
|
368
630
|
// A step after a successful ACS join (e.g. WHEP start) can throw. Record
|
|
369
631
|
// the failure before tearing the call down: lastError makes pushCallState
|
|
370
632
|
// ignore the hang-up's async disconnected callbacks, and emitIdle=false
|
|
371
633
|
// keeps the terminal state as error instead of resetting to idle. Either
|
|
372
634
|
// would otherwise let Mentra Call treat the failed join as a clean end.
|
|
373
635
|
lastError = message
|
|
636
|
+
softApBindError.compareAndSet(null, error)
|
|
374
637
|
leaveLocked(emitIdle = false)
|
|
375
638
|
emit("error")
|
|
639
|
+
softApReady?.countDown()
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
if (softApReady != null) {
|
|
643
|
+
if (!softApReady.await(SOFTAP_JOIN_WAIT_MS, TimeUnit.MILLISECONDS)) {
|
|
644
|
+
// The executor never got far enough to bind the listener. It is still running, so this
|
|
645
|
+
// failure leaves work in flight that the next join's barrier has to wait out.
|
|
646
|
+
SoftApTrace.failure(
|
|
647
|
+
"session_join_bind_timeout",
|
|
648
|
+
"waitedMs" to (SystemClock.elapsedRealtime() - submittedAt),
|
|
649
|
+
"timeoutMs" to SOFTAP_JOIN_WAIT_MS,
|
|
650
|
+
)
|
|
651
|
+
throw IllegalStateException("SoftAP ingest listener did not bind in ${SOFTAP_JOIN_WAIT_MS}ms")
|
|
652
|
+
}
|
|
653
|
+
softApBindError.get()?.let { throw it }
|
|
654
|
+
if (media.ingestUrl == null) {
|
|
655
|
+
throw IllegalStateException("SoftAP ingest listener bound but produced no URL")
|
|
376
656
|
}
|
|
377
657
|
}
|
|
378
658
|
return snapshot()
|
|
379
659
|
}
|
|
380
660
|
|
|
661
|
+
/**
|
|
662
|
+
* Point the subscriber at a different WHEP URL. WHEP only: a SoftAP listener has no URL to
|
|
663
|
+
* update, since the URL is an output of binding, and its recovery is a full rebuild through
|
|
664
|
+
* [restartVideoSource].
|
|
665
|
+
*/
|
|
381
666
|
fun updateVideoSource(whepUrl: String) {
|
|
382
667
|
executor.execute {
|
|
383
668
|
// The host has a fresher opinion about where the glasses publish; drop
|
|
@@ -387,6 +672,13 @@ class AcsMeetingSession(
|
|
|
387
672
|
}
|
|
388
673
|
}
|
|
389
674
|
|
|
675
|
+
/**
|
|
676
|
+
* The URL the glasses must POST their offer to, for a SoftAP call. Null until the listener has
|
|
677
|
+
* bound, and null for every other transport. The orchestrator reads this after join and sends it
|
|
678
|
+
* to the glasses in `start_stream`.
|
|
679
|
+
*/
|
|
680
|
+
fun softApIngestUrl(): String? = media.ingestUrl
|
|
681
|
+
|
|
390
682
|
/**
|
|
391
683
|
* Rebuild the WHEP subscription on the current URL even when it looks healthy.
|
|
392
684
|
* The host calls this when the phone changed networks: ICE may not have noticed
|
|
@@ -394,6 +686,15 @@ class AcsMeetingSession(
|
|
|
394
686
|
*/
|
|
395
687
|
fun restartVideoSource() {
|
|
396
688
|
executor.execute {
|
|
689
|
+
// A SoftAP source cannot be rebuilt in place: forceRestart() rebinds a new OS-chosen port and
|
|
690
|
+
// mints a fresh ingestUrl, but the glasses keep POSTing to the old port and nothing re-pushes
|
|
691
|
+
// the new URL, which permanently strands the call in CONNECTING. Leaving the listener bound on
|
|
692
|
+
// its stable port instead lets the glasses' own WHIP reconnect recover against the same URL;
|
|
693
|
+
// a network-level recovery is the orchestrator's job (re-run the SoftapCallTransport sequence).
|
|
694
|
+
if (currentSourceKind == SourceKind.SOFTAP) {
|
|
695
|
+
Log.w(TAG, "restartVideoSource ignored for SoftAP; a rebuild would strand the glasses on a dead port")
|
|
696
|
+
return@execute
|
|
697
|
+
}
|
|
397
698
|
cancelMediaRestart()
|
|
398
699
|
media.forceRestart()
|
|
399
700
|
}
|
|
@@ -416,6 +717,14 @@ class AcsMeetingSession(
|
|
|
416
717
|
*/
|
|
417
718
|
private fun scheduleMediaRestart(reason: String?) {
|
|
418
719
|
if (call == null || phase == "idle" || phase == "disconnected" || phase == "error") return
|
|
720
|
+
// SoftAP recovery must never rebuild the source from here: forceRestart() rebinds a new port and
|
|
721
|
+
// ingestUrl the glasses are never told about, so the call would strand in CONNECTING. The
|
|
722
|
+
// FAILED state is still surfaced to the host (onMediaSourceState emits a snapshot), and the
|
|
723
|
+
// still-bound listener lets the glasses' own WHIP reconnect recover on the unchanged URL.
|
|
724
|
+
if (currentSourceKind == SourceKind.SOFTAP) {
|
|
725
|
+
Log.w(TAG, "SoftAP media source failed ($reason); session-level rebuild suppressed, listener left bound for glasses reconnect")
|
|
726
|
+
return
|
|
727
|
+
}
|
|
419
728
|
if (mediaRestartTask?.isDone == false) return
|
|
420
729
|
val attempt = mediaRestartAttempts++
|
|
421
730
|
val delayMs = minOf(MEDIA_RESTART_BASE_MS shl minOf(attempt, 4), MEDIA_RESTART_MAX_MS)
|
|
@@ -444,6 +753,9 @@ class AcsMeetingSession(
|
|
|
444
753
|
return snapshot()
|
|
445
754
|
}
|
|
446
755
|
muted.set(next)
|
|
756
|
+
// Before the executor hop, not after it. Muting is the one audio operation whose latency the
|
|
757
|
+
// wearer can hear as a mistake, and the policy queue can be several ACS round trips deep.
|
|
758
|
+
if (next) uplinkChain?.mute() else uplinkChain?.unmute()
|
|
447
759
|
executor.execute { applyAudioPolicy("set-muted") }
|
|
448
760
|
val snap = snapshot()
|
|
449
761
|
onState(snap)
|
|
@@ -461,9 +773,131 @@ class AcsMeetingSession(
|
|
|
461
773
|
}
|
|
462
774
|
|
|
463
775
|
fun leave() {
|
|
776
|
+
val submittedAt = SystemClock.elapsedRealtime()
|
|
777
|
+
// Invalidate before queueing: leaveLocked cannot run until the executor finishes the current
|
|
778
|
+
// join/hang-up, and without this bump a Cancel sits behind an unbounded ACS Future.
|
|
779
|
+
joinGeneration.incrementAndGet()
|
|
780
|
+
// Queued and unwaited, so the only evidence this leave ever ran is the line the task logs
|
|
781
|
+
// when it starts. A `leave` with no matching begin means the executor never reached it.
|
|
782
|
+
SoftApTrace.stage("session_leave_queued")
|
|
783
|
+
executor.execute {
|
|
784
|
+
traceQueued("session_leave_begin", submittedAt)
|
|
785
|
+
leaveLocked()
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Leave, and do not return until the cleanup has actually finished.
|
|
791
|
+
*
|
|
792
|
+
* [leave] hands the work to [executor] and returns straight away, so a caller that awaits it and
|
|
793
|
+
* then starts the next call is racing this one's hang-up, agent disposal, and media teardown
|
|
794
|
+
* through the same hardware. That race is what turned a quick Stop/Start into a hotspot the
|
|
795
|
+
* previous call switched off underneath the new one.
|
|
796
|
+
*
|
|
797
|
+
* Failures that [leaveLocked] otherwise only logs are captured and rethrown here: reporting a
|
|
798
|
+
* clean teardown that did not happen is exactly what lets the next call build on leaked state.
|
|
799
|
+
*
|
|
800
|
+
* @param timeoutMs how long the cleanup may take before it is reported as stuck
|
|
801
|
+
* @throws IllegalStateException when the cleanup did not finish in time
|
|
802
|
+
*/
|
|
803
|
+
fun leaveAndAwait(timeoutMs: Long): Boolean {
|
|
804
|
+
val done = CountDownLatch(1)
|
|
805
|
+
val failure = AtomicReference<Exception?>(null)
|
|
806
|
+
val submittedAt = SystemClock.elapsedRealtime()
|
|
807
|
+
joinGeneration.incrementAndGet()
|
|
808
|
+
executor.execute {
|
|
809
|
+
traceQueued("session_leave_and_await_begin", submittedAt)
|
|
810
|
+
val startedAt = SystemClock.elapsedRealtime()
|
|
811
|
+
try {
|
|
812
|
+
leaveLocked(failures = failure)
|
|
813
|
+
SoftApTrace.stage(
|
|
814
|
+
"session_leave_and_await_end",
|
|
815
|
+
"durationMs" to (SystemClock.elapsedRealtime() - startedAt),
|
|
816
|
+
// Recorded rather than only logged: this is the one the host rethrows and turns into a
|
|
817
|
+
// refusal for the next call.
|
|
818
|
+
"recordedFailure" to (failure.get()?.let { "${it.javaClass.simpleName}: ${it.message ?: ""}" } ?: "none"),
|
|
819
|
+
)
|
|
820
|
+
} catch (error: Exception) {
|
|
821
|
+
SoftApTrace.failure(
|
|
822
|
+
"session_leave_and_await_failed",
|
|
823
|
+
"durationMs" to (SystemClock.elapsedRealtime() - startedAt),
|
|
824
|
+
"reason" to "${error.javaClass.simpleName}: ${error.message ?: ""}",
|
|
825
|
+
)
|
|
826
|
+
failure.compareAndSet(null, error)
|
|
827
|
+
} finally {
|
|
828
|
+
done.countDown()
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
if (!done.await(timeoutMs, TimeUnit.MILLISECONDS)) {
|
|
832
|
+
// The cleanup is still running. Nothing is abandoned and nothing is safe to restart yet,
|
|
833
|
+
// which is why this is reported rather than treated as a finished teardown.
|
|
834
|
+
SoftApTrace.failure("session_leave_and_await_timeout", "timeoutMs" to timeoutMs)
|
|
835
|
+
throw IllegalStateException("acs_leave_timeout")
|
|
836
|
+
}
|
|
837
|
+
failure.get()?.let { throw it }
|
|
838
|
+
return true
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* End the Teams group call for everyone, then tear this device down.
|
|
843
|
+
*
|
|
844
|
+
* Blocking, unlike [leave], because the caller has to know whether the meeting actually died: the
|
|
845
|
+
* miniapp shows different terminal copy for "ended" and "you left, but the meeting may still be
|
|
846
|
+
* active", and inventing the first would be a lie the wearer cannot check.
|
|
847
|
+
*
|
|
848
|
+
* Local teardown is queued whatever the hang-up did. A refused or failed End must still get the
|
|
849
|
+
* wearer out of the call — the only thing at stake in the failure is what we claim happened.
|
|
850
|
+
*
|
|
851
|
+
* @throws IllegalStateException when there is no call, when the capability is known to be denied,
|
|
852
|
+
* or when ACS rejects the hang-up
|
|
853
|
+
*/
|
|
854
|
+
fun endForEveryone(): Map<String, Any> {
|
|
855
|
+
val done = CountDownLatch(1)
|
|
856
|
+
val failure = AtomicReference<Exception?>(null)
|
|
857
|
+
val submittedAt = SystemClock.elapsedRealtime()
|
|
858
|
+
executor.execute {
|
|
859
|
+
traceQueued("session_end_for_everyone_begin", submittedAt)
|
|
860
|
+
val startedAt = SystemClock.elapsedRealtime()
|
|
861
|
+
try {
|
|
862
|
+
val active = call ?: throw IllegalStateException("no_active_call")
|
|
863
|
+
// Re-read rather than trusting the cached value: capabilities arrive asynchronously and the
|
|
864
|
+
// last event may predate admission.
|
|
865
|
+
val capability = readHangUpForEveryone()
|
|
866
|
+
EndForEveryonePolicy.refusalFor(capability)?.let { throw IllegalStateException(it) }
|
|
867
|
+
Log.i(TAG, "end for everyone: hangUp(forEveryone=true) allowed=${capability.allowed}")
|
|
868
|
+
active.hangUp(HangUpOptions().setForEveryone(true)).get()
|
|
869
|
+
Log.i(TAG, "end for everyone: ACS accepted the hang-up")
|
|
870
|
+
SoftApTrace.stage(
|
|
871
|
+
"session_end_for_everyone_end",
|
|
872
|
+
"durationMs" to (SystemClock.elapsedRealtime() - startedAt),
|
|
873
|
+
)
|
|
874
|
+
} catch (error: Exception) {
|
|
875
|
+
Log.w(TAG, "end for everyone failed", error)
|
|
876
|
+
SoftApTrace.failure(
|
|
877
|
+
"session_end_for_everyone_failed",
|
|
878
|
+
"durationMs" to (SystemClock.elapsedRealtime() - startedAt),
|
|
879
|
+
"reason" to "${error.javaClass.simpleName}: ${error.message ?: ""}",
|
|
880
|
+
)
|
|
881
|
+
failure.set(error)
|
|
882
|
+
} finally {
|
|
883
|
+
done.countDown()
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
val settled = done.await(END_FOR_EVERYONE_WAIT_MS, TimeUnit.MILLISECONDS)
|
|
887
|
+
if (!settled) {
|
|
888
|
+
SoftApTrace.failure("session_end_for_everyone_timeout", "timeoutMs" to END_FOR_EVERYONE_WAIT_MS)
|
|
889
|
+
}
|
|
890
|
+
// Queued unconditionally, and after the await so it cannot dispose the agent out from under the
|
|
891
|
+
// hang-up. A timed-out End still leaves this device.
|
|
464
892
|
executor.execute { leaveLocked() }
|
|
893
|
+
if (!settled) throw IllegalStateException("end_for_everyone_timeout")
|
|
894
|
+
failure.get()?.let { throw it }
|
|
895
|
+
return snapshot()
|
|
465
896
|
}
|
|
466
897
|
|
|
898
|
+
/** Latest capability read, for a host that wants to enable or hide End before the user taps it. */
|
|
899
|
+
fun hangUpForEveryoneCapability(): CapabilityStatus = hangUpForEveryone
|
|
900
|
+
|
|
467
901
|
fun getState(): Map<String, Any> = snapshot()
|
|
468
902
|
|
|
469
903
|
private fun desiredKind(): AudioSourceKind =
|
|
@@ -508,17 +942,54 @@ class AcsMeetingSession(
|
|
|
508
942
|
if (!outgoingReady.get()) return
|
|
509
943
|
// Resample here, but do not touch ACS: sending straight from this thread
|
|
510
944
|
// hands ACS the glasses' audio clock in bursts. The pacer decides when.
|
|
511
|
-
|
|
512
|
-
|
|
945
|
+
uplinkChain?.ingest(pcm, sampleRate, channels)
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Accept one buffer of microphone PCM from the host.
|
|
950
|
+
*
|
|
951
|
+
* The BLE LC3 path: the glasses encode their microphone, the Bluetooth SDK decodes it on the
|
|
952
|
+
* phone, and the host forwards it here rather than the wearer's voice riding the WHIP track. It
|
|
953
|
+
* is deliberately a hard drop rather than a buffer when there is no call to feed — a frame kept
|
|
954
|
+
* for a session that is going away is a frame played into the *next* call.
|
|
955
|
+
*
|
|
956
|
+
* @return true when the buffer entered the uplink
|
|
957
|
+
*/
|
|
958
|
+
fun pushOutgoingPcm(base64: String, sampleRate: Int, channels: Int): Boolean {
|
|
959
|
+
if (!externalPcmEnabled.get()) return false
|
|
960
|
+
if (phase == "idle" || phase == "disconnected" || phase == "error") return false
|
|
961
|
+
val pcm = try {
|
|
962
|
+
Base64.decode(base64, Base64.DEFAULT)
|
|
963
|
+
} catch (error: IllegalArgumentException) {
|
|
964
|
+
Log.w(TAG, "pushOutgoingPcm got undecodable base64 len=${base64.length}", error)
|
|
965
|
+
return false
|
|
966
|
+
}
|
|
967
|
+
if (pcm.isEmpty()) return false
|
|
968
|
+
feedOutgoingPcm(pcm, sampleRate, channels)
|
|
969
|
+
return true
|
|
513
970
|
}
|
|
514
971
|
|
|
515
972
|
@Synchronized
|
|
516
973
|
private fun startUplink(stream: RawOutgoingAudioStream) {
|
|
517
974
|
if (uplinkSender != null) return
|
|
518
975
|
pacer.reset()
|
|
519
|
-
val sender = UplinkSender(
|
|
976
|
+
val sender = UplinkSender(
|
|
977
|
+
pacer,
|
|
978
|
+
AcsUplinkTransport(stream),
|
|
979
|
+
muted = { muted.get() },
|
|
980
|
+
pcmMeanAbs = { pcmBridge?.lastMeanAbs ?: -1 },
|
|
981
|
+
)
|
|
520
982
|
uplinkSender = sender
|
|
521
983
|
sender.start()
|
|
984
|
+
// The A/V configuration this call ran with, stated once at the top so a receiver recording can
|
|
985
|
+
// be attributed to it. The measured ingest offset is a separate `AVSYNC clap audioLeadMs`
|
|
986
|
+
// line. Never print the two as one number: a delay that is configured is not an offset that
|
|
987
|
+
// was observed, and conflating them is how a calibration gets believed.
|
|
988
|
+
Log.i(
|
|
989
|
+
TAG,
|
|
990
|
+
"P8 audio-up config configuredDelayMs=$configuredAudioDelayMs " +
|
|
991
|
+
"audioTimestamps=${AcsInvestigation.acsAudioTimestamps}",
|
|
992
|
+
)
|
|
522
993
|
}
|
|
523
994
|
|
|
524
995
|
@Synchronized
|
|
@@ -537,16 +1008,24 @@ class AcsMeetingSession(
|
|
|
537
1008
|
CallState.CONNECTING -> "connecting"
|
|
538
1009
|
CallState.IN_LOBBY -> "lobby"
|
|
539
1010
|
CallState.CONNECTED -> "connected"
|
|
540
|
-
CallState.DISCONNECTING ->
|
|
1011
|
+
CallState.DISCONNECTING -> phase
|
|
541
1012
|
CallState.DISCONNECTED -> "disconnected"
|
|
542
1013
|
else -> phase
|
|
543
1014
|
}
|
|
544
1015
|
val end = describeEndReason(call)
|
|
1016
|
+
// A failed connection is recoverable; do not tell the wearer the remote meeting ended.
|
|
1017
|
+
// Wait for DISCONNECTED because DISCONNECTING may not yet carry the final reason.
|
|
1018
|
+
if (state == CallState.DISCONNECTED && (end["code"] as? Number)?.toInt()?.let { it != 0 } == true) {
|
|
1019
|
+
phase = "error"
|
|
1020
|
+
lastError = "ACS_CONNECTION_LOST: The Teams connection was lost. Check mobile data and rejoin. " +
|
|
1021
|
+
"(code=${end["code"]}, subcode=${end["subcode"]})"
|
|
1022
|
+
}
|
|
545
1023
|
Log.i(TAG, "ACS call state=$state phase=$phase previous=$previous end=$end")
|
|
546
1024
|
if (phase == "connected" && previous != "connected") {
|
|
547
1025
|
call?.let {
|
|
548
1026
|
attachMediaStats(it)
|
|
549
1027
|
attachDiagnostics(it)
|
|
1028
|
+
attachCapabilities(it)
|
|
550
1029
|
}
|
|
551
1030
|
applyAudioPolicy("call-connected")
|
|
552
1031
|
} else {
|
|
@@ -562,6 +1041,7 @@ class AcsMeetingSession(
|
|
|
562
1041
|
private fun attachMediaStats(joined: Call) {
|
|
563
1042
|
detachMediaStats()
|
|
564
1043
|
mediaStatsReports.set(0)
|
|
1044
|
+
lastWireSizeKey = null
|
|
565
1045
|
try {
|
|
566
1046
|
val feature = joined.feature(Features.MEDIA_STATISTICS)
|
|
567
1047
|
val listener = MediaStatisticsReportReceivedListener { event ->
|
|
@@ -581,11 +1061,13 @@ class AcsMeetingSession(
|
|
|
581
1061
|
stats.wireWidth = video?.frameWidth
|
|
582
1062
|
stats.wireHeight = video?.frameHeight
|
|
583
1063
|
stats.wireBitrateBps = video?.bitrateInBps?.toLong()
|
|
1064
|
+
stats.wirePacketCount = video?.packetCount
|
|
584
1065
|
val codec = video?.codecName.orEmpty()
|
|
585
1066
|
if (codec.isNotBlank() && codec != stats.codecName) {
|
|
586
1067
|
Log.i(TAG, "P6 wire codec=$codec ${video?.frameWidth}x${video?.frameHeight} fps=${video?.frameRate}")
|
|
587
1068
|
}
|
|
588
1069
|
stats.codecName = codec
|
|
1070
|
+
reportWireAdaptation(video?.frameWidth, video?.frameHeight, video?.frameRate)
|
|
589
1071
|
val width = video?.frameWidth
|
|
590
1072
|
val height = video?.frameHeight
|
|
591
1073
|
if (width != null && height != null && width > 0 && height > 0) {
|
|
@@ -605,6 +1087,35 @@ class AcsMeetingSession(
|
|
|
605
1087
|
}
|
|
606
1088
|
}
|
|
607
1089
|
|
|
1090
|
+
/**
|
|
1091
|
+
* Logs a change in what ACS is actually putting on the wire versus the profile we asked for.
|
|
1092
|
+
*
|
|
1093
|
+
* ACS runs its own rate controller and will trade resolution for frames inside the budget it was
|
|
1094
|
+
* given, so the negotiated 1280x720 is a ceiling, not a promise. Without this the ladder prints
|
|
1095
|
+
* the adapted size once per second and a permanent downscale reads exactly like a healthy call —
|
|
1096
|
+
* the number is right there and nothing ever calls it out. Logged on transition only, because at
|
|
1097
|
+
* 1 Hz a warning per report is noise nobody reads.
|
|
1098
|
+
*/
|
|
1099
|
+
private fun reportWireAdaptation(width: Int?, height: Int?, fps: Float?) {
|
|
1100
|
+
if (width == null || height == null || width <= 0 || height <= 0) return
|
|
1101
|
+
val key = "${width}x$height"
|
|
1102
|
+
if (key == lastWireSizeKey) return
|
|
1103
|
+
lastWireSizeKey = key
|
|
1104
|
+
val askedPixels = profile.width.toLong() * profile.height
|
|
1105
|
+
val gotPixels = width.toLong() * height
|
|
1106
|
+
if (gotPixels < askedPixels) {
|
|
1107
|
+
val percent = (gotPixels * 100 / askedPixels).toInt()
|
|
1108
|
+
Log.w(
|
|
1109
|
+
TAG,
|
|
1110
|
+
"P6 wire ADAPTED_DOWN acs=$key (${percent}% of ${profile.width}x${profile.height}) " +
|
|
1111
|
+
"fps=${fps ?: "na"} codec=${stats.codecName.ifBlank { "na" }} " +
|
|
1112
|
+
"budget=${profile.maxBitrateBps} bitsPerFrame=${profile.bitsPerFrame()}",
|
|
1113
|
+
)
|
|
1114
|
+
} else {
|
|
1115
|
+
Log.i(TAG, "P6 wire size=$key at or above profile ${profile.width}x${profile.height}")
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
608
1119
|
private fun scheduleMediaStatsInterval(feature: MediaStatisticsCallFeature, attempt: Int) {
|
|
609
1120
|
executor.schedule({
|
|
610
1121
|
if (mediaStatsFeature !== feature) return@schedule
|
|
@@ -635,7 +1146,10 @@ class AcsMeetingSession(
|
|
|
635
1146
|
val feature = joined.feature(Features.LOCAL_USER_DIAGNOSTICS) as LocalUserDiagnosticsCallFeature
|
|
636
1147
|
val network = feature.networkDiagnostics
|
|
637
1148
|
val onSend = DiagnosticQualityChangedListener { args ->
|
|
638
|
-
|
|
1149
|
+
val quality = args.value?.name ?: "null"
|
|
1150
|
+
// Latched so `P9 quality` can reprint it every tick; this fires on change only.
|
|
1151
|
+
stats.sendQuality = quality
|
|
1152
|
+
logDiagnostic("networkSendQuality", quality)
|
|
639
1153
|
}
|
|
640
1154
|
val onReconnect = DiagnosticQualityChangedListener { args ->
|
|
641
1155
|
logDiagnostic("networkReconnectionQuality", args.value?.name ?: "null")
|
|
@@ -665,6 +1179,68 @@ class AcsMeetingSession(
|
|
|
665
1179
|
Log.i(TAG, "P7 diag $name=$value")
|
|
666
1180
|
}
|
|
667
1181
|
|
|
1182
|
+
/**
|
|
1183
|
+
* Subscribe to participant capabilities so End can be offered honestly.
|
|
1184
|
+
*
|
|
1185
|
+
* The capability that matters is `HANG_UP_FOR_EVERYONE`. It can flip mid-call — a presenter role
|
|
1186
|
+
* granted or removed — so the listener stays attached rather than reading once at connect.
|
|
1187
|
+
*/
|
|
1188
|
+
private fun attachCapabilities(joined: Call) {
|
|
1189
|
+
detachCapabilities()
|
|
1190
|
+
try {
|
|
1191
|
+
val feature = joined.feature(Features.CAPABILITIES)
|
|
1192
|
+
val listener = CapabilitiesChangedListener { event ->
|
|
1193
|
+
val changed = event.changedCapabilities.orEmpty()
|
|
1194
|
+
.any { it.type == ParticipantCapabilityType.HANG_UP_FOR_EVERYONE }
|
|
1195
|
+
if (!changed) return@CapabilitiesChangedListener
|
|
1196
|
+
val next = readHangUpForEveryone(feature)
|
|
1197
|
+
if (next == hangUpForEveryone) return@CapabilitiesChangedListener
|
|
1198
|
+
hangUpForEveryone = next
|
|
1199
|
+
Log.i(TAG, "capability hangUpForEveryone allowed=${next.allowed} reason=${next.reason} (changed)")
|
|
1200
|
+
onState(snapshot())
|
|
1201
|
+
}
|
|
1202
|
+
feature.addOnCapabilitiesChangedListener(listener)
|
|
1203
|
+
capabilitiesFeature = feature
|
|
1204
|
+
capabilitiesListener = listener
|
|
1205
|
+
hangUpForEveryone = readHangUpForEveryone(feature)
|
|
1206
|
+
Log.i(
|
|
1207
|
+
TAG,
|
|
1208
|
+
"capability hangUpForEveryone allowed=${hangUpForEveryone.allowed} reason=${hangUpForEveryone.reason}",
|
|
1209
|
+
)
|
|
1210
|
+
} catch (error: Exception) {
|
|
1211
|
+
// Unknown, not denied: an End is still attempted and ACS gets to answer.
|
|
1212
|
+
Log.w(TAG, "CAPABILITIES attach failed", error)
|
|
1213
|
+
hangUpForEveryone = CapabilityStatus(reason = "capabilities_unavailable")
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
private fun readHangUpForEveryone(
|
|
1218
|
+
feature: CapabilitiesCallFeature? = capabilitiesFeature,
|
|
1219
|
+
): CapabilityStatus {
|
|
1220
|
+
val current = feature ?: return CapabilityStatus(reason = "capabilities_unavailable")
|
|
1221
|
+
return try {
|
|
1222
|
+
val capability = current.capabilities.orEmpty()
|
|
1223
|
+
.firstOrNull { it.type == ParticipantCapabilityType.HANG_UP_FOR_EVERYONE }
|
|
1224
|
+
?: return CapabilityStatus(reason = "not_reported")
|
|
1225
|
+
CapabilityStatus(capability.isAllowed, capability.reason?.name?.lowercase())
|
|
1226
|
+
} catch (error: Exception) {
|
|
1227
|
+
Log.w(TAG, "capabilities read failed", error)
|
|
1228
|
+
CapabilityStatus(reason = "capabilities_unavailable")
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
private fun detachCapabilities() {
|
|
1233
|
+
val feature = capabilitiesFeature
|
|
1234
|
+
val listener = capabilitiesListener
|
|
1235
|
+
capabilitiesFeature = null
|
|
1236
|
+
capabilitiesListener = null
|
|
1237
|
+
if (feature == null || listener == null) return
|
|
1238
|
+
try {
|
|
1239
|
+
feature.removeOnCapabilitiesChangedListener(listener)
|
|
1240
|
+
} catch (_: Exception) {
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
|
|
668
1244
|
private fun detachDiagnostics() {
|
|
669
1245
|
val network = netDiagnostics ?: return
|
|
670
1246
|
try {
|
|
@@ -694,51 +1270,275 @@ class AcsMeetingSession(
|
|
|
694
1270
|
}
|
|
695
1271
|
}
|
|
696
1272
|
|
|
697
|
-
|
|
1273
|
+
/**
|
|
1274
|
+
* Sign in to ACS with a deadline, and make sure nothing survives a missed one.
|
|
1275
|
+
*
|
|
1276
|
+
* `createCallAgent` returns a future with no timeout of its own, and it is the first thing after
|
|
1277
|
+
* the hotspot join that needs the internet. Unbounded, a cellular route that had not validated
|
|
1278
|
+
* yet turned into a 30 s stall followed by the join step's own timeout — one opaque failure
|
|
1279
|
+
* covering a specific, nameable cause.
|
|
1280
|
+
*
|
|
1281
|
+
* Do not cancel that future. Cancel marks it done without a value, so the sweeper can no longer
|
|
1282
|
+
* recover the native agent ACS still creates. The next join then dies with "CallAgent associated
|
|
1283
|
+
* with this identity already exists".
|
|
1284
|
+
*/
|
|
1285
|
+
private fun obtainCallAgent(
|
|
1286
|
+
client: CallClient,
|
|
1287
|
+
credential: CommunicationTokenCredential,
|
|
1288
|
+
options: CallAgentOptions,
|
|
1289
|
+
generation: Int,
|
|
1290
|
+
waitMs: Long = CALL_AGENT_WAIT_MS,
|
|
1291
|
+
): CallAgent {
|
|
1292
|
+
disposeAbandonedAgent(waitMs = ABANDONED_AGENT_REJOIN_WAIT_MS)
|
|
1293
|
+
return try {
|
|
1294
|
+
awaitCallAgent(client, credential, options, generation, waitMs)
|
|
1295
|
+
} catch (error: Exception) {
|
|
1296
|
+
if (!AbandonedCallAgent.isExistingAgentError(error)) throw error
|
|
1297
|
+
Log.w(TAG, "createCallAgent hit leftover identity; disposing abandoned agent and retrying")
|
|
1298
|
+
disposeAbandonedAgent(waitMs = ABANDONED_AGENT_REJOIN_WAIT_MS)
|
|
1299
|
+
awaitCallAgent(client, credential, options, generation, waitMs)
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
private fun awaitCallAgent(
|
|
1304
|
+
client: CallClient,
|
|
1305
|
+
credential: CommunicationTokenCredential,
|
|
1306
|
+
options: CallAgentOptions,
|
|
1307
|
+
generation: Int,
|
|
1308
|
+
waitMs: Long,
|
|
1309
|
+
): CallAgent {
|
|
1310
|
+
val startedAt = SystemClock.elapsedRealtime()
|
|
1311
|
+
SoftApTrace.stage("session_call_agent_wait", "waitMs" to waitMs, "generation" to generation)
|
|
1312
|
+
val pending = client.createCallAgent(context, credential, options)
|
|
1313
|
+
val agent = try {
|
|
1314
|
+
pending.get(waitMs, TimeUnit.MILLISECONDS)
|
|
1315
|
+
} catch (timeout: TimeoutException) {
|
|
1316
|
+
abandonedAgent = pending
|
|
1317
|
+
sweepLateAgent(pending, 0)
|
|
1318
|
+
// The sign-in is still running and still owns the identity. Named here because the next
|
|
1319
|
+
// join's "identity already exists" failure is otherwise the first sign of this one.
|
|
1320
|
+
SoftApTrace.failure(
|
|
1321
|
+
"session_call_agent_abandoned",
|
|
1322
|
+
"waitedMs" to (SystemClock.elapsedRealtime() - startedAt),
|
|
1323
|
+
"waitMs" to waitMs,
|
|
1324
|
+
)
|
|
1325
|
+
throw IllegalStateException(
|
|
1326
|
+
"ACS_AGENT_TIMEOUT: Teams did not finish signing this phone in within " +
|
|
1327
|
+
"${waitMs / 1000}s. This step needs the internet, so it usually means mobile " +
|
|
1328
|
+
"data had not taken over yet after joining the glasses hotspot.",
|
|
1329
|
+
timeout,
|
|
1330
|
+
)
|
|
1331
|
+
}
|
|
1332
|
+
// Leave now bumps generation off this executor, so a Cancel during `createCallAgent` can
|
|
1333
|
+
// land while we are blocked above. That is the case this check exists for.
|
|
1334
|
+
if (generation != joinGeneration.get()) {
|
|
1335
|
+
runCatching { agent.dispose() }
|
|
1336
|
+
SoftApTrace.failure(
|
|
1337
|
+
"session_call_agent_stale",
|
|
1338
|
+
"waitedMs" to (SystemClock.elapsedRealtime() - startedAt),
|
|
1339
|
+
"generation" to generation,
|
|
1340
|
+
"current" to joinGeneration.get(),
|
|
1341
|
+
)
|
|
1342
|
+
throw IllegalStateException("ACS_AGENT_STALE: the call was torn down before Teams signed in")
|
|
1343
|
+
}
|
|
1344
|
+
SoftApTrace.stage("session_call_agent_ready", "waitedMs" to (SystemClock.elapsedRealtime() - startedAt))
|
|
1345
|
+
return agent
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
/**
|
|
1349
|
+
* Dispose an agent that turns up after its wait was abandoned.
|
|
1350
|
+
*
|
|
1351
|
+
* Polls rather than chaining a completion callback because the SDK hands back a bare [Future].
|
|
1352
|
+
* Gives up after a bounded number of sweeps: by then the process has either got the agent or the
|
|
1353
|
+
* future is never completing, and an endless timer is its own leak.
|
|
1354
|
+
*/
|
|
1355
|
+
private fun sweepLateAgent(pending: Future<CallAgent>, sweep: Int) {
|
|
1356
|
+
if (sweep >= LATE_AGENT_SWEEPS) {
|
|
1357
|
+
Log.w(TAG, "abandoned call agent never completed; stopping sweep")
|
|
1358
|
+
// Giving up on the sweep is giving up on disposing that agent. It is a bounded leak by
|
|
1359
|
+
// design, but it is a leak, and the next sign-in is where it will be felt.
|
|
1360
|
+
SoftApTrace.failure("session_late_agent_abandoned", "sweeps" to sweep)
|
|
1361
|
+
return
|
|
1362
|
+
}
|
|
1363
|
+
executor.schedule({
|
|
1364
|
+
if (abandonedAgent !== pending) return@schedule
|
|
1365
|
+
val late = AbandonedCallAgent.takeIfDone(pending)
|
|
1366
|
+
if (late == null) {
|
|
1367
|
+
if (!pending.isDone) sweepLateAgent(pending, sweep + 1)
|
|
1368
|
+
return@schedule
|
|
1369
|
+
}
|
|
1370
|
+
abandonedAgent = null
|
|
1371
|
+
Log.w(TAG, "disposing call agent that arrived after its join was abandoned")
|
|
1372
|
+
SoftApTrace.stage("session_late_agent_disposed", "sweep" to sweep)
|
|
1373
|
+
runCatching { late.dispose() }
|
|
1374
|
+
}, LATE_AGENT_SWEEP_MS, TimeUnit.MILLISECONDS)
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* Best-effort dispose of a leftover agent before the next `createCallAgent`.
|
|
1379
|
+
*
|
|
1380
|
+
* [waitMs] is for rejoin: the previous sign-in may still be finishing, and that is the only
|
|
1381
|
+
* handle that can free the identity ACS refuses to share.
|
|
1382
|
+
*/
|
|
1383
|
+
private fun disposeAbandonedAgent(waitMs: Long) {
|
|
1384
|
+
val pending = abandonedAgent ?: return
|
|
1385
|
+
val startedAt = SystemClock.elapsedRealtime()
|
|
1386
|
+
val late = AbandonedCallAgent.take(pending, waitMs)
|
|
1387
|
+
if (late != null) {
|
|
1388
|
+
abandonedAgent = null
|
|
1389
|
+
Log.w(TAG, "disposing abandoned call agent before the next join")
|
|
1390
|
+
SoftApTrace.stage(
|
|
1391
|
+
"session_abandoned_agent_disposed",
|
|
1392
|
+
"waitedMs" to (SystemClock.elapsedRealtime() - startedAt),
|
|
1393
|
+
)
|
|
1394
|
+
runCatching { late.dispose() }
|
|
1395
|
+
return
|
|
1396
|
+
}
|
|
1397
|
+
// Still not finished. The identity stays taken, so the `createCallAgent` about to run may be
|
|
1398
|
+
// refused — which is the retry [obtainCallAgent] exists for, not an unexplained join failure.
|
|
1399
|
+
SoftApTrace.failure(
|
|
1400
|
+
"session_abandoned_agent_unclaimed",
|
|
1401
|
+
"waitedMs" to (SystemClock.elapsedRealtime() - startedAt),
|
|
1402
|
+
"done" to pending.isDone,
|
|
1403
|
+
)
|
|
1404
|
+
if (pending.isDone) abandonedAgent = null
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
/**
|
|
1408
|
+
* @param failures when present, the first cleanup exception is recorded here instead of only
|
|
1409
|
+
* being logged, so [leaveAndAwait] can tell its caller the teardown did not really succeed
|
|
1410
|
+
*/
|
|
1411
|
+
private fun leaveLocked(
|
|
1412
|
+
emitIdle: Boolean = true,
|
|
1413
|
+
keepAgent: Boolean = false,
|
|
1414
|
+
failures: AtomicReference<Exception?>? = null,
|
|
1415
|
+
) {
|
|
1416
|
+
// Invalidate first: a bounded ACS operation still in flight has to find a stale generation
|
|
1417
|
+
// rather than attach an agent to a session that is being torn down.
|
|
1418
|
+
joinGeneration.incrementAndGet()
|
|
1419
|
+
val startedAt = SystemClock.elapsedRealtime()
|
|
1420
|
+
SoftApTrace.stage(
|
|
1421
|
+
"session_cleanup_begin",
|
|
1422
|
+
"emitIdle" to emitIdle,
|
|
1423
|
+
"keepAgent" to keepAgent,
|
|
1424
|
+
"hasCall" to (call != null),
|
|
1425
|
+
"hasAgent" to (callAgent != null),
|
|
1426
|
+
"recordsFailures" to (failures != null),
|
|
1427
|
+
)
|
|
1428
|
+
// A dozen releases share one `try`, so the catch below cannot name the one that threw — and
|
|
1429
|
+
// the first throw skips every release after it. The breadcrumb is what turns "leave cleanup
|
|
1430
|
+
// failed" into a location; it is coarse on purpose, one name per group of related releases.
|
|
1431
|
+
var releasing = "telemetry"
|
|
698
1432
|
try {
|
|
699
1433
|
ticker.stop()
|
|
700
1434
|
detachDiagnostics()
|
|
701
1435
|
detachMediaStats()
|
|
1436
|
+
detachCapabilities()
|
|
702
1437
|
roster.detach()
|
|
1438
|
+
releasing = "audio"
|
|
703
1439
|
phoneMic.setEnabled(false)
|
|
704
1440
|
stopUplink()
|
|
705
1441
|
incomingPump.reset()
|
|
706
1442
|
incomingProbe.reset()
|
|
707
1443
|
applier.reset()
|
|
708
1444
|
scheduler.cancelPending()
|
|
1445
|
+
// Drop buffered voice before the dump: whatever is still in the chain belongs to a call that
|
|
1446
|
+
// is over, and the next call builds its own chain rather than inheriting this one.
|
|
1447
|
+
uplinkChain?.reset()
|
|
1448
|
+
uplinkChain = null
|
|
1449
|
+
externalPcmEnabled.set(false)
|
|
709
1450
|
pcmBridge?.finishDump()
|
|
710
1451
|
// Detach before stop so the teardown's own IDLE transition does not emit a
|
|
711
1452
|
// snapshot (or schedule a rebuild) for a call that is going away.
|
|
1453
|
+
releasing = "media"
|
|
712
1454
|
cancelMediaRestart()
|
|
713
1455
|
media.setStateListener(null)
|
|
714
1456
|
mediaSource = SourceState.IDLE
|
|
1457
|
+
currentSourceKind = SourceKind.WHEP
|
|
715
1458
|
media.stop()
|
|
716
1459
|
frameSender.detach()
|
|
1460
|
+
releasing = "none"
|
|
717
1461
|
} catch (error: Exception) {
|
|
718
1462
|
Log.w(TAG, "leave cleanup failed", error)
|
|
1463
|
+
// Whether this was recorded decides whether the host refuses the next call or never hears
|
|
1464
|
+
// about it, so the trace says which of the two happened.
|
|
1465
|
+
SoftApTrace.failure(
|
|
1466
|
+
"session_cleanup_failed",
|
|
1467
|
+
"releasing" to releasing,
|
|
1468
|
+
"reason" to "${error.javaClass.simpleName}: ${error.message ?: ""}",
|
|
1469
|
+
"recorded" to (failures != null),
|
|
1470
|
+
)
|
|
1471
|
+
failures?.compareAndSet(null, error)
|
|
719
1472
|
}
|
|
720
1473
|
// Hang up and dispose must be independent: a failed hang-up must not skip
|
|
721
1474
|
// dispose, or the ACS agent leaks and the guest stays in the Teams roster.
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
1475
|
+
if (!keepAgent) {
|
|
1476
|
+
val hangUpStartedAt = SystemClock.elapsedRealtime()
|
|
1477
|
+
try {
|
|
1478
|
+
val pending = call?.hangUp()
|
|
1479
|
+
if (pending != null) {
|
|
1480
|
+
// Bounded on purpose: an unbounded `get()` on this Future is what parked Leave behind
|
|
1481
|
+
// the previous call on the single session executor, so Cancel never came back.
|
|
1482
|
+
pending.get(HANGUP_WAIT_MS, TimeUnit.MILLISECONDS)
|
|
1483
|
+
}
|
|
1484
|
+
SoftApTrace.stage(
|
|
1485
|
+
"session_hangup",
|
|
1486
|
+
"hadCall" to (call != null),
|
|
1487
|
+
"durationMs" to (SystemClock.elapsedRealtime() - hangUpStartedAt),
|
|
1488
|
+
)
|
|
1489
|
+
} catch (timeout: TimeoutException) {
|
|
1490
|
+
Log.w(TAG, "leave hangUp timed out after ${HANGUP_WAIT_MS}ms")
|
|
1491
|
+
SoftApTrace.failure(
|
|
1492
|
+
"session_hangup_timeout",
|
|
1493
|
+
"durationMs" to (SystemClock.elapsedRealtime() - hangUpStartedAt),
|
|
1494
|
+
"timeoutMs" to HANGUP_WAIT_MS,
|
|
1495
|
+
"recorded" to (failures != null),
|
|
1496
|
+
)
|
|
1497
|
+
failures?.compareAndSet(null, IllegalStateException("acs_hangup_timeout"))
|
|
1498
|
+
} catch (error: Exception) {
|
|
1499
|
+
Log.w(TAG, "leave hangUp failed", error)
|
|
1500
|
+
SoftApTrace.failure(
|
|
1501
|
+
"session_hangup_failed",
|
|
1502
|
+
"durationMs" to (SystemClock.elapsedRealtime() - hangUpStartedAt),
|
|
1503
|
+
"reason" to "${error.javaClass.simpleName}: ${error.message ?: ""}",
|
|
1504
|
+
"recorded" to (failures != null),
|
|
1505
|
+
)
|
|
1506
|
+
failures?.compareAndSet(null, error)
|
|
1507
|
+
}
|
|
1508
|
+
val disposeStartedAt = SystemClock.elapsedRealtime()
|
|
1509
|
+
try {
|
|
1510
|
+
callAgent?.dispose()
|
|
1511
|
+
SoftApTrace.stage(
|
|
1512
|
+
"session_agent_disposed",
|
|
1513
|
+
"hadAgent" to (callAgent != null),
|
|
1514
|
+
"durationMs" to (SystemClock.elapsedRealtime() - disposeStartedAt),
|
|
1515
|
+
)
|
|
1516
|
+
} catch (error: Exception) {
|
|
1517
|
+
Log.w(TAG, "leave dispose failed", error)
|
|
1518
|
+
// A leaked agent keeps the identity ACS refuses to share, so the next sign-in fails with
|
|
1519
|
+
// "CallAgent associated with this identity already exists" rather than here.
|
|
1520
|
+
SoftApTrace.failure(
|
|
1521
|
+
"session_agent_dispose_failed",
|
|
1522
|
+
"durationMs" to (SystemClock.elapsedRealtime() - disposeStartedAt),
|
|
1523
|
+
"reason" to "${error.javaClass.simpleName}: ${error.message ?: ""}",
|
|
1524
|
+
"recorded" to (failures != null),
|
|
1525
|
+
)
|
|
1526
|
+
failures?.compareAndSet(null, error)
|
|
1527
|
+
}
|
|
1528
|
+
disposeAbandonedAgent(waitMs = 0)
|
|
1529
|
+
callClient = null
|
|
1530
|
+
call = null
|
|
1531
|
+
callAgent = null
|
|
1532
|
+
agentPrepared = false
|
|
731
1533
|
}
|
|
732
|
-
callClient = null
|
|
733
1534
|
media.stop()
|
|
734
|
-
call = null
|
|
735
|
-
callAgent = null
|
|
736
1535
|
audioOut = null
|
|
737
1536
|
localOut = null
|
|
738
1537
|
audioIn = null
|
|
739
1538
|
videoOut = null
|
|
740
1539
|
outgoingReady.set(false)
|
|
741
1540
|
muted.set(false)
|
|
1541
|
+
hangUpForEveryone = CapabilityStatus()
|
|
742
1542
|
audioSource = "glasses"
|
|
743
1543
|
lastSafety = AudioSafety.DEGRADED
|
|
744
1544
|
meetingUrl = null
|
|
@@ -749,6 +1549,12 @@ class AcsMeetingSession(
|
|
|
749
1549
|
lastError = null
|
|
750
1550
|
emit("idle")
|
|
751
1551
|
}
|
|
1552
|
+
SoftApTrace.stage(
|
|
1553
|
+
"session_cleanup_end",
|
|
1554
|
+
"durationMs" to (SystemClock.elapsedRealtime() - startedAt),
|
|
1555
|
+
"keptAgent" to keepAgent,
|
|
1556
|
+
"recordedFailure" to (failures?.get()?.let { "${it.javaClass.simpleName}: ${it.message ?: ""}" } ?: "none"),
|
|
1557
|
+
)
|
|
752
1558
|
}
|
|
753
1559
|
|
|
754
1560
|
private inner class SessionAudioController : AudioStreamController {
|
|
@@ -765,7 +1571,9 @@ class AcsMeetingSession(
|
|
|
765
1571
|
override fun isPhysicallyMuted(): Boolean? = call?.isOutgoingAudioMuted
|
|
766
1572
|
|
|
767
1573
|
override fun setGlassesPcmEnabled(enabled: Boolean) {
|
|
768
|
-
|
|
1574
|
+
val routing = GlassesPcmRouting.decide(softap = currentSourceKind == SourceKind.SOFTAP, enabled = enabled)
|
|
1575
|
+
externalPcmEnabled.set(routing.externalPcm)
|
|
1576
|
+
media.setPcmDeliveryEnabled(routing.relayPcm)
|
|
769
1577
|
}
|
|
770
1578
|
|
|
771
1579
|
override fun setPhonePcmEnabled(enabled: Boolean) {
|
|
@@ -796,6 +1604,51 @@ class AcsMeetingSession(
|
|
|
796
1604
|
private const val ROSTER_COALESCE_MS = 150L
|
|
797
1605
|
private const val MEDIA_RESTART_BASE_MS = 1_000L
|
|
798
1606
|
private const val MEDIA_RESTART_MAX_MS = 10_000L
|
|
1607
|
+
/** SoftAP join() blocks until the WHIP listener is bound and ACS join is queued. */
|
|
1608
|
+
private const val SOFTAP_JOIN_WAIT_MS = 45_000L
|
|
1609
|
+
|
|
1610
|
+
/**
|
|
1611
|
+
* How long End waits for ACS to accept the hang-up before reporting it unconfirmed. Local
|
|
1612
|
+
* teardown runs either way; this only bounds how long the wearer stares at a confirm sheet.
|
|
1613
|
+
*/
|
|
1614
|
+
/**
|
|
1615
|
+
* How long Leave waits for ACS `hangUp()` before disposing the agent anyway.
|
|
1616
|
+
*
|
|
1617
|
+
* This Future has no timeout of its own. On the single session executor an unbounded `get()`
|
|
1618
|
+
* is the hang that made Cancel sit on "Leaving the meeting" while the next join queued behind
|
|
1619
|
+
* the same stuck hang-up.
|
|
1620
|
+
*/
|
|
1621
|
+
private const val HANGUP_WAIT_MS = 8_000L
|
|
1622
|
+
|
|
1623
|
+
private const val END_FOR_EVERYONE_WAIT_MS = 15_000L
|
|
1624
|
+
|
|
1625
|
+
/**
|
|
1626
|
+
* How long to wait for ACS to hand back a call agent.
|
|
1627
|
+
*
|
|
1628
|
+
* Well inside [SOFTAP_JOIN_WAIT_MS] on purpose: this step needs the internet, and the hotspot
|
|
1629
|
+
* join just changed which network provides it. On device it stalled 30 s here and then blew the
|
|
1630
|
+
* whole join budget, so the wearer got one useless timeout instead of a nameable failure.
|
|
1631
|
+
*/
|
|
1632
|
+
private const val CALL_AGENT_WAIT_MS = 20_000L
|
|
1633
|
+
|
|
1634
|
+
/**
|
|
1635
|
+
* How long [prepareAgent] waits, which is longer than [CALL_AGENT_WAIT_MS] and does not need to
|
|
1636
|
+
* fit any other budget: nothing is joined or held while it runs, so overrunning costs a slower
|
|
1637
|
+
* join rather than a hotspot the wearer then has to leave.
|
|
1638
|
+
*
|
|
1639
|
+
* Sized off a cold sign-in measured at ~35 s on a 544 ms-RTT AP.
|
|
1640
|
+
*/
|
|
1641
|
+
private const val PREPARE_AGENT_WAIT_MS = 60_000L
|
|
1642
|
+
|
|
1643
|
+
/** How long to keep sweeping for an agent that arrives after its wait was abandoned. */
|
|
1644
|
+
private const val LATE_AGENT_SWEEP_MS = 5_000L
|
|
1645
|
+
private const val LATE_AGENT_SWEEPS = 12
|
|
1646
|
+
/**
|
|
1647
|
+
* How long a rejoin waits for the abandoned sign-in to finish so we can dispose it. Shorter
|
|
1648
|
+
* than [CALL_AGENT_WAIT_MS]: the leftover agent is usually already done, and blocking the
|
|
1649
|
+
* wearer again for a full sign-in would hide a stuck Future.
|
|
1650
|
+
*/
|
|
1651
|
+
private const val ABANDONED_AGENT_REJOIN_WAIT_MS = 8_000L
|
|
799
1652
|
|
|
800
1653
|
fun sampleRateHz(rate: AudioStreamSampleRate?): Int? = when (rate) {
|
|
801
1654
|
AudioStreamSampleRate.HZ_16000 -> 16000
|