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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +14 -3
  2. package/android/build.gradle +1 -0
  3. package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingModule.kt +10 -10
  4. package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingSession.kt +37 -37
  5. package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkSender.kt +4 -4
  6. package/android/src/main/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpec.kt +3 -0
  7. package/android/src/main/java/com/mentra/acsmeeting/video/AcsFrameSender.kt +12 -10
  8. package/android/src/main/java/com/mentra/acsmeeting/video/I420FormatSpec.kt +2 -0
  9. package/android/src/main/java/com/mentra/acsmeeting/video/Nv12Packer.kt +2 -0
  10. package/android/src/test/java/com/mentra/acsmeeting/source/{AcsInvestigationTest.kt → MediaDiagnosticsTest.kt} +10 -10
  11. package/android/src/test/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpecTest.kt +3 -0
  12. package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoQualityTest.kt +2 -2
  13. package/android/src/test/java/com/mentra/acsmeeting/video/I420FormatSpecTest.kt +2 -0
  14. package/android/src/test/java/com/mentra/acsmeeting/video/VideoProfileTest.kt +2 -2
  15. package/build/AcsMeeting.types.d.ts +10 -1
  16. package/build/AcsMeeting.types.d.ts.map +1 -1
  17. package/build/AcsMeeting.types.js.map +1 -1
  18. package/build/AcsMeetingModule.d.ts +2 -2
  19. package/build/AcsMeetingModule.d.ts.map +1 -1
  20. package/build/AcsMeetingModule.js.map +1 -1
  21. package/ios/AcsMeeting.podspec +1 -1
  22. package/ios/AcsMeetingModule.swift +284 -148
  23. package/ios/PolicyKit/Package.swift +2 -1
  24. package/ios/PolicyKit/Sources/AcsAudioPolicy/CallbackOperation.swift +60 -0
  25. package/ios/PolicyKit/Sources/AcsAudioPolicy/CapturePolicy.swift +5 -0
  26. package/ios/PolicyKit/Tests/AcsAudioPolicyTests/AcsAudioPolicyTests.swift +1 -0
  27. package/ios/PolicyKit/Tests/AcsAudioPolicyTests/CallAgentRetirementTests.swift +113 -0
  28. package/package.json +3 -2
  29. package/src/AcsMeeting.types.ts +4 -1
  30. package/src/AcsMeetingModule.ts +2 -2
  31. package/android/src/androidTest/java/com/mentra/acsmeeting/network/SoftApFeasibilityGateTest.kt +0 -291
  32. package/android/src/main/java/com/mentra/acsmeeting/network/InternetHold.kt +0 -501
  33. package/android/src/main/java/com/mentra/acsmeeting/network/Ipv4Prefix.kt +0 -51
  34. package/android/src/main/java/com/mentra/acsmeeting/network/NetworkFacts.kt +0 -81
  35. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetector.kt +0 -355
  36. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkError.kt +0 -109
  37. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkObserver.kt +0 -160
  38. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkReadiness.kt +0 -103
  39. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpec.kt +0 -53
  40. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkState.kt +0 -127
  41. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedSoftApNetwork.kt +0 -573
  42. package/android/src/main/java/com/mentra/acsmeeting/source/BitmapFont.kt +0 -99
  43. package/android/src/main/java/com/mentra/acsmeeting/source/CloudflareWhepSource.kt +0 -487
  44. package/android/src/main/java/com/mentra/acsmeeting/source/DecodedTrackRelay.kt +0 -147
  45. package/android/src/main/java/com/mentra/acsmeeting/source/FirstFrameGate.kt +0 -45
  46. package/android/src/main/java/com/mentra/acsmeeting/source/GlassesMediaSource.kt +0 -143
  47. package/android/src/main/java/com/mentra/acsmeeting/source/LocalWhipIngestSource.kt +0 -772
  48. package/android/src/main/java/com/mentra/acsmeeting/source/MediaListeners.kt +0 -57
  49. package/android/src/main/java/com/mentra/acsmeeting/source/SdpAdapter.kt +0 -17
  50. package/android/src/main/java/com/mentra/acsmeeting/source/SelectedIcePair.kt +0 -139
  51. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIceDiagnosis.kt +0 -144
  52. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIcePolicy.kt +0 -46
  53. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApSdpGuard.kt +0 -160
  54. package/android/src/main/java/com/mentra/acsmeeting/source/SyntheticFrameFactory.kt +0 -205
  55. package/android/src/main/java/com/mentra/acsmeeting/source/SyntheticI420Source.kt +0 -214
  56. package/android/src/main/java/com/mentra/acsmeeting/source/TrackRegistry.kt +0 -33
  57. package/android/src/main/java/com/mentra/acsmeeting/source/VideoSourceArm.kt +0 -110
  58. package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestProtocol.kt +0 -207
  59. package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestServer.kt +0 -356
  60. package/android/src/main/java/com/mentra/acsmeeting/telemetry/AvSyncProbe.kt +0 -155
  61. package/android/src/main/java/com/mentra/acsmeeting/telemetry/ChromaProbe.kt +0 -78
  62. package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineStats.kt +0 -372
  63. package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineTicker.kt +0 -79
  64. package/android/src/main/java/com/mentra/acsmeeting/telemetry/ProcessCpu.kt +0 -15
  65. package/android/src/main/java/com/mentra/acsmeeting/telemetry/RingPercentile.kt +0 -48
  66. package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoQuality.kt +0 -178
  67. package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoRateVerdict.kt +0 -145
  68. package/android/src/main/java/com/mentra/acsmeeting/trace/SoftApTrace.kt +0 -143
  69. package/android/src/main/java/com/mentra/acsmeeting/video/FrameGeometry.kt +0 -22
  70. package/android/src/main/java/com/mentra/acsmeeting/video/I420Packer.kt +0 -72
  71. package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldLifecycleTest.kt +0 -102
  72. package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldTest.kt +0 -148
  73. package/android/src/test/java/com/mentra/acsmeeting/network/Ipv4PrefixTest.kt +0 -59
  74. package/android/src/test/java/com/mentra/acsmeeting/network/NetworkFactsTest.kt +0 -59
  75. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedLocalNetworkPermissionTest.kt +0 -35
  76. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetectorTest.kt +0 -165
  77. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkErrorTest.kt +0 -96
  78. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkLifecycleTest.kt +0 -187
  79. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkObserverTest.kt +0 -172
  80. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkReadinessTest.kt +0 -141
  81. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpecTest.kt +0 -69
  82. package/android/src/test/java/com/mentra/acsmeeting/source/BitmapFontTest.kt +0 -22
  83. package/android/src/test/java/com/mentra/acsmeeting/source/DecodedTrackRelayTest.kt +0 -41
  84. package/android/src/test/java/com/mentra/acsmeeting/source/FirstFrameGateTest.kt +0 -60
  85. package/android/src/test/java/com/mentra/acsmeeting/source/GlassesMediaSourceTest.kt +0 -151
  86. package/android/src/test/java/com/mentra/acsmeeting/source/I420PlanesTest.kt +0 -67
  87. package/android/src/test/java/com/mentra/acsmeeting/source/SelectedIcePairTest.kt +0 -165
  88. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIceDiagnosisTest.kt +0 -148
  89. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIcePolicyTest.kt +0 -76
  90. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApSdpGuardTest.kt +0 -275
  91. package/android/src/test/java/com/mentra/acsmeeting/source/SyntheticFrameFactoryTest.kt +0 -193
  92. package/android/src/test/java/com/mentra/acsmeeting/source/SyntheticI420SourceTest.kt +0 -176
  93. package/android/src/test/java/com/mentra/acsmeeting/source/TrackRegistryTest.kt +0 -45
  94. package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestProtocolTest.kt +0 -356
  95. package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestServerLoopbackTest.kt +0 -411
  96. package/android/src/test/java/com/mentra/acsmeeting/telemetry/AvSyncProbeTest.kt +0 -83
  97. package/android/src/test/java/com/mentra/acsmeeting/telemetry/ChromaProbeTest.kt +0 -61
  98. package/android/src/test/java/com/mentra/acsmeeting/telemetry/PipelineStatsTest.kt +0 -322
  99. package/android/src/test/java/com/mentra/acsmeeting/telemetry/ProcessCpuTest.kt +0 -18
  100. package/android/src/test/java/com/mentra/acsmeeting/telemetry/RingPercentileTest.kt +0 -49
  101. package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoRateVerdictTest.kt +0 -149
  102. package/android/src/test/java/com/mentra/acsmeeting/trace/SoftApTraceTest.kt +0 -97
  103. package/android/src/test/java/com/mentra/acsmeeting/video/FrameGeometryTest.kt +0 -33
  104. package/android/src/test/java/com/mentra/acsmeeting/video/I420PackerTest.kt +0 -126
  105. package/ios/PolicyKit/Sources/AcsAudioPolicy/GlassesMediaSource.swift +0 -68
  106. package/ios/WhepVideoSource.swift +0 -356
@@ -1,57 +0,0 @@
1
- package com.mentra.acsmeeting.source
2
-
3
- import com.mentra.acsmeeting.video.I420Packer
4
- import java.nio.ByteBuffer
5
-
6
- /** Replaceable later by LocalGlassesVideoSource (SoftAP / local WebRTC). */
7
- interface VideoSource {
8
- fun start(whepUrl: String)
9
- fun updateUrl(whepUrl: String)
10
- fun stop()
11
- }
12
-
13
- /**
14
- * Planes are valid only for the duration of the callback unless [retain] is
15
- * non-null and invoked before the callback returns. [release] must then be
16
- * called exactly once after ACS is done with the buffers.
17
- */
18
- class I420Planes(
19
- val y: ByteBuffer,
20
- val strideY: Int,
21
- val u: ByteBuffer,
22
- val strideU: Int,
23
- val v: ByteBuffer,
24
- val strideV: Int,
25
- val width: Int,
26
- val height: Int,
27
- val timestampNs: Long,
28
- val retain: (() -> Unit)? = null,
29
- val release: (() -> Unit)? = null,
30
- ) {
31
- fun isTight(): Boolean {
32
- val chroma = I420Packer.chromaStride(width)
33
- return strideY == width && strideU == chroma && strideV == chroma
34
- }
35
-
36
- fun isDirect(): Boolean = y.isDirect && u.isDirect && v.isDirect
37
-
38
- fun planesReadable(): Boolean {
39
- val chromaW = I420Packer.chromaStride(width)
40
- val chromaH = I420Packer.chromaStride(height)
41
- return remaining(y) >= I420Packer.planeMinBytes(strideY, width, height) &&
42
- remaining(u) >= I420Packer.planeMinBytes(strideU, chromaW, chromaH) &&
43
- remaining(v) >= I420Packer.planeMinBytes(strideV, chromaW, chromaH)
44
- }
45
-
46
- companion object {
47
- fun remaining(buffer: ByteBuffer): Int = buffer.duplicate().remaining()
48
- }
49
- }
50
-
51
- fun interface VideoFrameListener {
52
- fun onVideoFrame(planes: I420Planes)
53
- }
54
-
55
- fun interface PcmListener {
56
- fun onPcm(pcm16Le: ByteArray, sampleRate: Int, channels: Int)
57
- }
@@ -1,17 +0,0 @@
1
- package com.mentra.acsmeeting.source
2
-
3
- import org.webrtc.SdpObserver
4
- import org.webrtc.SessionDescription
5
-
6
- /**
7
- * No-op [SdpObserver] so call sites override only the one callback they care about.
8
- *
9
- * Shared by the Cloudflare and SoftAP paths, which negotiate in opposite directions and therefore
10
- * each ignore a different half of this interface.
11
- */
12
- internal open class SdpAdapter : SdpObserver {
13
- override fun onCreateSuccess(sdp: SessionDescription) {}
14
- override fun onSetSuccess() {}
15
- override fun onCreateFailure(error: String) {}
16
- override fun onSetFailure(error: String) {}
17
- }
@@ -1,139 +0,0 @@
1
- package com.mentra.acsmeeting.source
2
-
3
- import com.mentra.acsmeeting.network.Ipv4Prefix
4
-
5
- /**
6
- * Reads the path media is *actually* taking out of a WebRTC stats sample.
7
- *
8
- * The SDP guard checks what each side offered. That is necessary and not sufficient: an answer can
9
- * carry a perfectly good hotspot candidate and ICE can still select a different pair, at which point
10
- * the SoftAP call is running over an interface that either cannot reach the glasses or defeats the
11
- * whole point of the transport. The only honest question is which pair got nominated.
12
- *
13
- * ## Why the selection is followed rather than inferred
14
- *
15
- * This used to pick "a nominated succeeded pair, else any succeeded pair, else whichever pair has
16
- * the most received bytes". None of those identify the current path. A succeeded pair is only a pair
17
- * that once passed a connectivity check, and historical bytes belong to a pair that may have been
18
- * replaced. Using it as acceptance evidence meant a run could be graded on a pair that was not
19
- * carrying the call.
20
- *
21
- * The transport's `selectedCandidatePairId` is the specification's answer to exactly this question,
22
- * so it is read and followed. When it is not available the verdict is [IcePathVerdict.Unknown] —
23
- * insufficient evidence, never a pass.
24
- *
25
- * Kept free of `org.webrtc` types so the decision is unit testable; [LocalWhipIngestSource] adapts
26
- * `RTCStatsReport` into these shapes.
27
- */
28
- data class IceCandidatePairStats(
29
- val id: String,
30
- /** `succeeded`, `in-progress`, `failed`, … as libwebrtc spells it. */
31
- val state: String?,
32
- val nominated: Boolean,
33
- val localCandidateId: String?,
34
- val remoteCandidateId: String?,
35
- val bytesReceived: Long,
36
- )
37
-
38
- data class IceCandidateStats(
39
- val id: String,
40
- val address: String?,
41
- val candidateType: String?,
42
- )
43
-
44
- /** The transport entry, for the one field that names the pair in use. */
45
- data class IceTransportStats(val id: String, val selectedCandidatePairId: String?)
46
-
47
- sealed interface IcePathVerdict {
48
- /**
49
- * Media is on the network we joined.
50
- *
51
- * [pairId] and [iceGeneration] are carried so two samples can be compared as the same path. Byte
52
- * growth across a pair change says nothing, and comparing it anyway is how an idle path could
53
- * read as a flowing one.
54
- */
55
- data class OnHotspot(
56
- val pairId: String,
57
- val iceGeneration: Int,
58
- val local: String,
59
- val remote: String?,
60
- val bytesReceived: Long,
61
- ) : IcePathVerdict
62
-
63
- /** A pair was selected and it is not on the hotspot. This is the silent failure worth killing. */
64
- data class OffHotspot(val pairId: String, val local: String?, val prefix: String) : IcePathVerdict
65
-
66
- /** Not enough information to judge. Never a pass, and never on its own a reason to fail a call. */
67
- data class Unknown(val reason: String) : IcePathVerdict
68
- }
69
-
70
- object SelectedIcePair {
71
-
72
- /**
73
- * The pair the transport says is selected, or null with the reason it could not be identified.
74
- *
75
- * Deliberately no fallback. Every fallback that was here answered a different question than
76
- * "which pair is carrying this call".
77
- */
78
- fun selected(
79
- transports: List<IceTransportStats>,
80
- pairs: List<IceCandidatePairStats>,
81
- ): Result<IceCandidatePairStats> {
82
- if (transports.isEmpty()) return Result.failure(Missing("no_transport_stats"))
83
- val selectedId =
84
- transports.firstNotNullOfOrNull { it.selectedCandidatePairId }
85
- ?: return Result.failure(Missing("no_selected_pair"))
86
- val pair =
87
- pairs.firstOrNull { it.id == selectedId }
88
- ?: return Result.failure(Missing("selected_pair_absent_from_report"))
89
- return Result.success(pair)
90
- }
91
-
92
- /** Carries the reason a selection could not be identified, for the trace. */
93
- class Missing(val reason: String) : Exception(reason)
94
-
95
- fun verdict(
96
- transports: List<IceTransportStats>,
97
- pairs: List<IceCandidatePairStats>,
98
- candidates: Map<String, IceCandidateStats>,
99
- prefix: Ipv4Prefix?,
100
- iceGeneration: Int,
101
- ): IcePathVerdict {
102
- val pair =
103
- selected(transports, pairs).getOrElse { error ->
104
- return IcePathVerdict.Unknown((error as? Missing)?.reason ?: "selection_failed")
105
- }
106
- // No prefix means the host could not describe the network it joined. Reporting that as
107
- // "off hotspot" would fail healthy calls on any host whose LinkProperties read came back empty.
108
- if (prefix == null) return IcePathVerdict.Unknown("no_scoped_prefix")
109
- val local = pair.localCandidateId?.let { candidates[it] }?.address
110
- ?: return IcePathVerdict.Unknown("no_local_candidate")
111
- val remote = pair.remoteCandidateId?.let { candidates[it] }?.address
112
- if (!prefix.contains(local)) return IcePathVerdict.OffHotspot(pair.id, local, prefix.toString())
113
- return IcePathVerdict.OnHotspot(pair.id, iceGeneration, local, remote, pair.bytesReceived)
114
- }
115
-
116
- /** Outcome of comparing two samples. Only [Flow.Compared] says anything about media. */
117
- sealed interface Flow {
118
- data class Compared(val before: Long, val after: Long) : Flow {
119
- val flowing: Boolean
120
- get() = after > before
121
- }
122
-
123
- /** The samples are not the same path, so growth between them is not evidence either way. */
124
- data class NotComparable(val reason: String) : Flow
125
- }
126
-
127
- /**
128
- * Compare byte counters only when both samples are the same pair on the same ICE generation.
129
- *
130
- * The previous check compared two numbers with no pair identity at all, which a renomination or a
131
- * session restart silently invalidated.
132
- */
133
- fun flow(first: IcePathVerdict, second: IcePathVerdict.OnHotspot): Flow {
134
- val before = first as? IcePathVerdict.OnHotspot ?: return Flow.NotComparable("first_sample_unusable")
135
- if (before.iceGeneration != second.iceGeneration) return Flow.NotComparable("ice_generation_changed")
136
- if (before.pairId != second.pairId) return Flow.NotComparable("pair_changed")
137
- return Flow.Compared(before.bytesReceived, second.bytesReceived)
138
- }
139
- }
@@ -1,144 +0,0 @@
1
- package com.mentra.acsmeeting.source
2
-
3
- import com.mentra.acsmeeting.network.Ipv4Prefix
4
- import com.mentra.acsmeeting.network.ScopedNetworkChangeDetector.PublishedEntry
5
-
6
- /**
7
- * Separates the causes of "the answer carried no hotspot candidate".
8
- *
9
- * That single symptom has been produced on device by four different faults, and a coarse error code
10
- * cannot tell them apart — which is how one of them was misattributed and "fixed" by a change that
11
- * created the next. Each fault is distinguished by state that only exists at the moment of
12
- * gathering, so it is captured there and reduced to a [Fault] rather than inferred from logs later:
13
- *
14
- * - the hotspot address is not in the kernel's interface table, so nothing could have gathered it
15
- * - the address is there but the inventory libwebrtc holds does not contain the hotspot, or two
16
- * entries share a handle so one overwrote the other
17
- * - both are fine and still nothing was gathered, which leaves the socket bind
18
- * - candidates were gathered, none on the hotspot, so the wrong address is being advertised
19
- *
20
- * The [Fault] is a hypothesis narrowed by observation, not a proof; the raw fields travel with it so
21
- * a run can be re-read without trusting the classifier.
22
- */
23
- object SoftApIceDiagnosis {
24
-
25
- /** The narrowed cause. Ordered from "cannot possibly work" to "worked, wrong answer". */
26
- enum class Fault {
27
- /** The scoped address is absent from `getifaddrs`. ICE has nothing to gather. */
28
- MISSING_INTERFACE,
29
-
30
- /** The hotspot is not in libwebrtc's inventory, or a handle collision dropped it. */
31
- ERASED_ENTRY,
32
-
33
- /** Address present, entry published, zero candidates: the socket could not bind. */
34
- FAILED_BINDING,
35
-
36
- /** Candidates gathered, none on the hotspot: a truthful port with the wrong address. */
37
- WRONG_ADDRESS,
38
-
39
- /** The observations do not narrow to one cause. */
40
- INDETERMINATE,
41
- }
42
-
43
- /** A gathered candidate reduced to the two things that matter: its address and whose it is. */
44
- data class CandidateFact(val address: String?, val owner: String?, val onHotspot: Boolean) {
45
- override fun toString(): String = "${address ?: "?"}@${owner ?: "ABSENT"}"
46
- }
47
-
48
- data class Diagnosis(
49
- val code: String,
50
- val fault: Fault,
51
- val scopedAddress: String?,
52
- val scopedOwner: String?,
53
- val scopedPrefix: Ipv4Prefix?,
54
- val published: List<PublishedEntry>,
55
- val candidates: List<CandidateFact>,
56
- ) {
57
- val scopedInTable: Boolean
58
- get() = scopedOwner != null
59
-
60
- val hotspotCandidates: List<CandidateFact>
61
- get() = candidates.filter { it.onHotspot }
62
-
63
- val offHotspot: List<CandidateFact>
64
- get() = candidates.filterNot { it.onHotspot }
65
-
66
- /**
67
- * Flattened for [SoftApTrace][com.mentra.acsmeeting.trace.SoftApTrace] and the analyzer.
68
- * Field names are the contract `softap-call-proof.mjs` reads, so they are stable.
69
- */
70
- fun fields(): Array<Pair<String, Any?>> =
71
- arrayOf(
72
- "code" to code,
73
- "fault" to fault.name,
74
- "scopedAddress" to (scopedAddress ?: "none"),
75
- "scopedOwner" to (scopedOwner ?: "ABSENT"),
76
- "scopedInTable" to scopedInTable,
77
- "scopedPrefix" to (scopedPrefix?.toString() ?: "unknown"),
78
- "publishedInventory" to
79
- published.joinToString(",").ifEmpty { "none" },
80
- "publishedHotspot" to publishesHotspot(),
81
- "handleCollision" to hasHandleCollision(),
82
- "gathered" to candidates.size,
83
- "hotspotCandidates" to hotspotCandidates.size,
84
- "offHotspot" to offHotspot.joinToString(",").ifEmpty { "none" },
85
- )
86
-
87
- internal fun publishesHotspot(): Boolean =
88
- scopedAddress != null && published.any { scopedAddress in it.addresses }
89
-
90
- internal fun hasHandleCollision(): Boolean {
91
- val handles = published.map { it.handle }
92
- return handles.size != handles.distinct().size
93
- }
94
- }
95
-
96
- /** Coarse label: nothing was gathered at all. */
97
- const val CODE_NO_CANDIDATES = "no_candidates_gathered"
98
-
99
- /** Coarse label: candidates exist, but every one is off the hotspot. */
100
- const val CODE_ONLY_NON_HOTSPOT = "only_non_hotspot_candidates"
101
-
102
- /**
103
- * Classify a failed gathering pass.
104
- *
105
- * [scopedOwner] is the interface the kernel says owns [scopedAddress], normally from
106
- * `NetworkFacts.ownerOf`; null means the address is not in the table.
107
- */
108
- fun diagnose(
109
- scopedAddress: String?,
110
- scopedOwner: String?,
111
- scopedPrefix: Ipv4Prefix?,
112
- published: List<PublishedEntry>,
113
- candidates: List<CandidateFact>,
114
- ): Diagnosis {
115
- val code =
116
- if (candidates.isEmpty()) CODE_NO_CANDIDATES else CODE_ONLY_NON_HOTSPOT
117
- val partial =
118
- Diagnosis(
119
- code,
120
- Fault.INDETERMINATE,
121
- scopedAddress,
122
- scopedOwner,
123
- scopedPrefix,
124
- published,
125
- candidates,
126
- )
127
- return partial.copy(fault = classify(partial))
128
- }
129
-
130
- /**
131
- * Order is load-bearing. A missing interface also looks like a failed bind, and an erased entry
132
- * also looks like one, so the checks that rule those out have to come first — otherwise every
133
- * run is blamed on the socket and the actual cause is never seen.
134
- */
135
- private fun classify(diagnosis: Diagnosis): Fault =
136
- when {
137
- diagnosis.scopedAddress == null -> Fault.MISSING_INTERFACE
138
- !diagnosis.scopedInTable -> Fault.MISSING_INTERFACE
139
- !diagnosis.publishesHotspot() || diagnosis.hasHandleCollision() -> Fault.ERASED_ENTRY
140
- diagnosis.candidates.isEmpty() -> Fault.FAILED_BINDING
141
- diagnosis.hotspotCandidates.isEmpty() -> Fault.WRONG_ADDRESS
142
- else -> Fault.INDETERMINATE
143
- }
144
- }
@@ -1,46 +0,0 @@
1
- package com.mentra.acsmeeting.source
2
-
3
- import org.webrtc.PeerConnectionFactory
4
-
5
- /**
6
- * Keeps the SoftAP peer's ICE on the hotspot.
7
- *
8
- * Answering with a cellular candidate is the quiet failure mode: the glasses have no route to the
9
- * phone's mobile address, so ICE either fails after a full timeout or, worse, connects over some
10
- * other shared network and the "direct" link is a fiction. Rather than hope ICE prefers the right
11
- * interface, this removes the wrong ones from consideration entirely.
12
- *
13
- * [networkIgnoreMask] is a real libwebrtc knob — `PeerConnectionFactory.Options.networkIgnoreMask`
14
- * — unlike the socket factory the design originally assumed existed. Combined with an empty ICE
15
- * server list (so no srflx or relay candidate can be gathered at all) it leaves exactly one kind of
16
- * candidate available: a Wi-Fi host candidate, which on a phone joined to the glasses hotspot is
17
- * the hotspot address.
18
- *
19
- * Pure integer arithmetic, so it is unit tested rather than assumed.
20
- */
21
- object SoftApIcePolicy {
22
-
23
- /**
24
- * Adapters the SoftAP peer must not gather on.
25
- *
26
- * Cellular is the dangerous one and the reason this exists. VPN is excluded because a corporate
27
- * VPN on the phone would otherwise offer a tunnel address the glasses cannot reach. Loopback is
28
- * excluded because it is never reachable from another device and only adds candidates to sift
29
- * through. Ethernet is left available: a USB-tethered debug setup is legitimate, and it is not a
30
- * route that can silently substitute for the hotspot.
31
- */
32
- fun networkIgnoreMask(): Int =
33
- PeerConnectionFactory.Options.ADAPTER_TYPE_CELLULAR or
34
- PeerConnectionFactory.Options.ADAPTER_TYPE_VPN or
35
- PeerConnectionFactory.Options.ADAPTER_TYPE_LOOPBACK or
36
- PeerConnectionFactory.Options.ADAPTER_TYPE_ETHERNET or
37
- PeerConnectionFactory.Options.ADAPTER_TYPE_ANY
38
-
39
- /** Whether [adapterType] is gathered on, given [mask]. Mirrors libwebrtc's own bitwise test. */
40
- fun allowsAdapter(mask: Int, adapterType: Int): Boolean = (mask and adapterType) == 0
41
-
42
- /** Factory options for the SoftAP peer. The network monitor stays on; we need it to see Wi-Fi. */
43
- fun factoryOptions(): PeerConnectionFactory.Options = PeerConnectionFactory.Options().apply {
44
- networkIgnoreMask = networkIgnoreMask()
45
- }
46
- }
@@ -1,160 +0,0 @@
1
- package com.mentra.acsmeeting.source
2
-
3
- import com.mentra.acsmeeting.network.Ipv4Prefix
4
-
5
- /**
6
- * Checks that a SoftAP negotiation is actually staying on the SoftAP.
7
- *
8
- * The failure this exists to catch is silent. If the phone answers with a candidate on its cellular
9
- * or home-Wi-Fi interface, or the glasses offer one, ICE may still connect — over a path that
10
- * defeats the entire point of SoftAP and, in the cellular case, cannot work at all because the
11
- * glasses have no route to it. Media either never flows or flows the long way while the logs look
12
- * healthy. So both the offer we receive and the answer we send are asserted against the hotspot
13
- * subnet before either is used.
14
- *
15
- * When the host can describe the network it joined, candidates are matched against that exact
16
- * prefix — the real invariant is "on the network we joined", and RFC1918 is only a stand-in for it.
17
- * Cellular carriers hand out 10/8 addresses, so the stand-in accepts precisely the interface this
18
- * gate exists to reject. Without a prefix (no scoped handle) the check falls back to RFC1918, which
19
- * deliberately matches `com.mentra.asg_client.io.streaming.config.IcePostPolicy` on the glasses. The
20
- * two cannot share code across the module boundary, so
21
- * [SoftApSdpGuardTest][com.mentra.acsmeeting.source.SoftApSdpGuardTest] pins the same cases the
22
- * glasses-side test pins.
23
- */
24
- object SoftApSdpGuard {
25
-
26
- sealed interface Verdict {
27
- /** At least one usable hotspot host candidate. [routableCandidates] is non-fatal, see below. */
28
- data class Ok(val hostCandidates: List<String>, val routableCandidates: List<String>) : Verdict
29
-
30
- data class Rejected(val code: String, val detail: String) : Verdict
31
- }
32
-
33
- /**
34
- * No `typ host` candidate on the hotspot subnet, split by whether anything was gathered at all.
35
- *
36
- * One code covered both for a long time and it cost real debugging time: "nothing gathered" and
37
- * "gathered the wrong address" are different faults with different fixes, and the single code
38
- * made a run that had regressed from one to the other look unchanged.
39
- * [SoftApIceDiagnosis] narrows them further; these two are the coarse label.
40
- */
41
- const val REASON_NO_CANDIDATES = SoftApIceDiagnosis.CODE_NO_CANDIDATES
42
-
43
- /** See [REASON_NO_CANDIDATES]. Candidates exist, none of them on the hotspot. */
44
- const val REASON_ONLY_NON_HOTSPOT = SoftApIceDiagnosis.CODE_ONLY_NON_HOTSPOT
45
-
46
- /** Not SDP, or SDP with no media section. */
47
- const val REASON_MALFORMED = "malformed_sdp"
48
-
49
- /**
50
- * Candidates that are not host candidates on a private subnet — srflx, relay, or a host candidate
51
- * on a public address. In host-only mode none should appear; if they do, ICE could select one, so
52
- * they are reported for logging. They are not rejected on the offer side: the glasses may run an
53
- * older build that still gathers them, and dropping the call would be worse than a warning when a
54
- * valid hotspot candidate is also present.
55
- */
56
- fun inspect(sdp: String?, prefix: Ipv4Prefix? = null): Verdict {
57
- if (sdp.isNullOrBlank()) return Verdict.Rejected(REASON_MALFORMED, "empty sdp")
58
- if (!sdp.contains("m=")) return Verdict.Rejected(REASON_MALFORMED, "no media section")
59
-
60
- val candidates = candidateLines(sdp)
61
- val host = candidates.filter { isSoftApHostCandidate(it, prefix) }
62
- val routable = candidates.filterNot { isSoftApHostCandidate(it, prefix) }
63
-
64
- if (host.isEmpty()) {
65
- val scope = if (prefix != null) " (hotspot is $prefix)" else ""
66
- return if (candidates.isEmpty()) {
67
- Verdict.Rejected(REASON_NO_CANDIDATES, "no candidates at all$scope")
68
- } else {
69
- Verdict.Rejected(
70
- REASON_ONLY_NON_HOTSPOT,
71
- "only non-hotspot candidates$scope: ${routable.joinToString("; ")}",
72
- )
73
- }
74
- }
75
- return Verdict.Ok(host, routable)
76
- }
77
-
78
- /** Every `a=candidate:` attribute, with the `a=` prefix stripped. */
79
- fun candidateLines(sdp: String): List<String> =
80
- sdp.lineSequence()
81
- .map { it.trim() }
82
- .filter { it.startsWith("a=candidate:") }
83
- .map { it.removePrefix("a=") }
84
- .toList()
85
-
86
- /**
87
- * A `typ host` candidate on the hotspot: inside [prefix] when we know it, RFC1918 otherwise.
88
- *
89
- * mDNS-obfuscated candidates (`<uuid>.local`) are rejected on purpose. libwebrtc emits them to
90
- * hide private addresses from remote peers, and the glasses cannot resolve them, so a negotiation
91
- * that only offers those is broken however healthy it looks. Seeing this fire means the answering
92
- * peer needs its mDNS obfuscation disabled.
93
- */
94
- fun isSoftApHostCandidate(candidate: String, prefix: Ipv4Prefix? = null): Boolean {
95
- if (!candidate.contains("typ host")) return false
96
- val onHotspot: (String) -> Boolean = if (prefix != null) prefix::contains else ::isPrivateIpv4
97
- return candidate.split(Regex("\\s+")).any(onHotspot)
98
- }
99
-
100
- /**
101
- * What [pinHostAddresses] did to one answer.
102
- *
103
- * [rewritten] is the host-candidate addresses that were off the hotspot and got replaced.
104
- * Empty means the gathered SDP already advertised the scoped IP, so signaling and native state
105
- * already match and nothing was edited.
106
- */
107
- data class PinResult(val sdp: String, val rewritten: List<String>)
108
-
109
- /**
110
- * Force every `typ host` candidate onto [scopedAddress].
111
- *
112
- * Needed because a bindable network handle marks the ICE sockets onto the SoftAP — which is what
113
- * actually delivers UDP — and then libwebrtc substitutes the phone's default-route address into
114
- * the candidate. The glasses have no route to that address. Replacing only the connection address
115
- * of host lines (field 5, not `raddr`) keeps srflx/relay untouched and leaves a candidate that
116
- * was already on the hotspot alone.
117
- *
118
- * The socket is still the marked one libwebrtc created. This only changes what the glasses are
119
- * told to send to, so it stays honest only when that socket is on [scopedAddress]'s network.
120
- */
121
- fun pinHostAddresses(sdp: String, scopedAddress: String, prefix: Ipv4Prefix): PinResult {
122
- val rewritten = mutableListOf<String>()
123
- val lines = sdp.split(Regex("(?<=\\r?\\n)", RegexOption.MULTILINE))
124
- val pinned =
125
- lines.joinToString("") { line ->
126
- val body = line.trimEnd('\r', '\n')
127
- if (!body.contains("candidate:") || !body.contains("typ host")) return@joinToString line
128
- val address = candidateAddress(body) ?: return@joinToString line
129
- if (prefix.contains(address) || address == scopedAddress) return@joinToString line
130
- rewritten += address
131
- line.replaceFirst(address, scopedAddress)
132
- }
133
- return PinResult(pinned, rewritten)
134
- }
135
-
136
- /**
137
- * The connection address of a candidate line, for attributing it to a real interface.
138
- *
139
- * Field 5 of `candidate:<foundation> <component> <transport> <priority> <address> <port> ...`,
140
- * per RFC 5245. Read positionally rather than by scanning for anything dotted-quad-shaped: a
141
- * `raddr` on an srflx line would otherwise win and point at the wrong interface.
142
- */
143
- fun candidateAddress(candidate: String): String? {
144
- val fields = candidate.trim().removePrefix("a=").split(Regex("\\s+"))
145
- return fields.getOrNull(4)?.takeIf { it.count { char -> char == '.' } == 3 }
146
- }
147
-
148
- /** RFC1918: 10/8, 172.16/12, 192.168/16. Rejects loopback, link-local and public addresses. */
149
- fun isPrivateIpv4(token: String): Boolean {
150
- val octets = token.split('.')
151
- if (octets.size != 4) return false
152
- val values = octets.map { it.toIntOrNull() ?: return false }
153
- if (values.any { it < 0 || it > 255 }) return false
154
- return when {
155
- values[0] == 10 -> true
156
- values[0] == 172 && values[1] in 16..31 -> true
157
- else -> values[0] == 192 && values[1] == 168
158
- }
159
- }
160
- }