@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.
Files changed (100) hide show
  1. package/README.md +4 -2
  2. package/android/build.gradle +23 -0
  3. package/android/src/androidTest/java/com/mentra/acsmeeting/network/SoftApFeasibilityGateTest.kt +291 -0
  4. package/android/src/main/AndroidManifest.xml +2 -0
  5. package/android/src/main/java/com/mentra/acsmeeting/AbandonedCallAgent.kt +36 -0
  6. package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingModule.kt +392 -22
  7. package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingSession.kt +909 -56
  8. package/android/src/main/java/com/mentra/acsmeeting/CallCapabilities.kt +33 -0
  9. package/android/src/main/java/com/mentra/acsmeeting/audio/AcsAudioPolicy.kt +21 -0
  10. package/android/src/main/java/com/mentra/acsmeeting/audio/AudioUplinkChain.kt +126 -0
  11. package/android/src/main/java/com/mentra/acsmeeting/audio/PcmBridge.kt +27 -4
  12. package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkPacer.kt +45 -10
  13. package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkSender.kt +110 -5
  14. package/android/src/main/java/com/mentra/acsmeeting/network/InternetHold.kt +501 -0
  15. package/android/src/main/java/com/mentra/acsmeeting/network/Ipv4Prefix.kt +51 -0
  16. package/android/src/main/java/com/mentra/acsmeeting/network/NetworkFacts.kt +81 -0
  17. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetector.kt +355 -0
  18. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkError.kt +109 -0
  19. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkObserver.kt +160 -0
  20. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkReadiness.kt +103 -0
  21. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpec.kt +53 -0
  22. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkState.kt +127 -0
  23. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedSoftApNetwork.kt +573 -0
  24. package/android/src/main/java/com/mentra/acsmeeting/source/CloudflareWhepSource.kt +10 -101
  25. package/android/src/main/java/com/mentra/acsmeeting/source/DecodedTrackRelay.kt +147 -0
  26. package/android/src/main/java/com/mentra/acsmeeting/source/GlassesMediaSource.kt +47 -5
  27. package/android/src/main/java/com/mentra/acsmeeting/source/LocalWhipIngestSource.kt +772 -0
  28. package/android/src/main/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpec.kt +73 -0
  29. package/android/src/main/java/com/mentra/acsmeeting/source/SdpAdapter.kt +17 -0
  30. package/android/src/main/java/com/mentra/acsmeeting/source/SelectedIcePair.kt +139 -0
  31. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIceDiagnosis.kt +144 -0
  32. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIcePolicy.kt +46 -0
  33. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApSdpGuard.kt +160 -0
  34. package/android/src/main/java/com/mentra/acsmeeting/source/TrackRegistry.kt +3 -1
  35. package/android/src/main/java/com/mentra/acsmeeting/source/VideoSourceArm.kt +69 -0
  36. package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestProtocol.kt +207 -0
  37. package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestServer.kt +356 -0
  38. package/android/src/main/java/com/mentra/acsmeeting/telemetry/AvSyncProbe.kt +155 -0
  39. package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineStats.kt +94 -3
  40. package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineTicker.kt +33 -5
  41. package/android/src/main/java/com/mentra/acsmeeting/telemetry/RingPercentile.kt +15 -0
  42. package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoQuality.kt +178 -0
  43. package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoRateVerdict.kt +145 -0
  44. package/android/src/main/java/com/mentra/acsmeeting/trace/SoftApTrace.kt +143 -0
  45. package/android/src/main/java/com/mentra/acsmeeting/video/AcsFrameSender.kt +23 -4
  46. package/android/src/main/java/com/mentra/acsmeeting/video/FramePacer.kt +70 -0
  47. package/android/src/main/java/com/mentra/acsmeeting/video/I420FormatSpec.kt +1 -1
  48. package/android/src/main/java/com/mentra/acsmeeting/video/VideoProfile.kt +36 -3
  49. package/android/src/test/java/com/mentra/acsmeeting/AbandonedCallAgentTest.kt +74 -0
  50. package/android/src/test/java/com/mentra/acsmeeting/CallCapabilitiesTest.kt +47 -0
  51. package/android/src/test/java/com/mentra/acsmeeting/audio/AudioUplinkChainTest.kt +250 -0
  52. package/android/src/test/java/com/mentra/acsmeeting/audio/CapturePolicyTest.kt +44 -0
  53. package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkPacerTest.kt +34 -4
  54. package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkSenderTest.kt +96 -0
  55. package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldLifecycleTest.kt +102 -0
  56. package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldTest.kt +148 -0
  57. package/android/src/test/java/com/mentra/acsmeeting/network/Ipv4PrefixTest.kt +59 -0
  58. package/android/src/test/java/com/mentra/acsmeeting/network/NetworkFactsTest.kt +59 -0
  59. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedLocalNetworkPermissionTest.kt +35 -0
  60. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetectorTest.kt +165 -0
  61. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkErrorTest.kt +96 -0
  62. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkLifecycleTest.kt +187 -0
  63. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkObserverTest.kt +172 -0
  64. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkReadinessTest.kt +141 -0
  65. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpecTest.kt +69 -0
  66. package/android/src/test/java/com/mentra/acsmeeting/source/AcsInvestigationTest.kt +40 -0
  67. package/android/src/test/java/com/mentra/acsmeeting/source/DecodedTrackRelayTest.kt +41 -0
  68. package/android/src/test/java/com/mentra/acsmeeting/source/GlassesMediaSourceTest.kt +26 -8
  69. package/android/src/test/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpecTest.kt +109 -0
  70. package/android/src/test/java/com/mentra/acsmeeting/source/SelectedIcePairTest.kt +165 -0
  71. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIceDiagnosisTest.kt +148 -0
  72. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIcePolicyTest.kt +76 -0
  73. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApSdpGuardTest.kt +275 -0
  74. package/android/src/test/java/com/mentra/acsmeeting/source/TrackRegistryTest.kt +9 -0
  75. package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestProtocolTest.kt +356 -0
  76. package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestServerLoopbackTest.kt +411 -0
  77. package/android/src/test/java/com/mentra/acsmeeting/telemetry/AvSyncProbeTest.kt +83 -0
  78. package/android/src/test/java/com/mentra/acsmeeting/telemetry/PipelineStatsTest.kt +69 -3
  79. package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoQualityTest.kt +182 -0
  80. package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoRateVerdictTest.kt +149 -0
  81. package/android/src/test/java/com/mentra/acsmeeting/trace/SoftApTraceTest.kt +97 -0
  82. package/android/src/test/java/com/mentra/acsmeeting/video/AcsTimestampTest.kt +34 -0
  83. package/android/src/test/java/com/mentra/acsmeeting/video/FramePacerTest.kt +105 -0
  84. package/android/src/test/java/com/mentra/acsmeeting/video/I420FormatSpecTest.kt +5 -0
  85. package/android/src/test/java/com/mentra/acsmeeting/video/VideoProfileTest.kt +38 -3
  86. package/build/AcsMeeting.types.d.ts +5 -0
  87. package/build/AcsMeeting.types.d.ts.map +1 -1
  88. package/build/AcsMeeting.types.js.map +1 -1
  89. package/build/AcsMeetingModule.d.ts +23 -0
  90. package/build/AcsMeetingModule.d.ts.map +1 -1
  91. package/build/AcsMeetingModule.js.map +1 -1
  92. package/build/AcsMeetingModule.web.d.ts +15 -0
  93. package/build/AcsMeetingModule.web.d.ts.map +1 -1
  94. package/build/AcsMeetingModule.web.js +13 -0
  95. package/build/AcsMeetingModule.web.js.map +1 -1
  96. package/ios/AcsMeetingModule.swift +213 -2
  97. package/package.json +1 -1
  98. package/src/AcsMeeting.types.ts +5 -0
  99. package/src/AcsMeetingModule.ts +13 -0
  100. package/src/AcsMeetingModule.web.ts +13 -0
@@ -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 ticker = PipelineTicker(stats) {
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
- when (AcsInvestigation.videoArm) {
98
- VideoSourceArm.SYNTHETIC -> SyntheticI420Source(video, stats, frameSender::isReady)
99
- VideoSourceArm.WHEP -> CloudflareWhepSource(context, video, pcm, stats)
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
- whepUrl: String,
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
- leaveLocked(emitIdle = false)
203
- this.profile = video
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
- pcmBridge = PcmBridge(context.cacheDir, dumpWav)
209
- val credential = CommunicationTokenCredential(token)
210
- callClient = CallClient()
211
- val agentOptions = CallAgentOptions()
212
- agentOptions.displayName = displayName ?: "Mentra Call"
213
- callAgent = callClient!!.createCallAgent(context, credential, agentOptions).get()
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
- val locator = TeamsMeetingLinkLocator(teamsUrl)
310
- val joined = callAgent!!.join(context, locator, joinOptions)
311
- call = joined
312
- roster.attach(joined)
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
- media.attach(
336
- video = { planes ->
337
- frameSender.sendPlanes(planes)
338
- },
339
- pcm = { pcm, rate, channels -> feedOutgoingPcm(pcm, rate, channels) },
340
- config = SourceConfig(
341
- url = whepUrl,
342
- kind = if (synthetic) SourceKind.DIRECT else SourceKind.WHEP,
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
- val frames = pcmBridge?.ingest(pcm, sampleRate, channels) ?: return
512
- for (frame in frames) pacer.push(frame)
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(pacer, AcsUplinkTransport(stream))
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 -> "disconnected"
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
- logDiagnostic("networkSendQuality", args.value?.name ?: "null")
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
- private fun leaveLocked(emitIdle: Boolean = true) {
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
- try {
723
- call?.hangUp()?.get()
724
- } catch (error: Exception) {
725
- Log.w(TAG, "leave hangUp failed", error)
726
- }
727
- try {
728
- callAgent?.dispose()
729
- } catch (error: Exception) {
730
- Log.w(TAG, "leave dispose failed", error)
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
- media.setPcmDeliveryEnabled(enabled)
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