oficina / meet Public

Say who can't hear you yet on the Mac and iPhone, and write §15.24

The apps report hearing (HearingRule: connected with packets in and
decoded frames at the tap, or connected and muted), and ConnectingWatch
says "<name> is connecting and can't hear you yet" on the tile, with
dots that take turns, past a one-second grace until their report lists
you, replaced by their blocked or no-sound report, with the fallback for
clients that do not say and a twenty-second cap. Reconnecting waits
until after. The mini window shows a small ear for anyone it is not
showing, and the tab a dot.

Snapshots for Mac (CallsInterop --connecting-snapshots) and iPhone
(testConnectingSnapshots); the native-browser gate holds the browser's
signalling and checks the notice both ways.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 010e518ca3936c3807f38f5a452292be1872fa7f parent 01a2925
15 files changed, +914 −25
  • docs/superpowers/specs/2026-09-07-meet-design.md +99 −0
  • native/README.md +9 −0
  • native/apple/Sources/CallsInterop/CallSnapshots.swift +46 −0
  • native/apple/Sources/CallsInterop/Interop.swift +6 −0
  • native/apple/Sources/CallsKit/AudioMeter.swift +63 −8
  • native/apple/Sources/CallsKit/AudioWork.swift +6 −0
  • native/apple/Sources/CallsKit/Call.swift +112 −8
  • native/apple/Sources/CallsKit/CallFixtures.swift +8 −0
  • native/apple/Sources/CallsKit/ConnectingWatch.swift +160 −0
  • native/apple/Sources/CallsKit/Media.swift +51 −7
  • native/apple/Sources/CallsKit/MiniWindow.swift +52 −1
  • native/apple/Sources/CallsKit/Wire.swift +6 −1
  • native/apple/Tests/CallsKitTests/ConnectingWatchTests.swift +231 −0
  • native/ios/UITests/CallsPhoneUITests.swift +24 −0
  • test/browser/native.mjs +41 −0
diff --git a/docs/superpowers/specs/2026-09-07-meet-design.md b/docs/superpowers/specs/2026-09-07-meet-design.md
index 243ec8e..97222f9 100644
--- a/docs/superpowers/specs/2026-09-07-meet-design.md
+++ b/docs/superpowers/specs/2026-09-07-meet-design.md
@@ -2566,3 +2566,102 @@ the apps, `RECONNECT_*` in room.js) with unit tests either side; the browser
gate's `renewal` scenario and the native gate's `resume` and `renewals` watch
for the words through several renewals and fail on any. The core's phase is
unchanged: controls that need an admitted call still follow it.
+
+### 15.24 §15.19, §15.20 — "… is connecting and can't hear you yet" (2026-10-01)
+
+**Why.** Paul, 2026-10-01, asking for what Zoom shows: "didemeski is
+connecting to audio and cannot hear you yet …". When someone joins, there is
+a gap — a few hundred milliseconds on a good network, seconds on a bad one,
+for ever on a broken one — before their side is receiving and playing your
+audio, and nothing said so: people greeted a newcomer who could not yet hear
+them. Nothing on the wire said whether anyone could hear anyone; §15.20 only
+carried bad news after five seconds of flat sound.
+
+**Decision.** A positive receive signal, and a notice built on it.
+
+- **`hearing`.** §15.19's `media` gains one more optional member: the peers
+ whose audio this client is actually receiving and playing. Unlike §15.20's
+ and §15.21's lists it is good news, and its presence is itself news: an
+ absent list is a client that does not say (one from before this section),
+ `[]` is a client that hears nobody yet. So it is sent whenever the server
+ takes it, `[]` included, and the hub keeps it with its emptiness — `[]`
+ travels as `[]`, and a change between absent and `[]` is a change.
+ Otherwise it is held as `noSoundFrom` is: at most fifteen distinct
+ well-formed peer ids, checked for shape and not presence, a `null`, a
+ non-array, a repeat or a sixteenth id refused 400; re-encoded last
+ (`muted`, `cameraOff`, `audioBlocked`, `noSoundFrom`, `screenStalled`,
+ `hearing`). The raw payload cap is 3 KB, room for all three lists full
+ (about 1,700 bytes) and whitespace. Sent only on a change, like the rest.
+- **The rule — who a client lists.** A peer is listed once the audio path
+ from them is established and is playing here:
+ - *established*: the connection is up (ICE connected or completed) and
+ either their audio is arriving — inbound packets received and decoded
+ samples coming out (`totalSamplesReceived` in the browser; frames at the
+ receive tap in the apps) on a live receiver — or **they say they are
+ muted**, in which case the connection being up and the receiver live
+ (or no audio receiver at all, for someone with no microphone) is enough.
+ A muted sender may send only silence, or nothing, and must not look
+ unheard: the moment they unmute, it plays. Once established it stays so
+ until the connection fails or closes; a blip is not losing them.
+ - *playing*: in the browser, the tile's element is playing and was not
+ refused by an autoplay policy (a refused tile is §15.20's
+ `audioBlocked`, and is not listed until a click plays it). The apps play
+ whatever they decode; there is no policy to refuse them.
+- **The notice.** From the moment a person appears in the roster — or
+ appears again, or is given a new connection (a new pair generation) —
+ their tile says "Samuel is connecting and can't hear you yet", with three
+ dots after it that brighten in turn (one steady ellipsis under reduced
+ motion). It goes the moment their report lists you in `hearing`, and a
+ screen reader is told "Samuel can hear you now". In order:
+ - **The grace.** It is not said at all if they hear you within 1 s of
+ appearing (`CONNECTING_GRACE`, `ConnectingWatch.grace`), so ordinary
+ joins say nothing. The browser gate measured 284 ms from appearing to
+ being heard on a loopback.
+ - **More specific news replaces it**: their `audioBlocked` ("Samuel's
+ browser is blocking sound — they need to click to hear you"), or you in
+ their `noSoundFrom` while you are unmuted ("Samuel isn't getting any
+ sound from you"). Those are their own reports and show even inside the
+ grace.
+ - **"Reconnecting to Samuel…" waits until after** (§15.23): a connection
+ that has never been up is not reconnecting. While someone is in their
+ first moments the reconnect watch is told nothing is wrong with them,
+ so a slow join of two people is never "Reconnecting…" for the call.
+ - **A client that does not say** (no `hearing` in its report — a client
+ from before this section, or any client on a server that refused the
+ key) is let go once the connection to it is up and either its receiver
+ reports show your audio reaching it (`remote-inbound-rtp` for your
+ microphone, which exists only once one has come back, with packets
+ sent) or it has made a media report without the list. A client that
+ does say is believed over this evidence.
+ - **The cap.** Whatever happens, it is given up 20 s after they appeared
+ (`CONNECTING_CAP`, `ConnectingWatch.cap`), silently; §15.20's "isn't
+ getting any sound" and §15.23's "Reconnecting to …" speak for any
+ problem after that.
+ - **Your own mute does not matter**: it is about their being able to
+ hear you once you speak.
+- **Where.** On the person's tile, in the place and the glass of the other
+ tile notices, with an ear where they have a warning triangle: every
+ layout on the Mac and the iPhone, and the browser's call window. In the
+ browser and in the apps' Grid, Speaker and Classic every person in the
+ roster has a tile from the roster on, so there is no call-level form;
+ the Mac's mini window, which shows one person, puts a small ear on its
+ glass for anyone else still connecting (their names in its tooltip and
+ for VoiceOver), and its tab shows the same ear. Screen readers hear the
+ sentence once when it appears and "… can hear you now" when it goes,
+ politely, and never the dots.
+
+**Compatibility, both orders.** Old clients ignore the member (the browser's
+`mediaFrom`, the core's decoder and the apps' `PeerMedia` all drop unknown
+keys). A server from before this section refuses a report carrying it
+(400): the browser then drops the extras for the rest of the page (§15.20)
+— though the browser's page is served by the same server, so only a page
+open across a deploy meets that — and the apps' core resends the same
+report without `hearing` and leaves it out from then on, then without
+`screenStalled` too if that is refused next, so a mute is never lost. The
+people on new clients then fall back to their own evidence about that
+client, as they do for an old one. So the server and the clients may ship
+in either order.
+
+**Not logged, not stored.** As §15.19. The apps log "… can hear us" in the
+connection log (`os_log` category `connection`), the browser on its
+console, each with the first six characters of the peer id only.
diff --git a/native/README.md b/native/README.md
index bfefb36..2dc0fb9 100644
--- a/native/README.md
+++ b/native/README.md
@@ -135,6 +135,15 @@ because nothing on it measured sound. Now:
isn't getting any sound from you". Each line is in the tile's accessibility
label and announced when it appears. The app itself is never blocked from
playing sound, so it never reports `audioBlocked`.
+- **Who can hear you yet** (spec §15.24): the app reports `hearing`, the people
+ whose audio path is established here (connected, packets in and decoded
+ frames at the tap, or connected and muted), read five times a second until
+ it is. From the moment someone joins, their tile says "Samuel is connecting
+ and can't hear you yet" — past a one-second grace, with dots that take turns
+ (steady under Reduce Motion) — until their report lists you, their own
+ blocked or no-sound report replacing it, and never past twenty seconds
+ (`ConnectingWatch.swift`). The mini window and its tab show a small ear for
+ anyone it is not showing.
The rules (`AudioLevelRules.swift`) are held by `AudioLevelTests` on an
injected clock; `TestNativeMacCallsBrowser` checks that the browser measures
diff --git a/native/apple/Sources/CallsInterop/CallSnapshots.swift b/native/apple/Sources/CallsInterop/CallSnapshots.swift
index adfe63b..192da92 100644
--- a/native/apple/Sources/CallsInterop/CallSnapshots.swift
+++ b/native/apple/Sources/CallsInterop/CallSnapshots.swift
@@ -60,6 +60,52 @@ import SwiftUI
}
}
+ /// Spec §15.24: two people who have just joined — Samuel still
+ /// connecting and unable to hear you yet, Bea's browser blocking sound in
+ /// place of that — in Grid, Speaker and Classic, light and dark, over a
+ /// busy picture, and the mini window and its tab with the small ear for
+ /// someone it is not showing.
+ static func saveConnecting(to folder: URL) async throws {
+ NSApplication.shared.setActivationPolicy(.prohibited)
+ try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
+ let size = CGSize(width: 1000, height: 640)
+ for scheme in [ColorScheme.light, .dark] {
+ let suffix = LobbySnapshots.name(scheme)
+ func shot(
+ _ name: String, grid: Bool = false, style: CallLayoutStyle = .fullWindow,
+ backdrop: FixtureBackdrop? = nil
+ ) async throws {
+ let call = Call(playAudio: false)
+ CallFixture.connecting.apply(to: call)
+ try await LobbySnapshots.render(
+ MacCallView(
+ call: call, grid: grid, showingChat: false, showingDevices: false, style: style,
+ controlsShown: true, leave: {}, rejoin: {}
+ ).tint(CallsAppearance.accent).environment(\.fixtureBackdrop, backdrop),
+ scheme: scheme, size: size,
+ to: folder.appendingPathComponent("mac-connecting-\(name)-\(suffix).png"))
+ }
+ try await shot("grid", grid: true)
+ try await shot("speaker")
+ try await shot("classic-grid", grid: true, style: .classic)
+ try await shot("busy-grid", grid: true, backdrop: .busy)
+ for hovering in [false, true] {
+ let call = Call(playAudio: false)
+ CallFixture.connecting.apply(to: call)
+ let width = MiniWindowRules.size.width
+ try await LobbySnapshots.render(
+ MiniWindowSnapshot.window(call: call, hovering: hovering), scheme: scheme,
+ size: CGSize(width: width, height: width / MiniWindowRules.aspect),
+ to: folder.appendingPathComponent("mac-connecting-mini\(hovering ? "-hover" : "")-\(suffix).png"))
+ }
+ let call = Call(playAudio: false)
+ CallFixture.connecting.apply(to: call)
+ try await LobbySnapshots.render(
+ MiniWindowSnapshot.tab(call: call), scheme: scheme, size: MiniWindowTab.size,
+ to: folder.appendingPathComponent("mac-connecting-tab-\(suffix).png"))
+ }
+ }
+
/// The glass and the glow as the window server draws them (0.3.7). An
/// off-screen bitmap has no Liquid Glass and no backdrop to blur, so
/// these are real windows, parked below the desktop where nobody sees
diff --git a/native/apple/Sources/CallsInterop/Interop.swift b/native/apple/Sources/CallsInterop/Interop.swift
index 21ef045..af6e902 100644
--- a/native/apple/Sources/CallsInterop/Interop.swift
+++ b/native/apple/Sources/CallsInterop/Interop.swift
@@ -34,6 +34,12 @@ struct Input: Decodable {
} catch { exit(1) }
return
}
+ if CommandLine.arguments.count == 3, CommandLine.arguments[1] == "--connecting-snapshots" {
+ do {
+ try await CallSnapshots.saveConnecting(to: URL(fileURLWithPath: CommandLine.arguments[2]))
+ } catch { exit(1) }
+ return
+ }
if CommandLine.arguments.count == 3, CommandLine.arguments[1] == "--glass-snapshots" {
do {
try await CallSnapshots.saveGlass(to: URL(fileURLWithPath: CommandLine.arguments[2]))
diff --git a/native/apple/Sources/CallsKit/AudioMeter.swift b/native/apple/Sources/CallsKit/AudioMeter.swift
index ccf985d..a8312ba 100644
--- a/native/apple/Sources/CallsKit/AudioMeter.swift
+++ b/native/apple/Sources/CallsKit/AudioMeter.swift
@@ -46,15 +46,19 @@ import SwiftUI
@Published public private(set) var speaking = false
/// Their report about you: blocked, or not hearing you.
@Published public private(set) var notices: [String] = []
+ /// "<name> is connecting and can't hear you yet" (spec §15.24), drawn
+ /// with a moving ellipsis; never beside `notices`.
+ @Published public private(set) var connecting: String?
/// The glow behind their name.
public let glow = GlowLevel()
public init() {}
- func show(state: PeerAudioState, speaking: Bool, notices: [String]) {
+ func show(state: PeerAudioState, speaking: Bool, notices: [String], connecting: String? = nil) {
if self.state != state { self.state = state }
if self.speaking != speaking { self.speaking = speaking }
if self.notices != notices { self.notices = notices }
+ if self.connecting != connecting { self.connecting = connecting }
}
}
@@ -124,7 +128,8 @@ import SwiftUI
/// Returns the state the rule arrived at.
@discardableResult
func updatePeer(
- _ id: String, name: String, sample: PeerAudioSample, notices: [String], now: Double
+ _ id: String, name: String, sample: PeerAudioSample, notices: [String], connecting: String? = nil,
+ now: Double
) -> PeerAudioState {
var watch = watches[id] ?? PeerAudioWatch()
let before = watch.state
@@ -132,8 +137,9 @@ import SwiftUI
watches[id] = watch
lastLevel[id] = sample.level
let model = peer(id)
- let fresh = notices.filter { !model.notices.contains($0) }
- model.show(state: state, speaking: watch.speaking, notices: notices)
+ var fresh = notices.filter { !model.notices.contains($0) }
+ if let connecting, model.connecting != connecting { fresh.append(connecting) }
+ model.show(state: state, speaking: watch.speaking, notices: notices, connecting: connecting)
model.glow.update(level: sample.level, speaking: watch.speaking, now: now)
if state == .noSound, before != .noSound { AudioAnnouncer.say(AudioWords.noSound(from: name)) }
for line in fresh { AudioAnnouncer.say(line) }
@@ -224,6 +230,9 @@ public struct TileAudioInfo: Equatable {
/// Lines above the name: their notices about you, or your microphone
/// warning.
public var notices: [String]
+ /// "<name> is connecting and can't hear you yet" (spec §15.24), with its
+ /// moving ellipsis; nil when not said.
+ public var connecting: String? = nil
/// The ⓘ's text, when a line has one.
public var detail: String?
@@ -232,6 +241,7 @@ public struct TileAudioInfo: Equatable {
var out: [String] = []
if case .noSound(let name) = status { out.append(AudioWords.noSound(from: name)) }
out.append(contentsOf: notices)
+ if let connecting { out.append(connecting) }
return out
}
@@ -251,7 +261,7 @@ public struct TileAudioInfo: Equatable {
case .meter: status = .live
}
return TileAudioInfo(
- status: status, speaking: audio.speaking, notices: audio.notices,
+ status: status, speaking: audio.speaking, notices: audio.notices, connecting: audio.connecting,
detail: audio.state == .noSound ? AudioWords.noSoundDetail : nil)
}
}
@@ -338,15 +348,55 @@ public struct TileAudioNotices: View {
let info: TileAudioInfo?
public init(_ info: TileAudioInfo?) { self.info = info }
public var body: some View {
- if let info, !info.notices.isEmpty {
+ if let info, !info.notices.isEmpty || info.connecting != nil {
VStack(alignment: .leading, spacing: 4) {
ForEach(info.notices, id: \.self) { line in
AudioLine(
text: line, systemImage: "exclamationmark.triangle.fill",
detail: line == AudioWords.micSilent ? info.detail : nil)
}
+ if let connecting = info.connecting {
+ AudioLine(text: connecting, systemImage: "ear", ellipsis: true)
+ .accessibilityIdentifier("connectingNotice")
+ }
+ }
+ }
+ }
+}
+
+/// "<name> is connecting and can't hear you yet" and three dots after it
+/// (spec §15.24) that brighten in turn — waiting, not wrong — or one steady
+/// ellipsis under Reduce Motion. One run of text, so a long name wraps the
+/// dots with the words. VoiceOver hears the sentence, never the dots.
+public struct ConnectingText: View {
+ let words: String
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+ public init(_ words: String) { self.words = words }
+ public var body: some View {
+ Group {
+ if reduceMotion {
+ Text(words + "…")
+ } else {
+ TimelineView(.periodic(from: .now, by: 0.1)) { context in
+ let t = context.date.timeIntervalSinceReferenceDate
+ (0..<3).reduce(Text(words)) { line, dot in
+ line + Text(".").foregroundColor(.white.opacity(Self.opacity(at: t, dot: dot)))
+ }
+ }
}
}
+ .accessibilityLabel(words)
+ }
+
+ /// Each dot's brightness at `t`: dim, lit at its turn in a 1.4 s cycle,
+ /// a fifth of a second after the one before — the browser's
+ /// `calls-ellipsis` keyframes.
+ static func opacity(at t: Double, dot: Int) -> Double {
+ let cycle = 1.4
+ let phase = (t - Double(dot) * 0.2).truncatingRemainder(dividingBy: cycle)
+ let p = (phase < 0 ? phase + cycle : phase) / cycle
+ let lift = p <= 0.4 ? p / 0.4 : p <= 0.8 ? (0.8 - p) / 0.4 : 0
+ return 0.35 + 0.65 * lift
}
}
@@ -357,12 +407,17 @@ struct AudioLine: View {
let text: String
let systemImage: String
var detail: String?
+ /// The connecting notice's moving dots after the words (spec §15.24).
+ var ellipsis = false
@State private var showing = false
var body: some View {
HStack(spacing: 5) {
Image(systemName: systemImage)
- Text(text).lineLimit(2).multilineTextAlignment(.leading)
- .fixedSize(horizontal: false, vertical: true)
+ Group {
+ if ellipsis { ConnectingText(text) } else { Text(text) }
+ }
+ .lineLimit(2).multilineTextAlignment(.leading)
+ .fixedSize(horizontal: false, vertical: true)
if let detail {
Button {
showing.toggle()
diff --git a/native/apple/Sources/CallsKit/AudioWork.swift b/native/apple/Sources/CallsKit/AudioWork.swift
index ccf38ec..15e0a36 100644
--- a/native/apple/Sources/CallsKit/AudioWork.swift
+++ b/native/apple/Sources/CallsKit/AudioWork.swift
@@ -131,6 +131,12 @@ enum PeerCalls {
}
}
+ static func statistics(_ pc: RTCPeerConnection, for sender: RTCRtpSender) async -> RTCStatisticsReport {
+ await withCheckedContinuation { c in
+ AudioWork.queue.async { pc.statistics(for: sender) { c.resume(returning: $0) } }
+ }
+ }
+
/// Anything else, read or done on the audio queue.
static func read<T>(_ pc: RTCPeerConnection, _ body: @escaping (RTCPeerConnection) -> T) async -> T {
await AudioWork.run { body(pc) }
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift
index 56f4107..801044d 100644
--- a/native/apple/Sources/CallsKit/Call.swift
+++ b/native/apple/Sources/CallsKit/Call.swift
@@ -87,6 +87,17 @@ private final class Events: NSObject, BridgeListenerProtocol {
private var reconnectWatch = ReconnectWatch()
@Published public private(set) var reconnecting = false
@Published public private(set) var reconnectingPeers: Set<String> = []
+ /// "<name> is connecting and can't hear you yet" (spec §15.24): the
+ /// watch, and the people it is said for now — on their tiles, and as a
+ /// dot in the mini window when they are not the one it shows.
+ private var connectingWatch = ConnectingWatch()
+ @Published public private(set) var connectingPeers: Set<String> = []
+ /// This app's own half: whose audio path is established (HearingRule),
+ /// per connection generation; whose receiver reports have shown this
+ /// app's audio reaching them; and the list last reported as `hearing`.
+ private var heardFrom: [String: (epoch: Int64, heard: Bool)] = [:]
+ private var ackedBy: [String: Int64] = [:]
+ private var reportedHearing: [String] = []
/// The phase the notices are drawn for: the core's, except that its
/// `reconnecting` is only said once ReconnectWatch says so, and is said
/// for a second after it is over.
@@ -519,6 +530,7 @@ private final class Events: NSObject, BridgeListenerProtocol {
private func watchLevels() {
levelWatch?.cancel()
levels.reset()
+ resetConnecting()
reportedNoSound = []
audioBytes = [:]
audioArriving = [:]
@@ -544,11 +556,21 @@ private final class Events: NSObject, BridgeListenerProtocol {
screenReceived = [:]
screenReceivedMoving = [:]
levels.reset()
+ resetConnecting()
reportedNoSound = []
audioBytes = [:]
audioArriving = [:]
}
+ private func resetConnecting() {
+ connectingWatch = ConnectingWatch()
+ if !connectingPeers.isEmpty { connectingPeers = [] }
+ heardFrom = [:]
+ ackedBy = [:]
+ // The core starts every report with `hearing: []` (spec §15.24).
+ reportedHearing = []
+ }
+
private func sampleMic(now: Double) {
let denoiser = media.audio.denoiser
let sent = denoiser.level.take()
@@ -575,30 +597,79 @@ private final class Events: NSObject, BridgeListenerProtocol {
}
private func samplePeers(stats: Bool, now: Double) async {
- let readings = await media.peerAudio(stats: stats)
+ // §15.24: the people whose audio path is not established yet are read
+ // closely, five times a second, until it is; the people in their first
+ // moments who do not say who they hear are asked about this app's audio.
+ let mine = state?.peer ?? ""
+ let joining = connectingWatch.joining
+ var detail = Set<String>()
+ var acks = Set<String>()
+ for person in state?.peers ?? [] where person.peer != mine {
+ let epoch = person.gen ?? 0
+ if heardFrom[person.peer].map({ $0.epoch != epoch || !$0.heard }) ?? true { detail.insert(person.peer) }
+ if joining.contains(person.peer), HearingRule.hearsYou(person.media, me: mine) == nil,
+ ackedBy[person.peer] != epoch
+ {
+ acks.insert(person.peer)
+ }
+ }
+ let readings = await media.peerAudio(stats: stats, detail: detail, acks: acks)
guard levelWatch != nil, let state else { return }
let me = state.peer
var present = Set<String>()
var people: [String: ReconnectWatch.Peer] = [:]
+ var arrivals: [String: ConnectingWatch.Peer] = [:]
for person in state.peers where person.peer != me {
guard let reading = readings[person.peer] else { continue }
present.insert(person.peer)
- if let bytes = reading.bytes {
+ if stats, let bytes = reading.bytes {
audioArriving[person.peer] = bytes > (audioBytes[person.peer] ?? 0)
audioBytes[person.peer] = bytes
}
- people[person.peer] = ReconnectWatch.Peer(
- connectionBad: reading.connectionBad,
- audioMissing: audioArriving[person.peer] == false && person.media?.muted != true)
- var notices = AudioWords.notices(name: person.name, media: person.media, me: me, muted: muted)
- if reconnectingPeers.contains(person.peer) { notices.insert(ReconnectWords.peer(person.name), at: 0) }
+ let epoch = person.gen ?? 0
+ var was = heardFrom[person.peer] ?? (epoch, false)
+ if was.epoch != epoch { was = (epoch, false) }
+ let heard = HearingRule.heard(
+ was: was.heard,
+ .init(
+ connected: reading.connected, bad: reading.gone, hasReceiver: reading.hasReceiver,
+ receiverLive: reading.receiverLive, packets: reading.packets ?? 0, decoded: reading.buffers,
+ senderMuted: person.media?.muted == true))
+ heardFrom[person.peer] = (epoch, heard)
+ if reading.acked == true { ackedBy[person.peer] = epoch }
+ arrivals[person.peer] = ConnectingWatch.Peer(
+ epoch: epoch, hearsMe: HearingRule.hearsYou(person.media, me: me), reported: person.media != nil,
+ connected: reading.connected, acked: ackedBy[person.peer] == epoch)
+ }
+ for id in Array(heardFrom.keys) where !present.contains(id) { heardFrom.removeValue(forKey: id) }
+ for id in Array(ackedBy.keys) where !present.contains(id) { ackedBy.removeValue(forKey: id) }
+ for id in connectingWatch.observe(at: now, people: arrivals) {
+ CallLog.info("\(id.prefix(6)) can hear us")
+ AudioAnnouncer.say(ConnectingWords.canHear(name(of: id)))
+ }
+ let nowJoining = connectingWatch.joining
+ if connectingPeers != connectingWatch.shown { connectingPeers = connectingWatch.shown }
+ for person in state.peers where person.peer != me {
+ guard let reading = readings[person.peer] else { continue }
+ // Someone in their first moments has not lost anything yet: §15.24
+ // speaks for them until it gives up.
+ people[person.peer] =
+ nowJoining.contains(person.peer)
+ ? ReconnectWatch.Peer()
+ : ReconnectWatch.Peer(
+ connectionBad: reading.connectionBad,
+ audioMissing: audioArriving[person.peer] == false && person.media?.muted != true)
+ let said = ConnectingWatch.tile(
+ name: person.name, media: person.media, me: me, muted: muted,
+ joining: nowJoining.contains(person.peer), shown: connectingPeers.contains(person.peer),
+ reconnecting: reconnectingPeers.contains(person.peer))
remotePeakSinceSample = max(remotePeakSinceSample, reading.peak)
levels.updatePeer(
person.peer, name: person.name,
sample: PeerAudioSample(
reported: person.media, connected: reading.connected,
arriving: audioArriving[person.peer] ?? false, level: reading.peak),
- notices: notices,
+ notices: said.notices, connecting: said.connecting,
now: now)
}
watchReconnect(now: now, people: people)
@@ -608,6 +679,16 @@ private final class Events: NSObject, BridgeListenerProtocol {
audioArriving.removeValue(forKey: id)
}
reportNoSound(levels.noSoundFrom)
+ reportHearing(heardFrom.filter { $0.value.heard }.keys.sorted())
+ }
+
+ /// §15.24: whose audio this app is receiving and playing. Sent only on a
+ /// change, in order, off the main thread, as `noSoundFrom` is.
+ private func reportHearing(_ peers: [String]) {
+ guard peers != reportedHearing, let client = bridge else { return }
+ reportedHearing = peers
+ let body = json(peers) ?? "[]"
+ noSoundQueue.async { try? client.setHearing(body) }
}
/// One look by ReconnectWatch (spec §15.23), and the words it says.
@@ -1052,6 +1133,11 @@ private final class Events: NSObject, BridgeListenerProtocol {
values["remoteScreens"] = UInt64(remoteScreens.count)
values["screens"] = UInt64(screens.count)
values["invalidEvents"] = invalidEvents
+ // §15.24: people still in their first moments, people whose tiles say
+ // they cannot hear you yet, and how many this app reports it hears.
+ values["connectingJoining"] = UInt64(connectingWatch.joining.count)
+ values["connectingShown"] = UInt64(connectingPeers.count)
+ values["hearing"] = UInt64(reportedHearing.count)
// This app's own screen, as it leaves: the slowest connection's.
if let screen = screenReport {
values["screenSendWidth"] = UInt64(screen.width)
@@ -1156,6 +1242,24 @@ extension Call {
self.sharingScreen = sharingScreen
}
+ /// Spec §15.24 for the snapshots: these people have just joined and the
+ /// grace has passed, so each tile says what it would — "connecting", or
+ /// a report of theirs that replaces it.
+ @_spi(Fixtures) public func showFixtureConnecting(_ peers: [String]) {
+ let me = state?.peer ?? ""
+ var shown: Set<String> = []
+ for person in state?.peers ?? [] where peers.contains(person.peer) {
+ let said = ConnectingWatch.tile(
+ name: person.name, media: person.media, me: me, muted: muted, joining: true, shown: true,
+ reconnecting: false)
+ if said.connecting != nil { shown.insert(person.peer) }
+ levels.peer(person.peer).show(
+ state: person.media?.muted == true ? .muted : .meter(0), speaking: false, notices: said.notices,
+ connecting: said.connecting)
+ }
+ connectingPeers = shown
+ }
+
/// Everyone's glow, and yours, held at one level, for the snapshots.
@_spi(Fixtures) public func showFixtureSpeaking(level: Float) {
levels.mic.glow.showFixture(level: level)
diff --git a/native/apple/Sources/CallsKit/CallFixtures.swift b/native/apple/Sources/CallsKit/CallFixtures.swift
index 071b94b..f09378d 100644
--- a/native/apple/Sources/CallsKit/CallFixtures.swift
+++ b/native/apple/Sources/CallsKit/CallFixtures.swift
@@ -16,6 +16,9 @@ import SwiftUI
case screen
/// Somebody waiting to be let in, and the connection coming back.
case notices
+ /// Two people who have just joined (spec §15.24): one still connecting
+ /// and unable to hear you yet, the other's browser blocking sound.
+ case connecting
public static let title = "Call, 24 Sep 10:21"
@@ -41,6 +44,11 @@ import SwiftUI
call.showFixture(
title: Self.title, status: "Reconnecting…",
state: roster("reconnecting", [me, ana], waiting: [knock]), muted: true, floor: true)
+ case .connecting:
+ let sam = #"{"peer":"sam","name":"Samuel Okafor","kind":"member","media":{"muted":false,"cameraOff":false,"hearing":[]}}"#
+ let bea = #"{"peer":"bea","name":"Bea Ruiz","kind":"guest","media":{"muted":false,"cameraOff":true,"audioBlocked":true,"hearing":[]}}"#
+ call.showFixture(title: Self.title, state: roster("admitted", [me, ana, sam, bea]))
+ call.showFixtureConnecting(["sam", "bea"])
}
}
}
diff --git a/native/apple/Sources/CallsKit/ConnectingWatch.swift b/native/apple/Sources/CallsKit/ConnectingWatch.swift
new file mode 100644
index 0000000..f8827b4
--- /dev/null
+++ b/native/apple/Sources/CallsKit/ConnectingWatch.swift
@@ -0,0 +1,160 @@
+import Foundation
+
+/// "<name> is connecting and can't hear you yet" (spec §15.24, 2026-10-01).
+///
+/// Paul asked for Zoom's "… is connecting to audio and cannot hear you yet":
+/// the gap after someone joins, before their side is receiving and playing
+/// your audio, said rather than talked into. Their own report's `hearing`
+/// is the authority; a client that does not send one is judged on what this
+/// app can see of its own audio reaching them.
+///
+/// For each person from the moment they appear in the roster (or appear
+/// again, or are given a new connection):
+///
+/// - the words are said only once `grace` (1 s) has passed without their
+/// hearing you, so an ordinary join says nothing;
+/// - they go the moment their report lists you in `hearing` — "Samuel can
+/// hear you now";
+/// - for a client that does not say who it hears, once the connection is up
+/// and either their receiver reports show your audio reaching them or
+/// they have made a report without the list;
+/// - and never past `cap` (20 s), after which §15.20's and §15.23's words
+/// speak for any problem.
+///
+/// Your own mute does not matter: it is about their hearing you when you
+/// speak. Pure: fed the facts as they are, with the time. The browser's
+/// `connectingStep` is its twin.
+struct ConnectingWatch: Equatable {
+ static let grace = 1.0
+ static let cap = 20.0
+
+ /// One person as they are now.
+ struct Peer: Equatable {
+ /// The pair generation: a new one is a new connection, and they cannot
+ /// hear you on it yet.
+ var epoch: Int64 = 0
+ /// Their report on you (`HearingRule.hearsYou`): listed, not listed,
+ /// or nil for no list at all.
+ var hearsMe: Bool?
+ /// They have made a media report.
+ var reported = false
+ /// The connection to them is up.
+ var connected = false
+ /// Their receiver reports say your audio reaches them.
+ var acked = false
+ }
+
+ private struct Record: Equatable {
+ var epoch: Int64
+ var since: Double
+ var done = false
+ var shown = false
+ }
+ private var records: [String: Record] = [:]
+
+ init() {}
+
+ /// People still in their first moments, whether or not the words show yet.
+ var joining: Set<String> { Set(records.filter { !$0.value.done }.keys) }
+ /// People whose tiles say it.
+ var shown: Set<String> { Set(records.filter { $0.value.shown }.keys) }
+
+ /// One look at everyone but you. Returns the people whose words have just
+ /// gone because they can hear you, for "… can hear you now".
+ @discardableResult
+ mutating func observe(at now: Double, people: [String: Peer]) -> [String] {
+ for id in Array(records.keys) where people[id] == nil { records.removeValue(forKey: id) }
+ var cleared: [String] = []
+ for (id, p) in people {
+ var r = records[id] ?? Record(epoch: p.epoch, since: now)
+ if r.epoch != p.epoch { r = Record(epoch: p.epoch, since: now) }
+ if !r.done {
+ let fallback = p.hearsMe == nil && p.connected && (p.acked || p.reported)
+ if p.hearsMe == true || fallback {
+ if r.shown { cleared.append(id) }
+ r.done = true
+ r.shown = false
+ } else if now - r.since >= Self.cap {
+ r.done = true
+ r.shown = false
+ } else {
+ r.shown = now - r.since >= Self.grace
+ }
+ }
+ records[id] = r
+ }
+ return cleared.sorted()
+ }
+
+ /// What a person's tile says about them and you, in order (the browser's
+ /// `peerNotice`): in their first moments, their own more specific report
+ /// (their browser blocking sound, or no sound from you) and otherwise the
+ /// connecting words once the grace has passed — "Reconnecting" would be
+ /// wrong of a connection that has never been up; after that, §15.23's
+ /// "Reconnecting to …" first and then their reports. `connecting` is the
+ /// line that carries the moving ellipsis, and is never set beside
+ /// `notices`.
+ static func tile(
+ name: String, media: PeerMedia?, me: String, muted: Bool, joining: Bool, shown: Bool,
+ reconnecting: Bool
+ ) -> (notices: [String], connecting: String?) {
+ let reports = AudioWords.notices(name: name, media: media, me: me, muted: muted)
+ if joining {
+ if !reports.isEmpty { return (reports, nil) }
+ return ([], shown ? ConnectingWords.connecting(name) : nil)
+ }
+ return ((reconnecting ? [ReconnectWords.peer(name)] : []) + reports, nil)
+ }
+}
+
+/// This app's half of §15.24: whose audio it can say it hears.
+enum HearingRule {
+ /// One connection's audio as it is now.
+ struct Facts: Equatable {
+ /// ICE connected or completed.
+ var connected = false
+ /// ICE failed or closed: the path is gone.
+ var bad = false
+ var hasReceiver = false
+ /// The audio receiver's track is live.
+ var receiverLive = false
+ /// Audio packets received on it so far.
+ var packets: UInt64 = 0
+ /// Decoded audio frames that reached the receive tap since the last look.
+ var decoded = 0
+ /// They say they are muted (or have no microphone).
+ var senderMuted = false
+ }
+
+ /// Whether the audio path from someone is established. Once it is, it
+ /// stays so until the connection fails or closes. Established is: the
+ /// connection up, and either their audio arriving — packets received and
+ /// decoded frames coming out — on a live receiver, or, for someone who
+ /// says they are muted, nothing more than that: a muted sender may send
+ /// only silence, or no audio at all, and must not look unheard, because
+ /// the moment they unmute, it plays. The browser's `judgeHeard` is its
+ /// twin. A native app plays whatever it decodes: there is no autoplay
+ /// policy to refuse it.
+ static func heard(was: Bool, _ f: Facts) -> Bool {
+ if f.bad { return false }
+ if was { return true }
+ if !f.connected { return false }
+ if f.senderMuted, !f.hasReceiver || f.receiverLive { return true }
+ return f.receiverLive && f.packets > 0 && f.decoded > 0
+ }
+
+ /// A person's report on you: true when their `hearing` lists you, false
+ /// when it has a list and you are not on it, nil when it has no list — no
+ /// report yet, or a client from before §15.24.
+ static func hearsYou(_ media: PeerMedia?, me: String) -> Bool? {
+ guard let list = media?.hearing else { return nil }
+ return !me.isEmpty && list.contains(me)
+ }
+}
+
+/// The words (spec §15.24). The ellipsis is drawn apart from them
+/// (`ConnectingEllipsis`), so VoiceOver hears the sentence and not the dots.
+public enum ConnectingWords {
+ public static func connecting(_ name: String) -> String { "\(name) is connecting and can’t hear you yet" }
+ public static func canHear(_ name: String) -> String { "\(name) can hear you now" }
+}
diff --git a/native/apple/Sources/CallsKit/Media.swift b/native/apple/Sources/CallsKit/Media.swift
index 92857a4..b96c140 100644
--- a/native/apple/Sources/CallsKit/Media.swift
+++ b/native/apple/Sources/CallsKit/Media.swift
@@ -386,32 +386,66 @@ import LiveKitWebRTC
/// Each remote person's sound, for the meters (spec §15.20): the level
/// their receive tap has seen since the last look, whether the
- /// connection is up, and — when `stats` is set — the audio bytes
- /// received so far, one `statistics(for:)` per audio receiver rather
- /// than the whole report. Nil bytes when not asked.
- func peerAudio(stats: Bool) async -> [String: PeerAudioReading] {
+ /// connection is up, and — when `stats` is set, or for the people in
+ /// `detail` — the audio bytes and packets received so far and whether
+ /// the receiver is live, one `statistics(for:)` per audio receiver rather
+ /// than the whole report. For the people in `acks`, whether their
+ /// receiver reports say this app's audio reaches them (spec §15.24's
+ /// fallback for a client that does not say who it hears).
+ func peerAudio(stats: Bool, detail: Set<String> = [], acks: Set<String> = []) async -> [String: PeerAudioReading] {
var out: [String: PeerAudioReading] = [:]
for (id, peer) in peers {
guard let pc = peer.pc else { continue }
let (peak, buffers) = peer.tap.take()
+ let deep = stats || detail.contains(id)
+ let ack = acks.contains(id)
// Read on the audio queue, as every call into the connection is
// (PeerCalls): five times a second, and each one a blocking hop to
// WebRTC's signalling thread.
- let (state, receivers) = await PeerCalls.read(pc) { pc in
- (pc.iceConnectionState, stats ? pc.receivers.filter { $0.track?.kind == "audio" } : [])
+ let (state, receivers, senders) = await PeerCalls.read(pc) { pc in
+ (
+ pc.iceConnectionState, deep ? pc.receivers.filter { $0.track?.kind == "audio" } : [],
+ ack ? pc.senders.filter { $0.track?.kind == "audio" } : []
+ )
}
var reading = PeerAudioReading(
peak: peak, buffers: buffers, connected: state == .connected || state == .completed,
connectionBad: state == .disconnected || state == .failed)
- if stats {
+ reading.gone = state == .failed || state == .closed
+ if deep {
var bytes: UInt64 = 0
+ var packets: UInt64 = 0
+ var live = false
for receiver in receivers {
+ if receiver.track?.readyState == .live { live = true }
let report = await PeerCalls.statistics(pc, for: receiver)
for stat in report.statistics.values where stat.type == "inbound-rtp" {
bytes += (stat.values["bytesReceived"] as? NSNumber)?.uint64Value ?? 0
+ packets += (stat.values["packetsReceived"] as? NSNumber)?.uint64Value ?? 0
}
}
reading.bytes = bytes
+ reading.packets = packets
+ reading.hasReceiver = !receivers.isEmpty
+ reading.receiverLive = live
+ }
+ if ack {
+ var sent: UInt64 = 0
+ var acked = false
+ for sender in senders {
+ let report = await PeerCalls.statistics(pc, for: sender)
+ for stat in report.statistics.values {
+ if stat.type == "outbound-rtp" {
+ sent += (stat.values["packetsSent"] as? NSNumber)?.uint64Value ?? 0
+ } else if stat.type == "remote-inbound-rtp" {
+ // Exists only once a receiver report has come back.
+ let received = (stat.values["packetsReceived"] as? NSNumber)?.uint64Value ?? 0
+ let measured = (stat.values["roundTripTimeMeasurements"] as? NSNumber)?.uint64Value ?? 0
+ if received > 0 || measured > 0 || stat.values["roundTripTime"] != nil { acked = true }
+ }
+ }
+ }
+ reading.acked = sent > 0 && acked
}
out[id] = reading
}
@@ -464,7 +498,17 @@ struct PeerAudioReading: Equatable, Sendable {
var connected: Bool
/// ICE `disconnected` or `failed` (spec §15.23's three seconds).
var connectionBad = false
+ /// ICE `failed` or `closed`: whatever was heard on it is gone (§15.24).
+ var gone = false
var bytes: UInt64?
+ /// Audio packets received, the receiver there at all, and its track
+ /// live (§15.24); read with `bytes`.
+ var packets: UInt64?
+ var hasReceiver = false
+ var receiverLive = false
+ /// Their receiver reports say this app's audio reaches them; nil when
+ /// not asked.
+ var acked: Bool?
}
@MainActor private final class MediaPeer: NSObject, RTCPeerConnectionDelegate {
diff --git a/native/apple/Sources/CallsKit/MiniWindow.swift b/native/apple/Sources/CallsKit/MiniWindow.swift
index 9b819b0..d4af785 100644
--- a/native/apple/Sources/CallsKit/MiniWindow.swift
+++ b/native/apple/Sources/CallsKit/MiniWindow.swift
@@ -422,6 +422,13 @@
return item
}
+ /// The people still connecting who are not the one on show.
+ private var offStage: [String] {
+ var shown: String?
+ if case .camera(let peer) = subject { shown = peer }
+ return call.connectingPeers.filter { $0 != shown }.sorted()
+ }
+
var body: some View {
ZStack {
Group {
@@ -455,6 +462,10 @@
.accessibilityLabel("Your microphone is muted")
}
Spacer()
+ // Spec §15.24, for the people this window is not showing: the
+ // one it shows says it on its own tile. Top right, clear of
+ // the tile's Muted badge.
+ ConnectingDot(names: offStage.map { call.name(of: $0) })
if showingControls {
Button("Collapse to a tab", glyph: "arrow.down.right.and.arrow.up.left") {
actions.collapse()
@@ -513,6 +524,17 @@
.background(
call.muted ? Color(red: 0.65, green: 0.30, blue: 0.02) : CallsAppearance.accent,
in: Circle())
+ // Spec §15.24: someone is still connecting and cannot hear you
+ // yet. A dot only — the tab has no room for words; its
+ // accessibility label and the window, opened, say who.
+ .overlay(alignment: .topTrailing) {
+ if !call.connectingPeers.isEmpty {
+ Circle().fill(ConnectingDot.colour).frame(width: 8, height: 8)
+ .overlay(Circle().strokeBorder(Color.black.opacity(0.35), lineWidth: 1))
+ .offset(x: 2, y: -2)
+ .help(call.connectingPeers.sorted().map { ConnectingWords.connecting(call.name(of: $0)) }.joined(separator: "\n"))
+ }
+ }
Text(caption).font(.callout.weight(.medium)).lineLimit(1).truncationMode(.tail)
.foregroundStyle(.white)
Spacer(minLength: 0)
@@ -531,7 +553,9 @@
.clipShape(Capsule())
.accessibilityElement(children: .ignore)
.accessibilityAddTraits(.isButton)
- .accessibilityLabel("Calls, \(caption)")
+ .accessibilityLabel(
+ (["Calls, \(caption)"] + call.connectingPeers.sorted().map { ConnectingWords.connecting(call.name(of: $0)) })
+ .joined(separator: ". "))
.accessibilityValue(call.muted ? "Microphone muted" : "Microphone on")
.accessibilityHint("Shows the mini window.")
.accessibilityAction { actions.expand() }
@@ -543,6 +567,33 @@
}
}
+ /// The mini window's whole say on spec §15.24: a small ear on the glass
+ /// while someone it is not showing is connecting and cannot hear you yet,
+ /// with their names in its tooltip and for VoiceOver. Nothing at all
+ /// otherwise.
+ struct ConnectingDot: View {
+ /// A warm amber, light enough to tell from the muted microphone's
+ /// brown on the tab.
+ static let colour = Color(red: 1, green: 0.78, blue: 0.42)
+ let names: [String]
+ var body: some View {
+ if !names.isEmpty {
+ let words = names.map(ConnectingWords.connecting).joined(separator: "\n")
+ Image(systemName: "ear")
+ .font(.callout.bold()).foregroundStyle(.white)
+ .frame(width: 32, height: 32)
+ .callOverlaySurface(Circle(), rim: false)
+ .overlay(alignment: .topTrailing) {
+ Circle().fill(Self.colour).frame(width: 7, height: 7)
+ }
+ .help(words)
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(words)
+ .accessibilityIdentifier("connectingDot")
+ }
+ }
+ }
+
/// The window's background: a click, a drag that moves the window, and
/// — for the expanded window — a drag from an edge or corner that
/// resizes it. Handled here rather than with isMovableByWindowBackground
diff --git a/native/apple/Sources/CallsKit/Wire.swift b/native/apple/Sources/CallsKit/Wire.swift
index 2c20d31..d750c01 100644
--- a/native/apple/Sources/CallsKit/Wire.swift
+++ b/native/apple/Sources/CallsKit/Wire.swift
@@ -24,16 +24,21 @@ public struct PeerMedia: Decodable, Equatable, Sendable {
/// The peers whose shared screen has stopped moving for it (§15.21).
/// A sharer that finds itself here recovers its screen. Nil is none.
public let screenStalled: [String]?
+ /// The peers whose audio it is actually receiving and playing (§15.24).
+ /// Nil (absent) is a client that does not say; empty is one that hears
+ /// nobody yet.
+ public let hearing: [String]?
public init(
muted: Bool, cameraOff: Bool, audioBlocked: Bool? = nil, noSoundFrom: [String]? = nil,
- screenStalled: [String]? = nil
+ screenStalled: [String]? = nil, hearing: [String]? = nil
) {
self.muted = muted
self.cameraOff = cameraOff
self.audioBlocked = audioBlocked
self.noSoundFrom = noSoundFrom
self.screenStalled = screenStalled
+ self.hearing = hearing
}
}
public struct RoomState: Decodable {
diff --git a/native/apple/Tests/CallsKitTests/ConnectingWatchTests.swift b/native/apple/Tests/CallsKitTests/ConnectingWatchTests.swift
new file mode 100644
index 0000000..53e29ae
--- /dev/null
+++ b/native/apple/Tests/CallsKitTests/ConnectingWatchTests.swift
@@ -0,0 +1,231 @@
+import XCTest
+
+@testable import CallsKit
+
+/// "<name> is connecting and can't hear you yet" (spec §15.24). Paul,
+/// 2026-10-01, asking for Zoom's "… is connecting to audio and cannot hear
+/// you yet": the gap after someone joins, said rather than talked into.
+final class ConnectingWatchTests: XCTestCase {
+ private typealias Peer = ConnectingWatch.Peer
+ private let waiting = Peer(epoch: 1, hearsMe: false, reported: true, connected: false, acked: false)
+
+ /// Runs the watch at 5 Hz from `from` to `to` seconds; when Sam's words
+ /// showed, and when they cleared with "can hear you now". `sam` nil is
+ /// Sam not in the roster.
+ private func run(
+ _ watch: inout ConnectingWatch, from: Double, to: Double, sam: (Double) -> Peer?
+ ) -> (shown: [Double], cleared: [Double]) {
+ var shown: [Double] = []
+ var cleared: [Double] = []
+ var t = from
+ while t < to {
+ let people = sam(t).map { ["sam": $0] } ?? [:]
+ if watch.observe(at: t, people: people).contains("sam") { cleared.append(t) }
+ if watch.shown.contains("sam") { shown.append(t) }
+ t = (t * 10 + 2).rounded() / 10
+ }
+ return (shown, cleared)
+ }
+
+ func testTheTimingsAreTheOnesTheSpecNames() {
+ XCTAssertEqual(ConnectingWatch.grace, 1)
+ XCTAssertEqual(ConnectingWatch.cap, 20)
+ }
+
+ func testAnOrdinaryJoinHeardWithinTheGraceSaysNothing() {
+ var watch = ConnectingWatch()
+ var sam = waiting
+ let seen = run(&watch, from: 0, to: 10) { t in
+ sam.connected = t >= 0.4
+ sam.hearsMe = t >= 0.8
+ return sam
+ }
+ XCTAssertEqual(seen.shown, [])
+ XCTAssertEqual(seen.cleared, [], "nothing was said, so \"can hear you now\" is not said either")
+ XCTAssertFalse(watch.joining.contains("sam"))
+ }
+
+ func testASlowJoinIsSaidAfterTheGraceAndClearsWhenTheyHearYou() {
+ var watch = ConnectingWatch()
+ var sam = waiting
+ let seen = run(&watch, from: 0, to: 12) { t in
+ sam.connected = t >= 5
+ sam.hearsMe = t >= 6
+ return sam
+ }
+ XCTAssertEqual(seen.shown.first, 1)
+ XCTAssertEqual(seen.shown.last, 5.8)
+ XCTAssertEqual(seen.cleared, [6])
+ }
+
+ func testOnceClearedItStaysClearedWhateverTheirListDoes() {
+ var watch = ConnectingWatch()
+ var sam = waiting
+ sam.connected = true
+ let seen = run(&watch, from: 0, to: 30) { t in
+ sam.hearsMe = t >= 3 && t < 10
+ return sam
+ }
+ XCTAssertEqual(seen.cleared, [3])
+ XCTAssertEqual(seen.shown.last, 2.8)
+ }
+
+ func testItIsGivenUpAtTheCapSilently() {
+ var watch = ConnectingWatch()
+ let seen = run(&watch, from: 0, to: 40) { _ in self.waiting }
+ XCTAssertEqual(seen.shown.first, 1)
+ XCTAssertEqual(seen.shown.last, 19.8)
+ XCTAssertEqual(seen.cleared, [], "a cap is not their hearing you")
+ XCTAssertTrue(watch.joining.isEmpty)
+ }
+
+ func testAClientThatDoesNotSayIsLetGoOnItsFirstReportAndAConnection() {
+ var watch = ConnectingWatch()
+ let seen = run(&watch, from: 0, to: 10) { t in
+ Peer(epoch: 1, hearsMe: nil, reported: t >= 4, connected: t >= 2, acked: false)
+ }
+ XCTAssertEqual(seen.cleared, [4])
+ }
+
+ func testOrOnAConnectionAndReceiverReportsShowingYourAudioReachesThem() {
+ var watch = ConnectingWatch()
+ let seen = run(&watch, from: 0, to: 10) { t in
+ Peer(epoch: 1, hearsMe: nil, reported: false, connected: t >= 2, acked: t >= 3)
+ }
+ XCTAssertEqual(seen.cleared, [3])
+ var unconnected = ConnectingWatch()
+ let never = run(&unconnected, from: 0, to: 10) { _ in
+ Peer(epoch: 1, hearsMe: nil, reported: true, connected: false, acked: true)
+ }
+ XCTAssertEqual(never.cleared, [], "never without a connection")
+ }
+
+ func testAClientThatSaysWhoItHearsIsBelievedOverYourOwnEvidence() {
+ var watch = ConnectingWatch()
+ let seen = run(&watch, from: 0, to: 10) { _ in
+ Peer(epoch: 1, hearsMe: false, reported: true, connected: true, acked: true)
+ }
+ XCTAssertEqual(seen.cleared, [])
+ XCTAssertFalse(seen.shown.isEmpty)
+ }
+
+ func testARejoinStartsItAgainAndSoDoesANewConnection() {
+ var watch = ConnectingWatch()
+ var sam = waiting
+ let seen = run(&watch, from: 0, to: 20) { t in
+ if t >= 5 && t < 6 { return nil }
+ sam.connected = true
+ sam.hearsMe = t < 5 ? t >= 2 : t >= 9
+ return sam
+ }
+ XCTAssertEqual(seen.cleared, [2, 9])
+ XCTAssertTrue(seen.shown.contains(7))
+ XCTAssertFalse(seen.shown.contains(6.8), "the grace again from the moment they are back")
+
+ var rebuilt = ConnectingWatch()
+ var again = waiting
+ again.connected = true
+ let second = run(&rebuilt, from: 0, to: 20) { t in
+ again.epoch = t < 8 ? 1 : 2
+ again.hearsMe = t < 1 || t >= 12
+ return again
+ }
+ XCTAssertEqual(second.cleared, [12])
+ XCTAssertEqual(second.shown.first, 9)
+ }
+
+ func testAMoreSpecificReportReplacesConnectingAndReconnectingWaitsUntilAfter() {
+ let plain = PeerMedia(muted: false, cameraOff: false, hearing: [])
+ func tile(
+ _ media: PeerMedia, muted: Bool = false, joining: Bool = true, shown: Bool = true,
+ reconnecting: Bool = false
+ ) -> (notices: [String], connecting: String?) {
+ ConnectingWatch.tile(
+ name: "Sam", media: media, me: "me", muted: muted, joining: joining, shown: shown,
+ reconnecting: reconnecting)
+ }
+ XCTAssertNil(tile(plain, shown: false).connecting, "inside the grace")
+ XCTAssertEqual(tile(plain).connecting, "Sam is connecting and can’t hear you yet")
+ XCTAssertEqual(tile(plain).notices, [])
+ XCTAssertNotNil(tile(plain, muted: true).connecting, "your own mute does not matter")
+ XCTAssertEqual(
+ tile(plain, reconnecting: true).notices, [], "a connection that has never been up is not reconnecting")
+ let blocked = PeerMedia(muted: false, cameraOff: false, audioBlocked: true, hearing: [])
+ XCTAssertEqual(tile(blocked, shown: false).notices, [AudioWords.blocked("Sam")], "even inside the grace")
+ XCTAssertNil(tile(blocked).connecting)
+ let silent = PeerMedia(muted: false, cameraOff: false, noSoundFrom: ["me"], hearing: ["me"])
+ XCTAssertEqual(tile(silent).notices, [AudioWords.notHearingYou("Sam")])
+ XCTAssertNil(tile(silent).connecting)
+ XCTAssertNotNil(
+ tile(PeerMedia(muted: false, cameraOff: false, noSoundFrom: ["me"]), muted: true).connecting,
+ "no sound from you while you are muted is expected, so it replaces nothing")
+ XCTAssertEqual(
+ tile(plain, joining: false, shown: false, reconnecting: true).notices, [ReconnectWords.peer("Sam")])
+ XCTAssertEqual(
+ tile(blocked, joining: false, shown: false, reconnecting: true).notices,
+ [ReconnectWords.peer("Sam"), AudioWords.blocked("Sam")])
+ XCTAssertEqual(tile(plain, joining: false, shown: false).notices, [])
+ }
+
+ func testHearsYou() {
+ XCTAssertEqual(HearingRule.hearsYou(PeerMedia(muted: false, cameraOff: false, hearing: ["me"]), me: "me"), true)
+ XCTAssertEqual(HearingRule.hearsYou(PeerMedia(muted: false, cameraOff: false, hearing: []), me: "me"), false)
+ XCTAssertNil(HearingRule.hearsYou(PeerMedia(muted: false, cameraOff: false), me: "me"))
+ XCTAssertNil(HearingRule.hearsYou(nil, me: "me"))
+ }
+
+ /// This app's own half: when a path counts as heard. A muted sender must
+ /// not look unheard.
+ func testHeardIsArrivingAndDecodedOrAMutedSenderOnALiveConnection() {
+ let base = HearingRule.Facts(
+ connected: true, bad: false, hasReceiver: true, receiverLive: true, packets: 0, decoded: 0,
+ senderMuted: false)
+ func with(_ change: (inout HearingRule.Facts) -> Void) -> HearingRule.Facts {
+ var f = base
+ change(&f)
+ return f
+ }
+ XCTAssertFalse(HearingRule.heard(was: false, base), "nothing has arrived")
+ XCTAssertFalse(HearingRule.heard(was: false, with { $0.packets = 10 }), "packets, nothing decoded")
+ XCTAssertTrue(HearingRule.heard(was: false, with { $0.packets = 10; $0.decoded = 3 }))
+ XCTAssertFalse(
+ HearingRule.heard(was: false, with { $0.packets = 10; $0.decoded = 3; $0.connected = false }),
+ "not before the connection is up")
+ XCTAssertTrue(HearingRule.heard(was: false, with { $0.senderMuted = true }), "a muted sender")
+ XCTAssertTrue(
+ HearingRule.heard(was: false, with { $0.senderMuted = true; $0.hasReceiver = false; $0.receiverLive = false }),
+ "nor one with no microphone at all")
+ XCTAssertFalse(
+ HearingRule.heard(was: false, with { $0.senderMuted = true; $0.receiverLive = false }), "but not a dead receiver")
+ XCTAssertTrue(HearingRule.heard(was: true, base), "once heard, kept")
+ XCTAssertTrue(HearingRule.heard(was: true, with { $0.connected = false }), "a blip is not losing them")
+ XCTAssertFalse(HearingRule.heard(was: true, with { $0.bad = true }), "a failed connection is")
+ }
+
+ func testTheHearingListDecodesAndAbsentIsNotEmpty() throws {
+ func media(_ json: String) throws -> PeerMedia? {
+ let state = try JSONDecoder().decode(
+ RoomState.self,
+ from: Data(
+ #"{"phase":"admitted","peer":"me","boot":"b","waiting":[],"peers":[{"peer":"sam","name":"Sam","kind":"member","gen":1,"media":\#(json)}]}"#
+ .utf8))
+ return Call.peerMedia("sam", in: state)
+ }
+ XCTAssertEqual(try media(#"{"muted":false,"cameraOff":false,"hearing":["me"]}"#)?.hearing, ["me"])
+ XCTAssertEqual(try media(#"{"muted":false,"cameraOff":false,"hearing":[]}"#)?.hearing, [])
+ XCTAssertNil(try media(#"{"muted":false,"cameraOff":false}"#)?.hearing)
+ }
+
+ func testTheDotsTakeTurnsAndNeverGoOut() {
+ for dot in 0..<3 {
+ for step in 0..<28 {
+ let o = ConnectingText.opacity(at: Double(step) * 0.05, dot: dot)
+ XCTAssertGreaterThanOrEqual(o, 0.35)
+ XCTAssertLessThanOrEqual(o, 1.0001)
+ }
+ }
+ XCTAssertEqual(ConnectingText.opacity(at: 0.56, dot: 0), 1, accuracy: 0.001)
+ XCTAssertEqual(ConnectingText.opacity(at: 0.76, dot: 1), 1, accuracy: 0.001)
+ XCTAssertEqual(ConnectingText.opacity(at: 0.96, dot: 2), 1, accuracy: 0.001)
+ }
+}
diff --git a/native/ios/UITests/CallsPhoneUITests.swift b/native/ios/UITests/CallsPhoneUITests.swift
index d9a9a47..92525ab 100644
--- a/native/ios/UITests/CallsPhoneUITests.swift
+++ b/native/ios/UITests/CallsPhoneUITests.swift
@@ -244,6 +244,30 @@ final class CallsPhoneUITests: XCTestCase {
}
}
+ /// Spec §15.24: two people who have just joined — one connecting and
+ /// unable to hear you yet, the other's browser blocking sound in place of
+ /// that — in Grid and Speaker, light and dark. The notice is on the tile,
+ /// and VoiceOver hears it in the tile's label, without the dots.
+ @MainActor func testConnectingSnapshots() throws {
+ XCUIDevice.shared.orientation = .portrait
+ for dark in [false, true] {
+ let scheme = dark ? "dark" : "light"
+ for layout in ["grid", "speaker"] {
+ let app = launchCall(
+ "connecting",
+ ["-CallsFixtureControls", "hidden", "-CallsFixtureLayout", layout, "-CallsFixtureOrder", "sam,bea,ana,me"],
+ dark: dark)
+ let notice = app.descendants(matching: .any)
+ .matching(NSPredicate(format: "label CONTAINS %@", "Samuel Okafor is connecting and can’t hear you yet"))
+ .firstMatch
+ XCTAssertTrue(notice.waitForExistence(timeout: 15), "Samuel's tile says it")
+ XCTAssertFalse(notice.label.contains("…"), "and VoiceOver hears no dots")
+ Thread.sleep(forTimeInterval: 1)
+ try snapshot(app, "connecting-\(layout)-portrait-\(scheme)")
+ }
+ }
+ }
+
/// The overlay over black, white and a busy frame, in each of your own
/// states, light and dark: it should look the same in both. The
/// microphone sits beside the menu here so its muted state shows.
diff --git a/test/browser/native.mjs b/test/browser/native.mjs
index 9ade7d2..f5a85ca 100644
--- a/test/browser/native.mjs
+++ b/test/browser/native.mjs
@@ -28,6 +28,20 @@ try {
await page.goto(`${origin}/m/${room}`);
await page.waitForFunction(() => !!window.meet);
console.log('browser before native', await page.evaluate(() => ({status: document.querySelector('#status')?.textContent, tiles: document.querySelectorAll('#tiles video').length})));
+ // Spec §15.24, both ways: the browser's offers, answers and candidates
+ // are held back for the first few seconds, so the app and the browser
+ // each have someone who cannot hear them yet, and each says so.
+ const HOLD = 5000;
+ let releaseAt = 0;
+ const holdSignals = async (route) => {
+ const body = route.request().postData() || '';
+ if (/"type":"(offer|answer|candidate)"/.test(body)) {
+ if (!releaseAt) releaseAt = Date.now() + HOLD;
+ await new Promise(r => setTimeout(r, Math.max(0, releaseAt - Date.now())));
+ }
+ await route.continue();
+ };
+ if (mode !== 'guest') await page.route('**/signal', holdSignals);
native = spawn(binary, [], { stdio: ['pipe', 'pipe', 'pipe'] });
native.stdin.write(process.env.CALLS_NATIVE_INPUT + '\n');
let sample = null;
@@ -76,6 +90,33 @@ try {
await page.getByRole('button', { name: 'Admit', exact: true }).click();
console.log('PASS guest media waits for member admission');
}
+ if (mode !== 'guest') {
+ await wait(() => sample?.peer, 'the app is in the call');
+ const app = sample.peer;
+ const connecting = () => page.evaluate((id) => {
+ const s = window.meet.state();
+ return { c: s.connecting[id] || null, h: s.hearing[id] || null, media: s.remoteMedia[id] || null,
+ me: s.peer, hearingList: s.hearingList };
+ }, app);
+ await wait(async () => { const c = await connecting(); return c.h && c.h.noticeKind === 'connecting'; },
+ 'the browser says the app cannot hear it yet', 15);
+ const words = (await connecting()).h.notice;
+ assert.match(words, / is connecting and can’t hear you yet$/);
+ console.log(`PASS the browser says "${words}" while the connection is held`);
+ await wait(() => sample?.stats.connectingShown === 1, 'the app says the browser cannot hear it yet', 15);
+ console.log('PASS the app says the browser cannot hear it yet');
+ await wait(async () => { const c = await connecting(); return c.c && !c.c.joining && c.h.notice === ''; },
+ 'the browser stops saying it once the app hears it', 30);
+ const heard = await connecting();
+ assert.ok((heard.media.hearing || []).includes(heard.me), 'because the app reports it hears the browser');
+ assert.ok(Date.now() >= releaseAt, 'and not before the hold let go');
+ console.log('PASS the app reports hearing the browser, and the browser\u2019s notice goes');
+ await wait(() => sample?.stats.connectingShown === 0 && sample?.stats.connectingJoining === 0 && sample?.stats.hearing === 1,
+ 'the app stops saying it once the browser hears it', 30);
+ assert.ok(heard.hearingList.includes(app) || (await connecting()).hearingList.includes(app), 'the browser reports it hears the app');
+ console.log('PASS the browser reports hearing the app, and the app\u2019s notice goes');
+ await page.unroute('**/signal', holdSignals);
+ }
await wait(() => sample?.stats.framesDecoded > 15 && sample?.stats.audioBytesReceived > 0 && sample?.stats.videoBytesSent > 0 && sample?.stats.audioBytesSent > 0, 'native receives browser audio/video');
console.log('PASS native decoded browser video and received audio');
// The synthetic microphone runs through WebRTC's capture processing, so