oficina / meet Public

Calls apps: quality reports and Report a problem on Mac and iPhone, report token in the core and bridge, Settings disclosure and toggle (spec 15.26)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 7bda96fff1e44a9f2f889fcce76bf3a8256888ee parent 6d95ad0
25 files changed, +2666 −19
  • callscore/report.go +175 −0
  • callscore/report_test.go +204 −0
  • callscore/room.go +7 −0
  • internal/callstest/browser_test.go +154 −1
  • internal/callstest/native_browser_test.go +43 −1
  • native/apple/Sources/CallsInterop/Interop.swift +5 −0
  • native/apple/Sources/CallsKit/Audio.swift +15 −0
  • native/apple/Sources/CallsKit/AudioDevices.swift +2 −0
  • native/apple/Sources/CallsKit/Call.swift +281 −7
  • native/apple/Sources/CallsKit/CallChrome.swift +20 −6
  • native/apple/Sources/CallsKit/CallsLobby.swift +23 −0
  • native/apple/Sources/CallsKit/CallsSettingsView.swift +1 −0
  • native/apple/Sources/CallsKit/DevicePanel.swift +18 −0
  • native/apple/Sources/CallsKit/MacCallView.swift +9 −3
  • native/apple/Sources/CallsKit/Media.swift +16 −0
  • native/apple/Sources/CallsKit/QualityReport.swift +781 −0
  • native/apple/Sources/CallsKit/ReportProblem.swift +270 −0
  • native/apple/Sources/CallsKit/ServerSettings.swift +23 −0
  • native/apple/Sources/CallsMac/CallsMac.swift +4 −0
  • native/apple/Tests/CallsKitTests/QualityReportTests.swift +450 −0
  • native/bridge/bridge.go +39 −0
  • native/bridge/bridge_test.go +77 −0
  • native/ios/Sources/CallsPhone.swift +2 −0
  • native/ios/Sources/PhoneFullScreenCall.swift +5 −1
  • native/ios/UITests/CallsPhoneUITests.swift +42 −0
diff --git a/callscore/report.go b/callscore/report.go
new file mode 100644
index 0000000..bbdb464
--- /dev/null
+++ b/callscore/report.go
@@ -0,0 +1,175 @@
+package callscore
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "regexp"
+ "time"
+
+ "amadan.net/oficina/calls/protocol"
+)
+
+// Quality and problem reports (spec §15.26). The host builds the report —
+// it holds the media statistics — and the core carries it, because the
+// core holds the two things the request needs: the session the hello was
+// given to, and the hello's report token.
+//
+// Both requests run on a context of their own rather than the room's, so
+// they still work once the room is closed: the final quality report goes
+// at leave, and "Report a problem" is offered for ten minutes after the
+// call (the server keeps the token that long past the participation).
+
+// reportTimeout bounds one report request. Ten seconds, as every other
+// request this package makes; a report that cannot get through in that
+// time is not worth holding a thread for.
+const reportTimeout = 10 * time.Second
+
+// reportTokenHeader is where the token goes (internal/calls/quality.go).
+const reportTokenHeader = "X-Calls-Report"
+
+var reportToken = regexp.MustCompile(`^[0-9a-f]{32}$`)
+
+// ReportOutcome is what became of a report, in words the host can turn
+// into a sentence for a person, or act on.
+type ReportOutcome string
+
+const (
+ // ReportSent: the server took it.
+ ReportSent ReportOutcome = "sent"
+ // ReportStop: the server's kill switch (CALLS_QUALITY_REPORTS=off)
+ // answered 204. The host sends no more quality reports this call.
+ ReportStop ReportOutcome = "stop"
+ // ReportTooMany: 429, the server's per-participation limit.
+ ReportTooMany ReportOutcome = "too-many"
+ // ReportGone: 404. The token is unknown — the participation ended
+ // more than ten minutes ago, or a new hello replaced it — or there
+ // was never one (a server from before §15.26).
+ ReportGone ReportOutcome = "gone"
+ // ReportFailed: anything else — no answer, a refusal, a bad body.
+ ReportFailed ReportOutcome = "failed"
+)
+
+// ReportError is a report that did not get through, and why.
+type ReportError struct{ Outcome ReportOutcome }
+
+func (e *ReportError) Error() string { return "Calls report not sent (" + string(e.Outcome) + ")" }
+
+// reportTokenNow is the latest hello's token, or "".
+func (r *Room) reportTokenNow() string {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return r.report
+}
+
+// SendQuality posts one quality report (protocol.QualityReport, as JSON).
+// stop is the server's 204: send no more for the rest of the call. Every
+// other refusal is a skipped report, and the next window tries again. It
+// blocks for up to ten seconds and belongs on the host's background
+// executor.
+func (r *Room) SendQuality(report []byte) (stop bool, err error) {
+ if len(report) > protocol.MaxQualityBody || !json.Valid(report) {
+ return false, &ReportError{ReportFailed}
+ }
+ status, _, err := r.postReport("quality", report)
+ if err != nil {
+ return false, err
+ }
+ switch status {
+ case http.StatusAccepted, http.StatusOK:
+ return false, nil
+ case http.StatusNoContent:
+ return true, nil
+ }
+ return false, &ReportError{outcomeOf(status)}
+}
+
+// SendProblem posts "Report a problem" (protocol.ProblemReport, as JSON)
+// and answers the reference the server gave it, which the host shows.
+func (r *Room) SendProblem(report []byte) (ref string, err error) {
+ if len(report) > protocol.MaxProblemBody || !json.Valid(report) {
+ return "", &ReportError{ReportFailed}
+ }
+ status, body, err := r.postReport("problem", report)
+ if err != nil {
+ return "", err
+ }
+ if status != http.StatusOK {
+ return "", &ReportError{outcomeOf(status)}
+ }
+ var receipt protocol.ProblemReceipt
+ if json.Unmarshal(body, &receipt) != nil || !validRef(receipt.Ref) {
+ return "", &ReportError{ReportFailed}
+ }
+ return receipt.Ref, nil
+}
+
+func outcomeOf(status int) ReportOutcome {
+ switch status {
+ case http.StatusTooManyRequests:
+ return ReportTooMany
+ case http.StatusNotFound:
+ return ReportGone
+ }
+ return ReportFailed
+}
+
+// validRef is the server's reference shape: eight of Crockford's
+// characters. Anything else is not shown to a person as one.
+func validRef(ref string) bool {
+ if len(ref) != 8 {
+ return false
+ }
+ for _, c := range ref {
+ if !(c >= '0' && c <= '9' || c >= 'A' && c <= 'Z') {
+ return false
+ }
+ }
+ return true
+}
+
+// postReport makes the request: the same session, Origin and redirect
+// rules as every other request from the transport, plus the token.
+func (r *Room) postReport(endpoint string, body []byte) (int, []byte, error) {
+ token := r.reportTokenNow()
+ if !reportToken.MatchString(token) {
+ return 0, nil, &ReportError{ReportGone}
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), reportTimeout)
+ defer cancel()
+ t := r.t
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, t.origin+"/m/"+t.room+"/"+endpoint, bytes.NewReader(body))
+ if err != nil {
+ return 0, nil, &ReportError{ReportFailed}
+ }
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Origin", t.origin)
+ req.Header.Set(reportTokenHeader, token)
+ res, err := t.http.Do(req)
+ if err != nil {
+ return 0, nil, &ReportError{ReportFailed}
+ }
+ defer res.Body.Close()
+ out, err := io.ReadAll(io.LimitReader(res.Body, maxResponse))
+ if err != nil {
+ return 0, nil, &ReportError{ReportFailed}
+ }
+ return res.StatusCode, out, nil
+}
+
+// OutcomeOf is a report error's outcome, ReportFailed for any other
+// error, and ReportSent for none.
+func OutcomeOf(err error) ReportOutcome {
+ if err == nil {
+ return ReportSent
+ }
+ var refusal *ReportError
+ if errors.As(err, &refusal) {
+ return refusal.Outcome
+ }
+ return ReportFailed
+}
diff --git a/callscore/report_test.go b/callscore/report_test.go
new file mode 100644
index 0000000..c736b74
--- /dev/null
+++ b/callscore/report_test.go
@@ -0,0 +1,204 @@
+package callscore
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+
+ "amadan.net/oficina/calls/protocol"
+)
+
+const testToken = "0123456789abcdef0123456789abcdef"
+
+// reportServer answers /m/room/{quality,problem} with status (and body),
+// recording what it was sent.
+type reportServer struct {
+ *httptest.Server
+ mu sync.Mutex
+ paths []string
+ tokens []string
+ origins []string
+ bodies []string
+}
+
+func newReportServer(t *testing.T, status int, body string) *reportServer {
+ s := &reportServer{}
+ s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ b, _ := io.ReadAll(req.Body)
+ s.mu.Lock()
+ s.paths = append(s.paths, req.Method+" "+req.URL.Path)
+ s.tokens = append(s.tokens, req.Header.Get("X-Calls-Report"))
+ s.origins = append(s.origins, req.Header.Get("Origin"))
+ s.bodies = append(s.bodies, string(b))
+ s.mu.Unlock()
+ w.WriteHeader(status)
+ _, _ = io.WriteString(w, body)
+ }))
+ t.Cleanup(s.Close)
+ return s
+}
+
+// helloed is a room that has been given token by a hello, as receive
+// would record it.
+func helloed(t *testing.T, srv *httptest.Server, token string) *Room {
+ r, err := New(srv.URL, "room", srv.Client(), func(Event) {})
+ if err != nil {
+ t.Fatal(err)
+ }
+ r.started = true
+ // Already joined, so the hello is a resume and sends no join.
+ r.state.Peer = "self"
+ if err := r.receive(protocol.Message{Type: protocol.TypeHello, Peer: "self", Report: token}); err != nil {
+ t.Fatal(err)
+ }
+ return r
+}
+
+func TestHelloReportTokenIsKeptLatestWins(t *testing.T) {
+ srv := newReportServer(t, http.StatusAccepted, "")
+ r := helloed(t, srv.Server, testToken)
+ defer r.Close()
+ if got := r.reportTokenNow(); got != testToken {
+ t.Fatalf("token = %q", got)
+ }
+ // A resumed hello without one (an older hub) keeps it; a new one wins.
+ _ = r.receive(protocol.Message{Type: protocol.TypeHello, Peer: "self"})
+ if got := r.reportTokenNow(); got != testToken {
+ t.Fatalf("token after a hello without one = %q", got)
+ }
+ next := strings.Repeat("f", 32)
+ _ = r.receive(protocol.Message{Type: protocol.TypeHello, Peer: "self", Report: next})
+ if got := r.reportTokenNow(); got != next {
+ t.Fatalf("token after a new hello = %q", got)
+ }
+}
+
+func TestSendQualityOutcomes(t *testing.T) {
+ report := []byte(`{"v":1,"seconds":30,"client":{"app":"mac"}}`)
+ for _, tc := range []struct {
+ status int
+ stop bool
+ outcome ReportOutcome
+ }{
+ {http.StatusAccepted, false, ReportSent},
+ {http.StatusNoContent, true, ReportSent},
+ {http.StatusNotFound, false, ReportGone},
+ {http.StatusTooManyRequests, false, ReportTooMany},
+ {http.StatusBadRequest, false, ReportFailed},
+ {http.StatusRequestEntityTooLarge, false, ReportFailed},
+ {http.StatusInternalServerError, false, ReportFailed},
+ } {
+ t.Run(fmt.Sprint(tc.status), func(t *testing.T) {
+ srv := newReportServer(t, tc.status, "")
+ r := helloed(t, srv.Server, testToken)
+ // After Close: the final report goes at leave, when the room
+ // is already shut.
+ r.Close()
+ stop, err := r.SendQuality(report)
+ if stop != tc.stop || OutcomeOf(err) != tc.outcome {
+ t.Fatalf("stop=%v err=%v, want stop=%v %s", stop, err, tc.stop, tc.outcome)
+ }
+ srv.mu.Lock()
+ defer srv.mu.Unlock()
+ if len(srv.paths) != 1 || srv.paths[0] != "POST /m/room/quality" || srv.tokens[0] != testToken ||
+ srv.origins[0] != srv.URL || srv.bodies[0] != string(report) {
+ t.Fatalf("request: %v %v %v %v", srv.paths, srv.tokens, srv.origins, srv.bodies)
+ }
+ })
+ }
+}
+
+func TestSendProblemAnswersTheReference(t *testing.T) {
+ report := []byte(`{"note":"it growled","diagnostics":"peer abc123","client":{"app":"mac"}}`)
+ srv := newReportServer(t, http.StatusOK, `{"ref":"K7Q2M9PD"}`)
+ r := helloed(t, srv.Server, testToken)
+ r.Leave()
+ ref, err := r.SendProblem(report)
+ if err != nil || ref != "K7Q2M9PD" {
+ t.Fatalf("ref=%q err=%v", ref, err)
+ }
+ srv.mu.Lock()
+ defer srv.mu.Unlock()
+ last := len(srv.paths) - 1
+ if srv.paths[last] != "POST /m/room/problem" || srv.tokens[last] != testToken || srv.bodies[last] != string(report) {
+ t.Fatalf("request: %v %v", srv.paths, srv.tokens)
+ }
+}
+
+func TestSendProblemRefusals(t *testing.T) {
+ report := []byte(`{"note":"","diagnostics":"","client":{"app":"mac"}}`)
+ for _, tc := range []struct {
+ status int
+ body string
+ outcome ReportOutcome
+ }{
+ {http.StatusTooManyRequests, `{"error":"rate"}`, ReportTooMany},
+ {http.StatusNotFound, `{"error":"not-found"}`, ReportGone},
+ {http.StatusBadRequest, `{"error":"bad-request"}`, ReportFailed},
+ // A 200 that is not a reference is not shown as one.
+ {http.StatusOK, `{"ref":"<script>"}`, ReportFailed},
+ {http.StatusOK, `not json`, ReportFailed},
+ } {
+ t.Run(fmt.Sprint(tc.status, tc.body), func(t *testing.T) {
+ srv := newReportServer(t, tc.status, tc.body)
+ r := helloed(t, srv.Server, testToken)
+ defer r.Close()
+ ref, err := r.SendProblem(report)
+ if ref != "" || OutcomeOf(err) != tc.outcome {
+ t.Fatalf("ref=%q err=%v, want %s", ref, err, tc.outcome)
+ }
+ })
+ }
+}
+
+func TestReportsNeedATokenAndABoundedBody(t *testing.T) {
+ srv := newReportServer(t, http.StatusAccepted, "")
+ // No token: a server from before §15.26 never gave one. Nothing is sent.
+ r := helloed(t, srv.Server, "")
+ defer r.Close()
+ if _, err := r.SendQuality([]byte(`{}`)); OutcomeOf(err) != ReportGone {
+ t.Fatalf("no token: %v", err)
+ }
+ if _, err := r.SendProblem([]byte(`{}`)); OutcomeOf(err) != ReportGone {
+ t.Fatalf("no token: %v", err)
+ }
+ r.mu.Lock()
+ r.report = testToken
+ r.mu.Unlock()
+ big, _ := json.Marshal(map[string]string{"x": strings.Repeat("a", protocol.MaxQualityBody)})
+ if _, err := r.SendQuality(big); OutcomeOf(err) != ReportFailed {
+ t.Fatalf("oversized quality: %v", err)
+ }
+ if _, err := r.SendQuality([]byte(`{not json`)); OutcomeOf(err) != ReportFailed {
+ t.Fatalf("invalid quality: %v", err)
+ }
+ huge, _ := json.Marshal(map[string]string{"x": strings.Repeat("a", protocol.MaxProblemBody)})
+ if _, err := r.SendProblem(huge); OutcomeOf(err) != ReportFailed {
+ t.Fatalf("oversized problem: %v", err)
+ }
+ srv.mu.Lock()
+ defer srv.mu.Unlock()
+ if len(srv.paths) != 0 {
+ t.Fatalf("sent anyway: %v", srv.paths)
+ }
+}
+
+func TestReportNeverFollowsARedirect(t *testing.T) {
+ reached := false
+ foreign := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true }))
+ defer foreign.Close()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ http.Redirect(w, req, foreign.URL, http.StatusTemporaryRedirect)
+ }))
+ defer srv.Close()
+ r := helloed(t, srv, testToken)
+ defer r.Close()
+ if _, err := r.SendProblem([]byte(`{}`)); OutcomeOf(err) != ReportFailed || reached {
+ t.Fatalf("err=%v reached=%v", err, reached)
+ }
+}
diff --git a/callscore/room.go b/callscore/room.go
index e9950fb..f230f3f 100644
--- a/callscore/room.go
+++ b/callscore/room.go
@@ -67,6 +67,10 @@ type Room struct {
// hears (it predates §15.24), so reports go without the key, and the
// people on new clients fall back to their own evidence about us.
plainHearing bool
+ // report is the latest hello's report token (spec §15.26): a resumed
+ // hello carries the same one, a fresh participation a new one. Kept
+ // past Close, for the after-call window (report.go).
+ report string
}
func New(origin, room string, authenticated *http.Client, callback func(Event)) (*Room, error) {
@@ -464,6 +468,9 @@ func (r *Room) receive(m protocol.Message) error {
join = r.state.Peer == ""
r.state.Peer = m.Peer
r.state.Boot = m.Boot
+ if m.Report != "" {
+ r.report = m.Report
+ }
if join {
r.state.Phase = "joining"
}
diff --git a/internal/callstest/browser_test.go b/internal/callstest/browser_test.go
index de032fd..974da52 100644
--- a/internal/callstest/browser_test.go
+++ b/internal/callstest/browser_test.go
@@ -124,6 +124,7 @@ var scenarios = []string{
"playfallback",
"autoplay",
"connecting",
+ "quality",
"scriptless",
}
@@ -264,6 +265,7 @@ var skipped = map[string]string{
func TestCallInARealBrowser(t *testing.T) {
rig := newBrowserRig(t)
sqid, token := rig.createMeeting(t)
+ rig.sqid, rig.token = sqid, token
for _, name := range scenarios {
t.Run(name, func(t *testing.T) {
@@ -284,6 +286,7 @@ func TestCallInARealBrowser(t *testing.T) {
// a tab that did not manage to send `leave` would otherwise
// hold this session's one peer slot into the next scenario.
rig.restart(t)
+ rig.reportsFrom = rig.reports.size()
rig.run(t, name, sqid, token)
})
}
@@ -312,6 +315,14 @@ type browserRig struct {
// reports is every quality and problem report line the app has
// written (spec §15.26), as the deployed app writes them: JSON.
reports reportLog
+
+ // The meeting and its link, which a report log must never contain.
+ sqid string
+ token string
+ // reportsFrom is where the current scenario's report lines start:
+ // the log is the rig's, and every scenario before this one wrote to
+ // it too.
+ reportsFrom int
}
// reportLog is a goroutine-safe sink for the app's report logger.
@@ -333,10 +344,26 @@ func (l *reportLog) raw() string {
return l.buf.String()
}
+// size is how much has been written, for linesFrom.
+func (l *reportLog) size() int {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ return l.buf.Len()
+}
+
// lines are the report lines with msg, decoded.
func (l *reportLog) lines(msg string) []map[string]any {
+ return l.linesFrom(0, msg)
+}
+
+// linesFrom are the report lines with msg written at or after offset.
+func (l *reportLog) linesFrom(offset int, msg string) []map[string]any {
+ raw := l.raw()
+ if offset > len(raw) {
+ offset = len(raw)
+ }
var out []map[string]any
- for _, line := range strings.Split(l.raw(), "\n") {
+ for _, line := range strings.Split(raw[offset:], "\n") {
var m map[string]any
if json.Unmarshal([]byte(line), &m) == nil && m["msg"] == msg {
out = append(out, m)
@@ -735,12 +762,138 @@ func (rig *browserRig) control(t *testing.T, verb string) string {
}
time.Sleep(50 * time.Millisecond)
}
+ case strings.HasPrefix(verb, "quality-check "):
+ return rig.qualityCheck(strings.Fields(strings.TrimPrefix(verb, "quality-check ")))
+ case strings.HasPrefix(verb, "problem-check "):
+ fields := strings.Fields(strings.TrimPrefix(verb, "problem-check "))
+ if len(fields) < 1 {
+ return "FAIL problem-check needs a reference"
+ }
+ return rig.problemCheck(fields[0], fields[1:])
+ case verb == "quality-final":
+ for _, l := range rig.reports.linesFrom(rig.reportsFrom, "calls: quality") {
+ if l["final"] == true {
+ return "ok"
+ }
+ }
+ return "FAIL no quality report with final:true"
default:
t.Fatalf("unknown control verb from the browser: %q", verb)
return ""
}
}
+// qualityCheck answers the quality scenario (spec §15.26): the two
+// browsers' automatic reports reached the log as `calls: quality` lines
+// under one call tag and two different peer tags, each naming the other
+// by its tag, and the log as a whole names nobody. `peers` is the two
+// browsers' peer ids, which the log must not contain either. The answer
+// is "ok", or "FAIL" and why, for call.mjs to print as its check.
+func (rig *browserRig) qualityCheck(peers []string) string {
+ lines := rig.reports.linesFrom(rig.reportsFrom, "calls: quality")
+ if len(lines) < 2 {
+ return fmt.Sprintf("FAIL %d calls: quality lines, want at least 2", len(lines))
+ }
+ calls := map[string]bool{}
+ tags := map[string]bool{}
+ named := map[string]bool{}
+ for _, l := range lines {
+ call, _ := l["call"].(string)
+ peer, _ := l["peer"].(string)
+ if call == "" || peer == "" {
+ return fmt.Sprintf("FAIL a quality line without its tags: %v", l)
+ }
+ if l["app"] != "web" {
+ return fmt.Sprintf("FAIL a quality line from app %v", l["app"])
+ }
+ calls[call] = true
+ tags[peer] = true
+ report, _ := l["report"].(map[string]any)
+ ps, _ := report["peers"].([]any)
+ for _, p := range ps {
+ if m, ok := p.(map[string]any); ok {
+ if tag, ok := m["peer"].(string); ok {
+ named[tag] = true
+ }
+ }
+ }
+ }
+ if len(calls) != 1 {
+ return fmt.Sprintf("FAIL quality lines under %d call tags, want one: %v", len(calls), calls)
+ }
+ if len(tags) != 2 {
+ return fmt.Sprintf("FAIL quality lines under %d peer tags, want two: %v", len(tags), tags)
+ }
+ for tag := range tags {
+ if !named[tag] {
+ return fmt.Sprintf("FAIL no report names peer tag %s as a remote peer (named: %v)", tag, named)
+ }
+ }
+ if why := rig.leaks(peers); why != "" {
+ return "FAIL " + why
+ }
+ return "ok"
+}
+
+// problemCheck answers "Report a problem": a `calls: problem report`
+// line under the reference the sheet showed, at least one diagnostics
+// line under it, and still nobody named anywhere in the log.
+func (rig *browserRig) problemCheck(ref string, peers []string) string {
+ var head map[string]any
+ for _, l := range rig.reports.linesFrom(rig.reportsFrom, "calls: problem report") {
+ if l["ref"] == ref {
+ head = l
+ }
+ }
+ if head == nil {
+ return "FAIL no calls: problem report line with ref " + ref
+ }
+ parts := 0
+ for _, l := range rig.reports.linesFrom(rig.reportsFrom, "calls: problem report diagnostics") {
+ if l["ref"] == ref {
+ parts++
+ }
+ }
+ if parts == 0 {
+ return "FAIL no calls: problem report diagnostics line with ref " + ref
+ }
+ if why := rig.leaks(peers); why != "" {
+ return "FAIL " + why
+ }
+ return fmt.Sprintf("ok (note %q, %d diagnostics part(s), live %v)", head["note"], parts, head["live"])
+}
+
+// leaks is what the report log must never hold (spec §15.26): the two
+// members' addresses and display names, the meeting's sqid and link,
+// and any peer id of the call, in full or as the six characters the
+// diagnostics name people by. "" when it holds none of them.
+func (rig *browserRig) leaks(peers []string) string {
+ raw := rig.reports.raw()
+ forbidden := []string{memberA, memberB, rig.sqid, rig.token}
+ for _, p := range peers {
+ forbidden = append(forbidden, p)
+ // The six-character prefix only when it has a letter in it: six
+ // decimal digits are as likely to be a packet count as a person.
+ if len(p) >= 6 && strings.ContainsAny(p[:6], "abcdef") {
+ forbidden = append(forbidden, p[:6])
+ }
+ }
+ for _, f := range forbidden {
+ if f != "" && strings.Contains(raw, f) {
+ return fmt.Sprintf("the report log contains %q", f)
+ }
+ }
+ // A display name is the address up to the "@" (see memberA); as a
+ // word, so a random tag that happens to spell it is not a finding.
+ for _, addr := range []string{memberA, memberB} {
+ name := addr[:strings.Index(addr, "@")]
+ if regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(name) + `\b`).MatchString(raw) {
+ return fmt.Sprintf("the report log contains the name %q", name)
+ }
+ }
+ return ""
+}
+
// repoRootForBrowser finds the repository from this file's own path,
// never from the working directory: go test runs in the package
// directory and the node script is addressed from the root.
diff --git a/internal/callstest/native_browser_test.go b/internal/callstest/native_browser_test.go
index efb643f..01a4e08 100644
--- a/internal/callstest/native_browser_test.go
+++ b/internal/callstest/native_browser_test.go
@@ -83,7 +83,9 @@ func nativeMacCallsBrowser(t *testing.T, guest, chat bool) {
fixture["chatSnapshot"] = filepath.Join(root, "native/build/chat-incoming.png")
}
input, _ := json.Marshal(fixture)
- cmd.Env = append(os.Environ(), "CALLS_COOKIE_NAME="+rig.cookieName, "CALLS_COOKIE_A="+rig.cookieA, "CALLS_NATIVE_INPUT="+string(input))
+ // CALLS_QUALITY_INTERVAL: the app's quality reports every two seconds
+ // rather than thirty (spec §15.26), so the call holds several.
+ cmd.Env = append(os.Environ(), "CALLS_COOKIE_NAME="+rig.cookieName, "CALLS_COOKIE_A="+rig.cookieA, "CALLS_NATIVE_INPUT="+string(input), "CALLS_QUALITY_INTERVAL=2")
out, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
@@ -99,6 +101,46 @@ func nativeMacCallsBrowser(t *testing.T, guest, chat bool) {
if err := cmd.Wait(); err != nil {
t.Fatal(err)
}
+ assertNativeQualityReports(t, rig)
+}
+
+// assertNativeQualityReports is spec §15.26 from the app: it sent quality
+// reports while it was in the call, the server took them (a refused one is
+// never logged), and they carry what the app measured of the browser's
+// connection — its audio arriving, and the path it took.
+func assertNativeQualityReports(t *testing.T, rig *browserRig) {
+ t.Helper()
+ var mac []map[string]any
+ for _, line := range rig.reports.lines("calls: quality") {
+ if line["app"] == "mac" {
+ mac = append(mac, line)
+ }
+ }
+ if len(mac) == 0 {
+ t.Fatalf("no quality report from the app; the report log holds:\n%s", rig.reports.raw())
+ }
+ measured := false
+ for _, line := range mac {
+ if line["os"] != "macos" || line["osVersion"] == nil {
+ t.Fatalf("the app's report does not say what it runs on: %v", line)
+ }
+ report, _ := line["report"].(map[string]any)
+ peers, _ := report["peers"].([]any)
+ for _, p := range peers {
+ peer, _ := p.(map[string]any)
+ in, _ := peer["in"].(map[string]any)
+ audio, _ := in["audio"].(map[string]any)
+ path, _ := peer["path"].(map[string]any)
+ if packets, _ := audio["packets"].(float64); packets > 0 && path["protocol"] != nil {
+ measured = true
+ }
+ }
+ }
+ if !measured {
+ t.Fatalf("no report from the app measured the browser's audio and path: %v", mac)
+ }
+ b, _ := json.Marshal(mac[len(mac)-1])
+ t.Logf("PASS %d quality reports from the app; the last: %s", len(mac), b)
}
// TestNativeMacScreenShare is §5.4 between two apps, with no browser in
diff --git a/native/apple/Sources/CallsInterop/Interop.swift b/native/apple/Sources/CallsInterop/Interop.swift
index af6e902..a4ecd42 100644
--- a/native/apple/Sources/CallsInterop/Interop.swift
+++ b/native/apple/Sources/CallsInterop/Interop.swift
@@ -68,6 +68,11 @@ struct Input: Decodable {
if ProcessInfo.processInfo.environment["CALLS_DECODER_WEDGE"] == "1" { DecoderWedge.enabled = true }
let call = Call(playAudio: false, audioInput: SyntheticVoice())
if ProcessInfo.processInfo.environment["CALLS_SCREEN_WATCHDOG"] == "0" { Call.screenWatchdogEnabled = false }
+ // Quality reports every N seconds rather than thirty (spec §15.26), so
+ // the gate sees one inside its call. A number of seconds, at least one.
+ if let seconds = ProcessInfo.processInfo.environment["CALLS_QUALITY_INTERVAL"].flatMap(Double.init), seconds >= 1 {
+ Call.qualityInterval = seconds
+ }
// WebRTC's own log, filtered to the video encoder, into a file: for
// diagnosing the gate's screen scenarios by hand. Never credentials:
// the filter keeps encoder lines only.
diff --git a/native/apple/Sources/CallsKit/Audio.swift b/native/apple/Sources/CallsKit/Audio.swift
index 37bb2d7..7951977 100644
--- a/native/apple/Sources/CallsKit/Audio.swift
+++ b/native/apple/Sources/CallsKit/Audio.swift
@@ -414,6 +414,21 @@ public struct AudioDevice: Identifiable, Hashable, Sendable {
return k == k.rounded() ? "\(Int(k)) kHz" : String(format: "%.1f kHz", k)
}
+ /// The kinds of microphone and speaker the call is on, for the quality
+ /// report (spec §15.26): their CoreAudio transports, never their names.
+ /// "unknown" before the call has audio.
+ func deviceKinds() -> (input: String, output: String) {
+ guard let inventory = lastInventory else { return ("unknown", "unknown") }
+ let input =
+ engineInput ?? (currentInput == AudioRouteRules.defaultID || currentInput == nil ? inventory.defaultInput : currentInput)
+ let output = currentOutput == AudioRouteRules.defaultID || currentOutput == nil ? inventory.defaultOutput : currentOutput
+ func kind(_ uid: String?) -> String {
+ guard let uid else { return "none" }
+ return DeviceKind.mac(transport: inventory.devices.first { $0.uid == uid }?.transport)
+ }
+ return (kind(input), kind(output))
+ }
+
/// For "Copy audio diagnostics": the choices, what they resolve to,
/// and everything CoreAudio has.
public func diagnostics() -> [String] {
diff --git a/native/apple/Sources/CallsKit/AudioDevices.swift b/native/apple/Sources/CallsKit/AudioDevices.swift
index af67e08..e0fa472 100644
--- a/native/apple/Sources/CallsKit/AudioDevices.swift
+++ b/native/apple/Sources/CallsKit/AudioDevices.swift
@@ -182,6 +182,8 @@
case kAudioDeviceTransportTypeThunderbolt: return "thunderbolt"
case kAudioDeviceTransportTypeHDMI, kAudioDeviceTransportTypeDisplayPort: return "display"
case kAudioDeviceTransportTypeAirPlay: return "airplay"
+ case kAudioDeviceTransportTypeContinuityCaptureWired, kAudioDeviceTransportTypeContinuityCaptureWireless:
+ return "continuity"
default: return String(format: "0x%08x", type)
}
}
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift
index fe840b0..98d154e 100644
--- a/native/apple/Sources/CallsKit/Call.swift
+++ b/native/apple/Sources/CallsKit/Call.swift
@@ -1,5 +1,8 @@
import Combine
import Foundation
+#if os(iOS)
+ import AVFoundation
+#endif
// Go serializes this API with a mutex; Objective-C bindings carry no Sendable annotation.
@preconcurrency import CallsBridge
import LiveKitWebRTC
@@ -101,6 +104,30 @@ private final class Events: NSObject, BridgeListenerProtocol {
private var heardFrom: [String: (epoch: Int64, heard: Bool)] = [:]
private var ackedBy: [String: Int64] = [:]
private var reportedHearing: [String] = []
+ /// Quality reports (spec §15.26): the notices tallied over the window,
+ /// the running totals the last report was taken from, the loop that
+ /// sends one every `qualityInterval`, and whether the server has said to
+ /// stop (its kill switch) or the final one has gone.
+ private var qualityTally = QualityEventTally()
+ private var qualityBuilder = QualityReportBuilder()
+ private var qualityWatch: Task<Void, Never>?
+ private var qualityStopped = false
+ private var qualityFinalSent = false
+ /// Thirty seconds, as the ruling has it; the gate shortens it
+ /// (CALLS_QUALITY_INTERVAL, read by the interop harness).
+ @_spi(Fixtures) public static var qualityInterval: Double = 30
+ /// "Report a problem" (spec §15.26): the sheet, while it is open.
+ @Published public var problemReport: ProblemReportModel?
+ /// Until when the last call can still be reported: ten minutes after it
+ /// ended, which is as long as the server keeps its report token. The
+ /// closed bridge client is kept that long — its transport and token
+ /// outlive the room — with the call's diagnostics as they were at the end.
+ @Published public private(set) var reportableUntil: Date?
+ private var reportClient: BridgeClient?
+ private var reportDiagnostics = ""
+ static let reportWindow: TimeInterval = 600
+ /// A fixture call answers a report itself, for the UI test of the sheet.
+ private var fixtureReports = false
/// 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.
@@ -215,18 +242,59 @@ private final class Events: NSObject, BridgeListenerProtocol {
}
public func audioDiagnosticsText() -> String {
+ diagnosticsText { self.name(of: $0) }
+ }
+
+ /// "Copy call diagnostics" as "Report a problem" sends it (spec §15.26):
+ /// everyone in it named "peer abc123", the first six characters of their
+ /// peer id, rather than by name. The server turns those into its tags.
+ /// Device names stay, as the spec says.
+ public func anonymousDiagnosticsText() -> String {
+ diagnosticsText(label: Self.anonymousLabel)
+ }
+
+ nonisolated static func anonymousLabel(_ peer: String) -> String { "peer " + peer.prefix(6) }
+
+ private func diagnosticsText(label: (String) -> String) -> String {
AudioDiagnosticsReport.make(
- context: "a call's details", sections: audioDiagnosticSections() + screenDiagnosticSections())
+ context: "a call's details",
+ sections: audioDiagnosticSections() + peopleDiagnosticSections(label: label)
+ + screenDiagnosticSections(label: label))
+ }
+
+ /// Everyone else in the call, as this app sees them: what they report
+ /// about their own media and hearing, and what this app is saying
+ /// about them.
+ private func peopleDiagnosticSections(label: (String) -> String) -> [(String, [String])] {
+ guard let state else { return [] }
+ var lines: [String] = []
+ for person in state.peers where person.peer != state.peer {
+ var facts = ["connection \(person.gen ?? 0)"]
+ if let media = person.media {
+ facts.append(media.muted ? "muted" : "unmuted")
+ facts.append(media.cameraOff ? "camera off" : "camera on")
+ if let hearing = media.hearing { facts.append(hearing.contains(state.peer) ? "hears you" : "does not hear you yet") }
+ if media.noSoundFrom?.contains(state.peer) == true { facts.append("hears no sound from you") }
+ if media.audioBlocked == true { facts.append("their browser blocked sound") }
+ } else {
+ facts.append("no media report")
+ }
+ if levels.noSoundFrom.contains(person.peer) { facts.append("no sound from them here") }
+ if reconnectingPeers.contains(person.peer) { facts.append("reconnecting") }
+ if connectingPeers.contains(person.peer) { facts.append("connecting, can't hear you yet") }
+ lines.append("\(label(person.peer)): " + facts.joined(separator: ", "))
+ }
+ return lines.isEmpty ? [] : [("People", lines)]
}
/// The screen's part of "Copy call diagnostics": how it is leaving, what
/// the watchdog has done, and whose screens have stalled here.
- private func screenDiagnosticSections() -> [(String, [String])] {
+ private func screenDiagnosticSections(label name: (String) -> String) -> [(String, [String])] {
var lines: [String] = []
if sharingScreen {
lines.append("Sharing (\(screenMode.rawValue))")
for (peer, report) in screenReports.sorted(by: { $0.key < $1.key }) {
- lines.append("to \(name(of: peer)): \(report.summary)")
+ lines.append("to \(name(peer)): \(report.summary)")
lines.append(" frames captured \(report.framesCaptured), encoded \(report.framesEncoded), sent \(report.framesSent), keyframes \(report.keyFramesEncoded), asked for \(report.keyframeRequests), limitation \(report.limitation.isEmpty ? "none" : report.limitation)")
}
if let capture = media.screenCaptureCounts {
@@ -238,7 +306,7 @@ private final class Events: NSObject, BridgeListenerProtocol {
}
if sharingScreen {
for peer in (state?.peers ?? []).map(\.peer) where screenWatchdog.escalation(of: peer) != .none {
- lines.append("recovering \(name(of: peer))'s view: \(screenWatchdog.escalation(of: peer))")
+ lines.append("recovering \(name(peer))'s view: \(screenWatchdog.escalation(of: peer))")
}
lines.append(
"recoveries: \(screenWatchdog.keyframes) keyframes, \(screenWatchdog.encoderRecoveries) fresh encoders, \(screenWatchdog.reAdds) re-adds, \(screenWatchdog.rebuilds) rebuilds, \(screenWatchdog.reshares) shares again, \(screenWatchdog.failures) given up")
@@ -246,10 +314,10 @@ private final class Events: NSObject, BridgeListenerProtocol {
// The receiver's half: every screen arriving here, as its statistics
// have it, whether or not it has stalled.
for (peer, report) in screenReceived.sorted(by: { $0.key < $1.key }) {
- lines.append("from \(name(of: peer)): \(report.summary)")
+ lines.append("from \(name(peer)): \(report.summary)")
}
if !reportedStalled.isEmpty {
- lines.append("stalled here: " + reportedStalled.map { name(of: $0) }.joined(separator: ", "))
+ lines.append("stalled here: " + reportedStalled.map { name($0) }.joined(separator: ", "))
}
if screenReattaches > 0 { lines.append("pictures attached again here: \(screenReattaches)") }
let log = AudioDiagnosticsStore.screen.lines(last: AudioDiagnosticsStore.lineLimit)
@@ -316,6 +384,7 @@ private final class Events: NSObject, BridgeListenerProtocol {
media.audio.reduceNoise = reduceNoise
audioProcessing = media.audio.report()
watchLevels()
+ startQualityWatch()
KnockNotice.requestPermission()
// Asking Apple at join, once there is a session to register the
// answer against. Apple replies on its own schedule, at the app
@@ -351,6 +420,8 @@ private final class Events: NSObject, BridgeListenerProtocol {
audioHealth = nil
audioProcessing = nil
endLevelWatch()
+ qualityWatch?.cancel()
+ qualityWatch = nil
currentCamera = nil
cameraStatus = nil
cameraNote = ""
@@ -362,11 +433,23 @@ private final class Events: NSObject, BridgeListenerProtocol {
if let old { DispatchQueue.global().async { old.close() } }
}
public func leave() async {
+ // The final quality report reads the connections' statistics, so it is
+ // built before they close, and sent beside the leave rather than after
+ // it: quitting gives the two together three seconds.
+ let final = await finalQualityReport()
let old = bridge
+ if let old { keepForReports(old) }
bridge = nil
close()
status = "You left the meeting"
- if let old { await Task.detached { old.leave() }.value }
+ if let old {
+ await Task.detached {
+ await withTaskGroup(of: Void.self) { group in
+ if let final { group.addTask { _ = old.sendQuality(final) } }
+ group.addTask { old.leave() }
+ }
+ }.value
+ }
}
public func mute() {
muted.toggle()
@@ -676,6 +759,7 @@ private final class Events: NSObject, BridgeListenerProtocol {
now: now)
}
watchReconnect(now: now, people: people)
+ observeQuality(now: now, state: state)
levels.settle(present: present)
for id in Array(audioBytes.keys) where !present.contains(id) {
audioBytes.removeValue(forKey: id)
@@ -805,6 +889,7 @@ private final class Events: NSObject, BridgeListenerProtocol {
"standing": "You no longer have access", "expired": "Your invitation has expired",
"signin": "Sign in again to join", "unavailable": "This meeting is unavailable",
][state.reason ?? ""] ?? "The call has ended"
+ endedForReports()
media.close()
remoteTracks = [:]
screens = []
@@ -957,9 +1042,11 @@ private final class Events: NSObject, BridgeListenerProtocol {
encoded: $0.framesEncoded, sent: $0.framesSent, keyframes: $0.keyFramesEncoded,
bandwidthLimited: $0.limitation == "bandwidth")
}
+ let failuresBefore = screenWatchdog.failures
var actions = screenWatchdog.observe(
at: ProcessInfo.processInfo.systemUptime, capture: capture, sending: sending, stalledBy: stalledBy)
if !Self.screenWatchdogEnabled { actions = [] }
+ countScreenSteps(actions, failed: screenWatchdog.failures - failuresBefore, stalledBy: stalledBy)
for action in actions {
switch action {
case .restartCapture:
@@ -1077,6 +1164,7 @@ private final class Events: NSObject, BridgeListenerProtocol {
private func reattachScreen(_ peer: String) {
guard let track = remoteScreens[peer] else { return }
screenReattaches += 1
+ qualityTally.count(.reattach, for: peer)
remoteScreens.removeValue(forKey: peer)
Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 150_000_000)
@@ -1214,6 +1302,191 @@ private final class Events: NSObject, BridgeListenerProtocol {
}
}
+// ---------------------------------------------------------------
+// Quality and problem reports (§15.26)
+// ---------------------------------------------------------------
+
+extension Call {
+ /// Sends a quality report every `qualityInterval` while admitted: the
+ /// window starts at admission, and each report covers the time since
+ /// the last. Read and built on the main actor, as the statistics reads
+ /// for the meters are; sent off it, because the bridge's send is a
+ /// request.
+ fileprivate func startQualityWatch() {
+ qualityWatch?.cancel()
+ qualityTally = QualityEventTally()
+ qualityBuilder = QualityReportBuilder()
+ qualityStopped = false
+ qualityFinalSent = false
+ qualityWatch = Task { @MainActor [weak self] in
+ while !Task.isCancelled {
+ try? await Task.sleep(nanoseconds: 1_000_000_000)
+ guard let self, !Task.isCancelled, let client = self.bridge else { return }
+ let now = ProcessInfo.processInfo.systemUptime
+ guard self.qualityBuilder.started else {
+ if self.admittedForReports {
+ self.qualityBuilder.start(at: now)
+ self.qualityTally = QualityEventTally()
+ }
+ continue
+ }
+ guard self.qualityBuilder.elapsed(at: now) >= Self.qualityInterval else { continue }
+ guard self.admittedForReports, !self.qualityStopped, QualityReporting.enabled() else {
+ // Off, stopped, or between participations: the window moves on
+ // with nothing sent, so a report never covers time it did not see.
+ self.qualityBuilder.start(at: now)
+ self.qualityTally = QualityEventTally()
+ continue
+ }
+ let samples = await self.built?.qualitySamples() ?? [:]
+ guard !Task.isCancelled, self.bridge === client else { return }
+ guard let body = self.qualityReport(at: ProcessInfo.processInfo.systemUptime, samples: samples, final: false)
+ else { continue }
+ Task.detached { [weak self] in
+ let answer = client.sendQuality(body)
+ if answer == "stop" {
+ await MainActor.run { [weak self] in
+ self?.qualityStopped = true
+ CallLog.info("quality reports: the server asked for no more this call")
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private var admittedForReports: Bool {
+ guard let phase = state?.phase else { return false }
+ return phase == "admitted" || phase == "reconnecting"
+ }
+
+ private func qualityReport(at now: Double, samples: [String: QualityPeerSample], final: Bool) -> String? {
+ qualityBuilder.build(
+ at: now, samples: samples, events: &qualityTally, client: QualityClientInfo.current(),
+ devices: qualityDevices(), final: final
+ ).encoded()
+ }
+
+ /// The report sent at leave, built while the connections are still up;
+ /// nil when there is none to send.
+ fileprivate func finalQualityReport() async -> String? {
+ guard let client = bridge, qualityBuilder.started, !qualityStopped, !qualityFinalSent,
+ QualityReporting.enabled()
+ else { return nil }
+ let samples = await built?.qualitySamples() ?? [:]
+ guard bridge === client, !qualityFinalSent else { return nil }
+ qualityFinalSent = true
+ return qualityReport(at: ProcessInfo.processInfo.systemUptime, samples: samples, final: true)
+ }
+
+ /// The call ended under this app (ended, removed, open elsewhere): the
+ /// final report goes now, with the notices but without statistics — the
+ /// connections are closing as it is built — and the call stays
+ /// reportable from here.
+ fileprivate func endedForReports() {
+ guard let client = bridge else { return }
+ keepForReports(client)
+ guard qualityBuilder.started, !qualityStopped, !qualityFinalSent, QualityReporting.enabled() else { return }
+ qualityFinalSent = true
+ guard let body = qualityReport(at: ProcessInfo.processInfo.systemUptime, samples: [:], final: true) else { return }
+ Task.detached { _ = client.sendQuality(body) }
+ }
+
+ /// The kinds of microphone and speaker in use. Never their names.
+ private func qualityDevices() -> QualityDevices? {
+ #if os(macOS)
+ let kinds = audio.deviceKinds()
+ return QualityDevices(input: kinds.input, output: kinds.output)
+ #else
+ let route = AVAudioSession.sharedInstance().currentRoute
+ return QualityDevices(
+ input: DeviceKind.ios(port: route.inputs.first?.portType.rawValue),
+ output: DeviceKind.ios(port: route.outputs.first?.portType.rawValue))
+ #endif
+ }
+
+ /// One look by the level watch, tallied for the window's events.
+ fileprivate func observeQuality(now: Double, state: RoomState) {
+ guard qualityBuilder.started else { return }
+ let me = state.peer
+ var fromUs = Set<String>()
+ var blocked = Set<String>()
+ for person in state.peers where person.peer != me {
+ if person.media?.noSoundFrom?.contains(me) == true { fromUs.insert(person.peer) }
+ if person.media?.audioBlocked == true { blocked.insert(person.peer) }
+ }
+ qualityTally.observe(
+ at: now, call: reconnecting, reconnecting: reconnectingPeers, cantHear: connectingPeers,
+ noSound: Set(levels.noSoundFrom), noSoundFromUs: fromUs, audioBlocked: blocked,
+ screenStalled: Set(reportedStalled))
+ }
+
+ /// The sharer's watchdog's steps, toward the peers they were for.
+ fileprivate func countScreenSteps(_ actions: [ScreenRecovery], failed: Int, stalledBy: Set<String>) {
+ let me = state?.peer ?? ""
+ for action in actions {
+ switch action {
+ case .keyframe(let peer): qualityTally.count(.keyframe, for: peer)
+ case .reAdd(let peer), .rebuild(let peer): qualityTally.count(.rebuild, for: peer)
+ case .reshare:
+ for person in state?.peers ?? [] where person.peer != me { qualityTally.count(.reshare, for: person.peer) }
+ case .restartCapture, .recreateEncoder: break
+ }
+ }
+ guard failed > 0 else { return }
+ for peer in stalledBy where screenWatchdog.escalation(of: peer) == .failed {
+ qualityTally.count(.failure, for: peer)
+ }
+ }
+
+ /// Keeps a call's closed client, and its diagnostics as they are now,
+ /// for "Report a problem" during the ten minutes after it.
+ fileprivate func keepForReports(_ client: BridgeClient) {
+ guard reportClient !== client else { return }
+ reportClient = client
+ reportDiagnostics = anonymousDiagnosticsText()
+ let until = Date().addingTimeInterval(Self.reportWindow)
+ reportableUntil = until
+ Task { @MainActor [weak self] in
+ try? await Task.sleep(nanoseconds: UInt64(Self.reportWindow * 1_000_000_000))
+ guard let self, self.reportableUntil == until else { return }
+ self.reportableUntil = nil
+ self.reportClient = nil
+ self.reportDiagnostics = ""
+ }
+ }
+
+ /// Whether "Report a problem" has anything to report: a call in
+ /// progress, or one that ended less than ten minutes ago.
+ public var canReportProblem: Bool {
+ bridge != nil || fixtureReports || (reportableUntil.map { $0 > Date() } ?? false)
+ }
+
+ /// Opens the sheet. In a call it reports this call, its diagnostics read
+ /// now; after one, the call that ended, as it was when it ended.
+ public func reportProblem() {
+ let info = QualityClientInfo.current()
+ let deliver: @Sendable (String) async -> ProblemOutcome
+ if let client = bridge ?? (canReportProblem ? reportClient : nil) {
+ let diagnostics = bridge != nil ? anonymousDiagnosticsText() : reportDiagnostics
+ deliver = { note in
+ let body = ProblemReportBody(note: note, diagnostics: diagnostics, client: info).encoded()
+ return await Task.detached { ProblemOutcome(bridgeAnswer: client.sendProblem(body)) }.value
+ }
+ } else if fixtureReports {
+ // The UI test's call has no server: it is answered here, after a
+ // moment, with a reference of the server's shape.
+ deliver = { _ in
+ try? await Task.sleep(nanoseconds: 300_000_000)
+ return .sent(ref: "K7Q2M9PD")
+ }
+ } else {
+ return
+ }
+ problemReport = ProblemReportModel(deliver: deliver)
+ }
+}
+
// ---------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------
@@ -1239,6 +1512,7 @@ extension Call {
self.sharingScreen = sharingScreen
self.muted = muted
self.floor = floor
+ self.fixtureReports = true
}
/// Your own microphone, camera and screen, set over a fixture for the
diff --git a/native/apple/Sources/CallsKit/CallChrome.swift b/native/apple/Sources/CallsKit/CallChrome.swift
index 024b197..5d73ac6 100644
--- a/native/apple/Sources/CallsKit/CallChrome.swift
+++ b/native/apple/Sources/CallsKit/CallChrome.swift
@@ -371,8 +371,17 @@ public struct CallNotice: Identifiable, Equatable, Sendable {
/// Paul, 2026-09-28: "I would expect this notification to have a
/// 'Stop sharing' button inline." The same stop as the bar's toggle.
case stopSharing
+ /// The call has ended under you: "Report a problem" (spec §15.26),
+ /// which works for ten minutes after it.
+ case reportProblem
+ }
+ public var action: Action? {
+ switch kind {
+ case .sharing: .stopSharing
+ case .ended: .reportProblem
+ default: nil
+ }
}
- public var action: Action? { kind == .sharing ? .stopSharing : nil }
}
public enum CallNotices {
@@ -461,9 +470,14 @@ public struct NoticeActionButton: View {
public var body: some View {
switch action {
- case .stopSharing:
+ case .stopSharing: capsule("Stop sharing", label: "Stop sharing your screen", id: "stopSharingInline")
+ case .reportProblem: capsule(ProblemWords.title, label: ProblemWords.title, id: "reportProblem")
+ }
+ }
+
+ private func capsule(_ title: String, label: String, id: String) -> some View {
Button(action: perform) {
- Text("Stop sharing").font(.callout.weight(.semibold))
+ Text(title).font(.callout.weight(.semibold))
.padding(.horizontal, 12).padding(.vertical, 4)
.foregroundStyle(Color.black)
.background(Capsule().fill(Color.white.opacity(focused ? 1 : 0.92)))
@@ -477,9 +491,8 @@ public struct NoticeActionButton: View {
perform()
return .handled
}
- .accessibilityLabel("Stop sharing your screen")
- .accessibilityIdentifier("stopSharingInline")
- }
+ .accessibilityLabel(label)
+ .accessibilityIdentifier(id)
}
}
@@ -488,6 +501,7 @@ extension Call {
public func perform(_ action: CallNotice.Action) {
switch action {
case .stopSharing: stopSharingScreen()
+ case .reportProblem: reportProblem()
}
}
}
diff --git a/native/apple/Sources/CallsKit/CallsLobby.swift b/native/apple/Sources/CallsKit/CallsLobby.swift
index e7a63c6..a7510f3 100644
--- a/native/apple/Sources/CallsKit/CallsLobby.swift
+++ b/native/apple/Sources/CallsKit/CallsLobby.swift
@@ -110,6 +110,11 @@ import SwiftUI
}
.buttonStyle(.bordered).accessibilityIdentifier("joinAsGuest")
}
+ // The call just left, or ended, can still be reported for ten
+ // minutes (spec §15.26).
+ if model.call.reportableUntil != nil {
+ AfterCallReport(call: model.call)
+ }
if !model.error.isEmpty {
Label(model.error, systemImage: "exclamationmark.circle.fill")
.foregroundStyle(.red).fixedSize(horizontal: false, vertical: true)
@@ -253,3 +258,21 @@ import SwiftUI
return "A place for your team to come together."
}
}
+
+/// "Report a problem" on the home screen after a call, while the server
+/// still keeps that call's report token. Observes the call, so it goes
+/// when the ten minutes are up.
+struct AfterCallReport: View {
+ @ObservedObject var call: Call
+ var body: some View {
+ if call.reportableUntil != nil {
+ VStack(alignment: .leading, spacing: 6) {
+ ReportProblemButton { call.reportProblem() }.buttonStyle(.bordered)
+ Text("Something wrong with the call you were just in? You can report it for ten minutes after it ends.")
+ .font(.footnote).foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ .reportProblemSheet(call)
+ }
+ }
+}
diff --git a/native/apple/Sources/CallsKit/CallsSettingsView.swift b/native/apple/Sources/CallsKit/CallsSettingsView.swift
index 6a82659..93482af 100644
--- a/native/apple/Sources/CallsKit/CallsSettingsView.swift
+++ b/native/apple/Sources/CallsKit/CallsSettingsView.swift
@@ -69,6 +69,7 @@
Text("During a call")
}
ServerSettingsSection(model: model)
+ QualityReportsSection()
if let automaticUpdates {
Section {
Toggle(isOn: automaticUpdates) {
diff --git a/native/apple/Sources/CallsKit/DevicePanel.swift b/native/apple/Sources/CallsKit/DevicePanel.swift
index 1dc202b..0d11a2a 100644
--- a/native/apple/Sources/CallsKit/DevicePanel.swift
+++ b/native/apple/Sources/CallsKit/DevicePanel.swift
@@ -18,6 +18,10 @@ import SwiftUI
@State private var camera: String?
@State private var cameraRequest = 0
@State private var showingAudioDetails = false
+ @Environment(\.dismiss) private var dismiss
+ /// How long a popover, or the sheet holding the panel, takes to go
+ /// before another sheet can open in its place.
+ static let handOver: TimeInterval = 0.45
#if os(iOS)
@StateObject private var micModes = MicrophoneModes()
#endif
@@ -322,6 +326,20 @@ import SwiftUI
}
CopyAudioDiagnosticsButton(title: "Copy call diagnostics") { call.audioDiagnosticsText() }
.padding(.top, 6)
+ // Beside the copy, the same diagnostics sent rather than pasted
+ // (spec §15.26). The popover goes first — and on iPhone the Devices
+ // sheet with it — so the report's sheet opens over the call.
+ ReportProblemButton {
+ showingAudioDetails = false
+ #if os(iOS)
+ dismiss()
+ #endif
+ let call = self.call
+ DispatchQueue.main.asyncAfter(deadline: .now() + Self.handOver) { call.reportProblem() }
+ }
+ .buttonStyle(.borderless)
+ .font(.footnote)
+ .disabled(!call.canReportProblem)
if call.sharingScreen {
Text("Screen sharing").font(.headline).padding(.top, 6)
Text(call.screenReport?.summary ?? "Screen: starting…")
diff --git a/native/apple/Sources/CallsKit/MacCallView.swift b/native/apple/Sources/CallsKit/MacCallView.swift
index c92b664..964c20a 100644
--- a/native/apple/Sources/CallsKit/MacCallView.swift
+++ b/native/apple/Sources/CallsKit/MacCallView.swift
@@ -84,6 +84,7 @@
if visible { readChatCount = call.receivedChatCount }
}
.onDisappear { chatWindow.close() }
+ .reportProblemSheet(call)
}
/// The call as it was before the full-window layout: a title block,
@@ -167,9 +168,14 @@
if call.state?.phase == "full" { Button("Try joining again") { call.retryJoin() } }
// An ended call used to stay on screen as a call with no way back
// into it but Leave and the lobby.
- if call.state?.phase == "ended", let rejoin {
- Button("Join again", action: rejoin).buttonStyle(.borderedProminent)
- .accessibilityIdentifier("joinAgain")
+ if call.state?.phase == "ended" {
+ HStack {
+ if let rejoin {
+ Button("Join again", action: rejoin).buttonStyle(.borderedProminent)
+ .accessibilityIdentifier("joinAgain")
+ }
+ ReportProblemButton { call.reportProblem() }.buttonStyle(.bordered)
+ }
}
controls(fullWindow: false)
}
diff --git a/native/apple/Sources/CallsKit/Media.swift b/native/apple/Sources/CallsKit/Media.swift
index b96c140..751f573 100644
--- a/native/apple/Sources/CallsKit/Media.swift
+++ b/native/apple/Sources/CallsKit/Media.swift
@@ -452,6 +452,22 @@ import LiveKitWebRTC
return out
}
+ /// Every connection's statistics at one look, for the quality report
+ /// (spec §15.26): the whole report per connection, with its generation
+ /// and which mids carry screens, read as the other statistics are.
+ func qualitySamples() async -> [String: QualityPeerSample] {
+ var out: [String: QualityPeerSample] = [:]
+ for (id, peer) in peers {
+ guard let pc = peer.pc else { continue }
+ let ownScreen = await peer.ownScreenMid()
+ let report = await PeerCalls.statistics(pc)
+ out[id] = QualityPeerSample(
+ generation: peer.generation, ownScreenMid: ownScreen, screenMid: peer.screenMidHere,
+ stats: report.statistics.mapValues { QualityStat(type: $0.type, values: $0.values) })
+ }
+ return out
+ }
+
/// Caps every connection's bandwidth estimate, which is what a thin
/// uplink does to it. For the gate, which has no thin uplink to test on;
/// nil takes the cap off.
diff --git a/native/apple/Sources/CallsKit/QualityReport.swift b/native/apple/Sources/CallsKit/QualityReport.swift
new file mode 100644
index 0000000..aa52bba
--- /dev/null
+++ b/native/apple/Sources/CallsKit/QualityReport.swift
@@ -0,0 +1,781 @@
+import Foundation
+#if os(iOS)
+ import UIKit
+#endif
+
+// Quality reports (spec §15.26, Paul's ruling of 2026-10-03): every thirty
+// seconds while admitted, and once more at leave, this app tells the
+// server how its connections went over the window — numbers and enums
+// only, in protocol/quality.go's fixed schema. No names, no addresses, no
+// device names, no meeting, nothing typed by a person.
+//
+// Everything here is pure: the statistics come in as a snapshot
+// (`QualityPeerSample`), the notices as a tally (`QualityEventTally`), and
+// the report comes out, so the whole of it is tested from fixtures. Call
+// gathers the snapshot and sends what comes out.
+
+// MARK: - The wire (protocol/quality.go)
+
+/// The schema's bounds, as protocol/quality.go has them. The server
+/// refuses a report over any of them, so a client clamps to them first.
+public enum QualityBounds {
+ public static let version = 1
+ /// Remote peers in one report: the mesh holds three, and one spare for a
+ /// connection on its way out. Past it the line would not fit the
+ /// platform's 8 KB.
+ public static let peers = 4
+ public static let seconds = 600.0
+ public static let millis = 600_000.0
+ public static let count = 1e9
+ public static let kbps = 1e6
+ public static let pixels = 16384.0
+ public static let fps = 240.0
+ public static let body = 16 << 10
+ public static let note = 1000
+ public static let problemBody = 64 << 10
+}
+
+public struct QualityReport: Encodable, Equatable, Sendable {
+ public var v = QualityBounds.version
+ /// The report sent at leave or end.
+ public var isFinal: Bool?
+ public var seconds: Double
+ public var client: QualityClient
+ public var devices: QualityDevices?
+ public var events: QualityCallEvents?
+ public var peers: [QualityPeer]?
+
+ enum CodingKeys: String, CodingKey {
+ case v, seconds, client, devices, events, peers
+ case isFinal = "final"
+ }
+
+ /// The body as it is sent: sorted keys, so two equal reports are the
+ /// same bytes.
+ public func encoded() -> String? {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ guard let data = try? encoder.encode(self), data.count <= QualityBounds.body else { return nil }
+ return String(data: data, encoding: .utf8)
+ }
+}
+
+/// What is reporting: enums, and versions as digits and dots. Never a
+/// device model or a machine name.
+public struct QualityClient: Codable, Equatable, Sendable {
+ public var app: String
+ public var version: String?
+ public var os: String?
+ public var osVersion: String?
+
+ public init(app: String, version: String? = nil, os: String? = nil, osVersion: String? = nil) {
+ self.app = app
+ self.version = version
+ self.os = os
+ self.osVersion = osVersion
+ }
+}
+
+/// The kinds of microphone and speaker in use, by transport: never a
+/// device's name, which can be personal ("Paul's AirPods Pro").
+public struct QualityDevices: Encodable, Equatable, Sendable {
+ public var input: String?
+ public var output: String?
+}
+
+public struct QualityCallEvents: Encodable, Equatable, Sendable {
+ public var reconnecting: Double?
+ public var reconnectingSeconds: Double?
+}
+
+public struct QualityPeer: Encodable, Equatable, Sendable {
+ public var peer: String
+ public var path: QualityPath?
+ public var rtt: Double?
+ public var out: QualityOutbound?
+ public var `in`: QualityInbound?
+ public var events: QualityPeerEvents?
+}
+
+public struct QualityPath: Encodable, Equatable, Sendable {
+ public var local: String?
+ public var remote: String?
+ public var `protocol`: String?
+}
+
+public struct QualityOutbound: Encodable, Equatable, Sendable {
+ public var audio: QualityOutStream?
+ public var video: QualityOutStream?
+ public var screen: QualityOutStream?
+}
+
+public struct QualityOutStream: Encodable, Equatable, Sendable {
+ public var kbps: Double?
+ public var packets: Double?
+ public var lost: Double?
+ public var rtt: Double?
+ public var jitter: Double?
+ public var limit: String?
+ public var width: Double?
+ public var height: Double?
+ public var fps: Double?
+ public var keyframes: Double?
+ public var rebuilds: Double?
+ public var reshares: Double?
+ public var failures: Double?
+}
+
+public struct QualityInbound: Encodable, Equatable, Sendable {
+ public var audio: QualityInAudio?
+ public var video: QualityInVideo?
+ public var screen: QualityInVideo?
+}
+
+public struct QualityInAudio: Encodable, Equatable, Sendable {
+ public var kbps: Double?
+ public var packets: Double?
+ public var lost: Double?
+ public var jitter: Double?
+ public var samples: Double?
+ public var concealed: Double?
+ public var inserted: Double?
+ public var removed: Double?
+ public var jitterBufferMs: Double?
+}
+
+public struct QualityInVideo: Encodable, Equatable, Sendable {
+ public var kbps: Double?
+ public var packets: Double?
+ public var lost: Double?
+ public var jitter: Double?
+ public var freezes: Double?
+ public var freezeSeconds: Double?
+ public var decoded: Double?
+ public var dropped: Double?
+ public var width: Double?
+ public var height: Double?
+ public var fps: Double?
+ public var reattaches: Double?
+}
+
+public struct QualityPeerEvents: Encodable, Equatable, Sendable {
+ public var reconnecting: Double?
+ public var reconnectingSeconds: Double?
+ public var cantHearSeconds: Double?
+ public var noSound: Bool?
+ public var noSoundFromUs: Bool?
+ public var audioBlocked: Bool?
+ public var screenStalled: Bool?
+}
+
+// MARK: - The snapshot
+
+/// One statistics object from WebRTC's report, its values split by kind:
+/// numbers (NSNumber, booleans among them) and strings. Nothing else in a
+/// report is read.
+public struct QualityStat: Equatable, Sendable {
+ public var type: String
+ public var numbers: [String: Double]
+ public var strings: [String: String]
+
+ public init(type: String, numbers: [String: Double] = [:], strings: [String: String] = [:]) {
+ self.type = type
+ self.numbers = numbers
+ self.strings = strings
+ }
+
+ /// From WebRTC's own values.
+ init(type: String, values: [String: Any]) {
+ var numbers: [String: Double] = [:]
+ var strings: [String: String] = [:]
+ for (key, value) in values {
+ if let n = value as? NSNumber {
+ numbers[key] = n.doubleValue
+ } else if let s = value as? String {
+ strings[key] = s
+ }
+ }
+ self.init(type: type, numbers: numbers, strings: strings)
+ }
+}
+
+/// One connection's statistics at one look, with what is needed to read
+/// them: the connection's generation (a new one starts every count from
+/// nothing), and which mids are screens.
+public struct QualityPeerSample: Equatable, Sendable {
+ public var generation: Int64
+ /// The mid this app's own screen goes out on, while sharing.
+ public var ownScreenMid: String?
+ /// The mid their screen arrives on here, while they share.
+ public var screenMid: String
+ public var stats: [String: QualityStat]
+
+ public init(generation: Int64, ownScreenMid: String? = nil, screenMid: String = "", stats: [String: QualityStat]) {
+ self.generation = generation
+ self.ownScreenMid = ownScreenMid
+ self.screenMid = screenMid
+ self.stats = stats
+ }
+}
+
+// MARK: - Running totals, read from a snapshot
+
+/// The cumulative counters and current gauges of one connection, as the
+/// statistics have them. A report is the difference between two of these.
+struct QualityCounters: Equatable, Sendable {
+ struct Out: Equatable, Sendable {
+ var bytes: Double?
+ var packets: Double?
+ var lost: Double?
+ var rtt: Double?
+ var jitter: Double?
+ var limit: String?
+ var width: Double?
+ var height: Double?
+ var fps: Double?
+ }
+ struct InAudio: Equatable, Sendable {
+ var bytes: Double?
+ var packets: Double?
+ var lost: Double?
+ var jitter: Double?
+ var samples: Double?
+ var concealed: Double?
+ var inserted: Double?
+ var removed: Double?
+ var bufferDelay: Double?
+ var emitted: Double?
+ }
+ struct InVideo: Equatable, Sendable {
+ var bytes: Double?
+ var packets: Double?
+ var lost: Double?
+ var jitter: Double?
+ var freezes: Double?
+ var freezeSeconds: Double?
+ var decoded: Double?
+ var dropped: Double?
+ var width: Double?
+ var height: Double?
+ var fps: Double?
+ }
+ var outAudio: Out?
+ var outVideo: Out?
+ var outScreen: Out?
+ var inAudio: InAudio?
+ var inVideo: InVideo?
+ var inScreen: InVideo?
+ var path: QualityPath?
+ var rtt: Double?
+
+ init() {}
+
+ /// Reads a snapshot. A stream absent from the statistics is absent
+ /// here; a value WebRTC does not give is nil, never zero.
+ init(_ sample: QualityPeerSample) {
+ let stats = sample.stats
+ for (id, stat) in stats {
+ let kind = stat.strings["kind"] ?? stat.strings["mediaType"] ?? ""
+ let mid = stat.strings["mid"] ?? ""
+ switch stat.type {
+ case "outbound-rtp":
+ var out = Out()
+ out.bytes = stat.numbers["bytesSent"]
+ out.packets = stat.numbers["packetsSent"]
+ out.limit = stat.strings["qualityLimitationReason"]
+ out.width = stat.numbers["frameWidth"]
+ out.height = stat.numbers["frameHeight"]
+ out.fps = stat.numbers["framesPerSecond"]
+ // The receiver's own account of this stream.
+ if let remote = stats.values.first(where: { $0.type == "remote-inbound-rtp" && $0.strings["localId"] == id }) {
+ out.lost = remote.numbers["packetsLost"]
+ out.rtt = remote.numbers["roundTripTime"].map { $0 * 1000 }
+ out.jitter = remote.numbers["jitter"].map { $0 * 1000 }
+ }
+ if kind == "audio" {
+ outAudio = Self.add(outAudio, out)
+ } else if kind == "video" {
+ if let own = sample.ownScreenMid, !own.isEmpty, own == mid {
+ outScreen = Self.add(outScreen, out)
+ } else {
+ outVideo = Self.add(outVideo, out)
+ }
+ }
+ case "inbound-rtp":
+ if kind == "audio" {
+ var a = inAudio ?? InAudio()
+ a.bytes = Self.sum(a.bytes, stat.numbers["bytesReceived"])
+ a.packets = Self.sum(a.packets, stat.numbers["packetsReceived"])
+ a.lost = Self.sum(a.lost, stat.numbers["packetsLost"])
+ a.jitter = Self.most(a.jitter, stat.numbers["jitter"].map { $0 * 1000 })
+ a.samples = Self.sum(a.samples, stat.numbers["totalSamplesReceived"])
+ a.concealed = Self.sum(a.concealed, stat.numbers["concealedSamples"])
+ a.inserted = Self.sum(a.inserted, stat.numbers["insertedSamplesForDeceleration"])
+ a.removed = Self.sum(a.removed, stat.numbers["removedSamplesForAcceleration"])
+ a.bufferDelay = Self.sum(a.bufferDelay, stat.numbers["jitterBufferDelay"])
+ a.emitted = Self.sum(a.emitted, stat.numbers["jitterBufferEmittedCount"])
+ inAudio = a
+ } else if kind == "video" {
+ var v = InVideo()
+ v.bytes = stat.numbers["bytesReceived"]
+ v.packets = stat.numbers["packetsReceived"]
+ v.lost = stat.numbers["packetsLost"]
+ v.jitter = stat.numbers["jitter"].map { $0 * 1000 }
+ v.freezes = stat.numbers["freezeCount"]
+ v.freezeSeconds = stat.numbers["totalFreezesDuration"]
+ v.decoded = stat.numbers["framesDecoded"]
+ v.dropped = stat.numbers["framesDropped"]
+ v.width = stat.numbers["frameWidth"]
+ v.height = stat.numbers["frameHeight"]
+ v.fps = stat.numbers["framesPerSecond"]
+ // Told apart by mid, as the statistics elsewhere are: a peer
+ // sharing its screen sends two video streams.
+ if !sample.screenMid.isEmpty, mid == sample.screenMid {
+ inScreen = Self.add(inScreen, v)
+ } else {
+ inVideo = Self.add(inVideo, v)
+ }
+ }
+ default: break
+ }
+ }
+ readPath(stats)
+ }
+
+ /// The selected candidate pair: each end's kind of candidate and the
+ /// transport, and its round trip. Addresses and ports are in the same
+ /// objects and are never read.
+ private mutating func readPath(_ stats: [String: QualityStat]) {
+ var pairId = stats.values.first { $0.type == "transport" }?.strings["selectedCandidatePairId"]
+ if pairId == nil {
+ pairId = stats.first {
+ $0.value.type == "candidate-pair" && ($0.value.numbers["nominated"] ?? 0) != 0
+ && $0.value.strings["state"] == "succeeded"
+ }?.key
+ }
+ guard let pairId, let pair = stats[pairId] else { return }
+ let local = pair.strings["localCandidateId"].flatMap { stats[$0] }
+ let remote = pair.strings["remoteCandidateId"].flatMap { stats[$0] }
+ var path = QualityPath()
+ path.local = local.map { QualityWords.candidate($0.strings["candidateType"]) }
+ path.remote = remote.map { QualityWords.candidate($0.strings["candidateType"]) }
+ if let local {
+ path.protocol = QualityWords.transport(
+ local.strings["protocol"], relayProtocol: local.strings["relayProtocol"],
+ relay: local.strings["candidateType"] == "relay")
+ }
+ self.path = path
+ rtt = pair.numbers["currentRoundTripTime"].map { $0 * 1000 }
+ }
+
+ private static func sum(_ a: Double?, _ b: Double?) -> Double? {
+ guard let b else { return a }
+ return (a ?? 0) + b
+ }
+ private static func most(_ a: Double?, _ b: Double?) -> Double? {
+ guard let b else { return a }
+ return max(a ?? b, b)
+ }
+ /// Two streams of the same kind (a stream replaced on a fresh
+ /// transceiver, briefly beside the one it replaces): counts add, gauges
+ /// are the larger.
+ private static func add(_ a: Out?, _ b: Out) -> Out {
+ guard var a else { return b }
+ a.bytes = sum(a.bytes, b.bytes)
+ a.packets = sum(a.packets, b.packets)
+ a.lost = sum(a.lost, b.lost)
+ a.rtt = most(a.rtt, b.rtt)
+ a.jitter = most(a.jitter, b.jitter)
+ a.limit = a.limit ?? b.limit
+ a.width = most(a.width, b.width)
+ a.height = most(a.height, b.height)
+ a.fps = most(a.fps, b.fps)
+ return a
+ }
+ private static func add(_ a: InVideo?, _ b: InVideo) -> InVideo {
+ guard var a else { return b }
+ a.bytes = sum(a.bytes, b.bytes)
+ a.packets = sum(a.packets, b.packets)
+ a.lost = sum(a.lost, b.lost)
+ a.jitter = most(a.jitter, b.jitter)
+ a.freezes = sum(a.freezes, b.freezes)
+ a.freezeSeconds = sum(a.freezeSeconds, b.freezeSeconds)
+ a.decoded = sum(a.decoded, b.decoded)
+ a.dropped = sum(a.dropped, b.dropped)
+ a.width = most(a.width, b.width)
+ a.height = most(a.height, b.height)
+ a.fps = most(a.fps, b.fps)
+ return a
+ }
+}
+
+/// The schema's enums, from WebRTC's words.
+enum QualityWords {
+ static func candidate(_ type: String?) -> String {
+ switch type {
+ case "host", "srflx", "prflx", "relay": return type!
+ default: return "unknown"
+ }
+ }
+
+ /// udp or tcp; tls for a relay reached over TURN/TLS.
+ static func transport(_ proto: String?, relayProtocol: String?, relay: Bool) -> String {
+ if relay, relayProtocol?.lowercased() == "tls" { return "tls" }
+ switch proto?.lowercased() {
+ case "udp": return "udp"
+ case "tcp": return "tcp"
+ default: return "unknown"
+ }
+ }
+
+ static func limit(_ reason: String?) -> String? {
+ guard let reason, !reason.isEmpty else { return nil }
+ return ["none", "cpu", "bandwidth"].contains(reason) ? reason : "other"
+ }
+}
+
+// MARK: - The notices, tallied
+
+/// What the call said during a window (§15.20, §15.21, §15.23, §15.24), and
+/// the screen watchdogs' steps (§15.22), fed from the level watch's looks.
+/// A notice still showing when the window ends carries into the next
+/// without being counted again.
+public struct QualityEventTally: Equatable, Sendable {
+ /// A screen recovery step, toward a peer (the sharer's) or for their
+ /// screen here (the receiver's).
+ public enum Step: Sendable { case keyframe, rebuild, reshare, failure, reattach }
+
+ struct Peer: Equatable, Sendable {
+ var reconnecting = 0
+ var reconnectingSeconds = 0.0
+ var cantHearSeconds = 0.0
+ var noSound = false
+ var noSoundFromUs = false
+ var audioBlocked = false
+ var screenStalled = false
+ var keyframes = 0
+ var rebuilds = 0
+ var reshares = 0
+ var failures = 0
+ var reattaches = 0
+ }
+
+ /// The longest a look is taken to cover: the level watch looks five
+ /// times a second, and a gap longer than this is the process asleep, not
+ /// a notice on screen.
+ static let longestLook = 1.0
+
+ private var last: Double?
+ private var callShown = false
+ private var peersShown: Set<String> = []
+ private(set) var callReconnecting = 0
+ private(set) var callSeconds = 0.0
+ private(set) var peers: [String: Peer] = [:]
+
+ public init() {}
+
+ /// One look. `reconnecting` and `cantHear` are the people whose tiles
+ /// say so now; the rest are the people each fact is true of now.
+ public mutating func observe(
+ at now: Double, call: Bool, reconnecting: Set<String>, cantHear: Set<String>,
+ noSound: Set<String> = [], noSoundFromUs: Set<String> = [], audioBlocked: Set<String> = [],
+ screenStalled: Set<String> = []
+ ) {
+ let dt = last.map { min(max(now - $0, 0), Self.longestLook) } ?? 0
+ last = now
+ if call {
+ if !callShown { callReconnecting += 1 }
+ callSeconds += dt
+ }
+ callShown = call
+ for id in reconnecting {
+ if !peersShown.contains(id) { peers[id, default: Peer()].reconnecting += 1 }
+ peers[id, default: Peer()].reconnectingSeconds += dt
+ }
+ peersShown = reconnecting
+ for id in cantHear { peers[id, default: Peer()].cantHearSeconds += dt }
+ for id in noSound { peers[id, default: Peer()].noSound = true }
+ for id in noSoundFromUs { peers[id, default: Peer()].noSoundFromUs = true }
+ for id in audioBlocked { peers[id, default: Peer()].audioBlocked = true }
+ for id in screenStalled { peers[id, default: Peer()].screenStalled = true }
+ }
+
+ public mutating func count(_ step: Step, for peer: String) {
+ switch step {
+ case .keyframe: peers[peer, default: Peer()].keyframes += 1
+ case .rebuild: peers[peer, default: Peer()].rebuilds += 1
+ case .reshare: peers[peer, default: Peer()].reshares += 1
+ case .failure: peers[peer, default: Peer()].failures += 1
+ case .reattach: peers[peer, default: Peer()].reattaches += 1
+ }
+ }
+
+ /// The window's tally, and a fresh one for the next window that
+ /// remembers what is still showing.
+ mutating func take() -> QualityEventTally {
+ let window = self
+ callReconnecting = 0
+ callSeconds = 0
+ peers = [:]
+ return window
+ }
+}
+
+// MARK: - The report
+
+/// Turns snapshots into reports: keeps each connection's running totals
+/// from the last report, so a report's counts are what happened during
+/// its window. A new connection generation, or a counter that went
+/// backwards (a stream rebuilt on a fresh transceiver), starts from
+/// nothing.
+public struct QualityReportBuilder: Equatable, Sendable {
+ private var since: Double?
+ private var previous: [String: (generation: Int64, counters: QualityCounters)] = [:]
+
+ public init() {}
+
+ public var started: Bool { since != nil }
+
+ /// Seconds since the window began; zero before it has.
+ public func elapsed(at now: Double) -> Double { since.map { now - $0 } ?? 0 }
+
+ /// The first window begins: at admission.
+ public mutating func start(at now: Double) {
+ since = now
+ previous = [:]
+ }
+
+ public static func == (a: Self, b: Self) -> Bool {
+ a.since == b.since && a.previous.keys == b.previous.keys
+ }
+
+ /// One report, for the window from the last one (or `start`) to `now`.
+ public mutating func build(
+ at now: Double, samples: [String: QualityPeerSample], events tally: inout QualityEventTally,
+ client: QualityClient, devices: QualityDevices?, final: Bool
+ ) -> QualityReport {
+ let seconds = max(now - (since ?? now), 0.1)
+ since = now
+ let events = tally.take()
+ var report = QualityReport(
+ isFinal: final ? true : nil, seconds: Self.bound(seconds, QualityBounds.seconds) ?? QualityBounds.seconds,
+ client: client, devices: devices)
+ if events.callReconnecting > 0 || events.callSeconds > 0 {
+ report.events = QualityCallEvents(
+ reconnecting: Self.steps(events.callReconnecting),
+ reconnectingSeconds: events.callSeconds > 0 ? Self.bound(events.callSeconds, QualityBounds.seconds) : nil)
+ }
+ let ids = Set(samples.keys).union(events.peers.keys).sorted()
+ var peers: [QualityPeer] = []
+ for id in ids.prefix(QualityBounds.peers) {
+ var peer = QualityPeer(peer: id)
+ if let sample = samples[id] {
+ let now = QualityCounters(sample)
+ let before = previous[id].flatMap { $0.generation == sample.generation ? $0.counters : nil }
+ previous[id] = (sample.generation, now)
+ peer.path = now.path
+ peer.rtt = Self.bound(now.rtt, QualityBounds.millis)
+ var out = QualityOutbound()
+ out.audio = now.outAudio.map { Self.out($0, before?.outAudio, seconds) }
+ out.video = now.outVideo.map { Self.out($0, before?.outVideo, seconds) }
+ out.screen = now.outScreen.map { Self.out($0, before?.outScreen, seconds) }
+ if out != QualityOutbound() { peer.out = out }
+ var inbound = QualityInbound()
+ inbound.audio = now.inAudio.map { Self.inAudio($0, before?.inAudio, seconds) }
+ inbound.video = now.inVideo.map { Self.inVideo($0, before?.inVideo, seconds) }
+ inbound.screen = now.inScreen.map { Self.inVideo($0, before?.inScreen, seconds) }
+ if inbound != QualityInbound() { peer.in = inbound }
+ }
+ if let e = events.peers[id] { Self.apply(e, to: &peer) }
+ peers.append(peer)
+ }
+ // A connection gone is forgotten; if it comes back it starts again.
+ for id in Array(previous.keys) where samples[id] == nil { previous.removeValue(forKey: id) }
+ report.peers = peers.isEmpty ? nil : peers
+ return report
+ }
+
+ private static func apply(_ e: QualityEventTally.Peer, to peer: inout QualityPeer) {
+ var events = QualityPeerEvents()
+ events.reconnecting = steps(e.reconnecting)
+ if e.reconnectingSeconds > 0 { events.reconnectingSeconds = bound(e.reconnectingSeconds, QualityBounds.seconds) }
+ if e.cantHearSeconds > 0 { events.cantHearSeconds = bound(e.cantHearSeconds, QualityBounds.seconds) }
+ if e.noSound { events.noSound = true }
+ if e.noSoundFromUs { events.noSoundFromUs = true }
+ if e.audioBlocked { events.audioBlocked = true }
+ if e.screenStalled { events.screenStalled = true }
+ if events != QualityPeerEvents() { peer.events = events }
+ // The sharer's recovery steps toward this peer belong with the screen
+ // going out to them, even in a window where it sent nothing.
+ if e.keyframes + e.rebuilds + e.reshares + e.failures > 0 {
+ var out = peer.out ?? QualityOutbound()
+ var screen = out.screen ?? QualityOutStream()
+ screen.keyframes = steps(e.keyframes)
+ screen.rebuilds = steps(e.rebuilds)
+ screen.reshares = steps(e.reshares)
+ screen.failures = steps(e.failures)
+ out.screen = screen
+ peer.out = out
+ }
+ if e.reattaches > 0 {
+ var inbound = peer.in ?? QualityInbound()
+ var screen = inbound.screen ?? QualityInVideo()
+ screen.reattaches = steps(e.reattaches)
+ inbound.screen = screen
+ peer.in = inbound
+ }
+ }
+
+ private static func steps(_ n: Int) -> Double? { n > 0 ? bound(Double(n), QualityBounds.count) : nil }
+
+ /// What happened during the window: the difference, or the whole count
+ /// when there is nothing to take it from or it went backwards.
+ static func delta(_ now: Double?, _ before: Double?) -> Double? {
+ // A running total below zero (RTCP's packetsLost can be, with
+ // duplicates) is not a count of anything.
+ guard let now, now.isFinite, now >= 0 else { return nil }
+ guard let before, before.isFinite, now >= before else { return now }
+ return now - before
+ }
+
+ /// Finite, not negative, under the ceiling, to a tenth; nil otherwise.
+ static func bound(_ value: Double?, _ ceiling: Double) -> Double? {
+ guard let value, value.isFinite, value >= 0 else { return nil }
+ return (min(value, ceiling) * 10).rounded() / 10
+ }
+
+ private static func kbps(_ bytes: Double?, _ seconds: Double) -> Double? {
+ guard let bytes, seconds > 0 else { return nil }
+ return bound(bytes * 8 / 1000 / seconds, QualityBounds.kbps)
+ }
+
+ private static func out(_ now: QualityCounters.Out, _ before: QualityCounters.Out?, _ seconds: Double)
+ -> QualityOutStream
+ {
+ var s = QualityOutStream()
+ s.kbps = kbps(delta(now.bytes, before?.bytes), seconds)
+ s.packets = bound(delta(now.packets, before?.packets), QualityBounds.count)
+ s.lost = bound(delta(now.lost, before?.lost), QualityBounds.count)
+ s.rtt = bound(now.rtt, QualityBounds.millis)
+ s.jitter = bound(now.jitter, QualityBounds.millis)
+ s.limit = QualityWords.limit(now.limit)
+ s.width = bound(now.width, QualityBounds.pixels)
+ s.height = bound(now.height, QualityBounds.pixels)
+ s.fps = bound(now.fps, QualityBounds.fps)
+ return s
+ }
+
+ private static func inAudio(
+ _ now: QualityCounters.InAudio, _ before: QualityCounters.InAudio?, _ seconds: Double
+ ) -> QualityInAudio {
+ var a = QualityInAudio()
+ a.kbps = kbps(delta(now.bytes, before?.bytes), seconds)
+ a.packets = bound(delta(now.packets, before?.packets), QualityBounds.count)
+ a.lost = bound(delta(now.lost, before?.lost), QualityBounds.count)
+ a.jitter = bound(now.jitter, QualityBounds.millis)
+ a.samples = bound(delta(now.samples, before?.samples), QualityBounds.count)
+ a.concealed = bound(delta(now.concealed, before?.concealed), QualityBounds.count)
+ a.inserted = bound(delta(now.inserted, before?.inserted), QualityBounds.count)
+ a.removed = bound(delta(now.removed, before?.removed), QualityBounds.count)
+ // jitterBufferDelay is seconds summed over every sample emitted, so the
+ // window's mean is one delta over the other.
+ if let delay = delta(now.bufferDelay, before?.bufferDelay),
+ let emitted = delta(now.emitted, before?.emitted), emitted > 0
+ {
+ a.jitterBufferMs = bound(delay / emitted * 1000, QualityBounds.millis)
+ }
+ return a
+ }
+
+ private static func inVideo(
+ _ now: QualityCounters.InVideo, _ before: QualityCounters.InVideo?, _ seconds: Double
+ ) -> QualityInVideo {
+ var v = QualityInVideo()
+ v.kbps = kbps(delta(now.bytes, before?.bytes), seconds)
+ v.packets = bound(delta(now.packets, before?.packets), QualityBounds.count)
+ v.lost = bound(delta(now.lost, before?.lost), QualityBounds.count)
+ v.jitter = bound(now.jitter, QualityBounds.millis)
+ v.freezes = bound(delta(now.freezes, before?.freezes), QualityBounds.count)
+ v.freezeSeconds = bound(delta(now.freezeSeconds, before?.freezeSeconds), QualityBounds.seconds)
+ v.decoded = bound(delta(now.decoded, before?.decoded), QualityBounds.count)
+ v.dropped = bound(delta(now.dropped, before?.dropped), QualityBounds.count)
+ v.width = bound(now.width, QualityBounds.pixels)
+ v.height = bound(now.height, QualityBounds.pixels)
+ v.fps = bound(now.fps, QualityBounds.fps)
+ return v
+ }
+}
+
+// MARK: - Who is reporting, and on what
+
+/// The app, its version and the system's, as the schema allows them.
+public enum QualityClientInfo {
+ /// This process: "mac", or "iphone"/"ipad" by idiom.
+ @MainActor public static func current() -> QualityClient {
+ let version = self.version(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String)
+ let os = osVersion(ProcessInfo.processInfo.operatingSystemVersion)
+ #if os(macOS)
+ return QualityClient(app: "mac", version: version, os: "macos", osVersion: os)
+ #else
+ let pad = UIDevice.current.userInterfaceIdiom == .pad
+ return QualityClient(app: pad ? "ipad" : "iphone", version: version, os: pad ? "ipados" : "ios", osVersion: os)
+ #endif
+ }
+
+ /// Digits and dots, or nothing: a version is never free text.
+ public static func version(_ raw: String?) -> String? {
+ guard let raw, !raw.isEmpty, raw.count <= 24,
+ raw.allSatisfy({ $0.isASCII && ($0.isNumber || $0 == ".") }),
+ raw.first?.isNumber == true, raw.last?.isNumber == true, !raw.contains("..")
+ else { return nil }
+ return raw
+ }
+
+ public static func osVersion(_ v: OperatingSystemVersion) -> String {
+ "\(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
+ }
+}
+
+/// A microphone's or speaker's kind, from how it is connected. Never its
+/// name.
+public enum DeviceKind {
+ /// From CoreAudioDevices.transport's words.
+ public static func mac(transport: String?) -> String {
+ switch transport {
+ case nil, "": return "unknown"
+ case "built-in": return "built-in"
+ case "bluetooth": return "bluetooth"
+ case "usb": return "usb"
+ case "airplay": return "airplay"
+ case "virtual", "aggregate": return "virtual"
+ case "continuity": return "continuity"
+ default: return "other"
+ }
+ }
+
+ /// From an AVAudioSession port type's raw value (AVAudioSession.Port).
+ public static func ios(port: String?) -> String {
+ switch port {
+ case nil, "": return "none"
+ case "MicrophoneBuiltIn", "Speaker", "Receiver": return "built-in"
+ case "BluetoothHFP", "BluetoothA2DPOutput", "BluetoothLEOutput": return "bluetooth"
+ case "USBAudio": return "usb"
+ case "Headphones", "MicrophoneWired": return "wired"
+ case "AirPlay": return "airplay"
+ case "Virtual": return "virtual"
+ case "ContinuityMicrophone": return "continuity"
+ default: return "other"
+ }
+ }
+}
+
+/// The automatic reports' switch, in Settings (spec §15.26). On unless
+/// turned off; "Report a problem" works either way.
+public enum QualityReporting {
+ public static let enabledKey = "sendQualityReports"
+ public static func enabled(_ defaults: UserDefaults = .standard) -> Bool {
+ defaults.object(forKey: enabledKey) as? Bool ?? true
+ }
+ /// The settings line, both platforms.
+ public static let disclosure = "Calls sends anonymous connection quality reports to help fix call problems."
+}
diff --git a/native/apple/Sources/CallsKit/ReportProblem.swift b/native/apple/Sources/CallsKit/ReportProblem.swift
new file mode 100644
index 0000000..32cc1da
--- /dev/null
+++ b/native/apple/Sources/CallsKit/ReportProblem.swift
@@ -0,0 +1,270 @@
+import SwiftUI
+
+// "Report a problem" (spec §15.26): the person's note and this call's
+// diagnostics, with the people in it named by short peer ids, sent to the
+// server's log under a reference the person can quote. From the call's
+// controls, from the ⓘ beside "Copy call diagnostics", and for ten minutes
+// after the call from the notice that it ended and from the home screen.
+
+/// What became of a report, from the bridge's answer (bridge.SendProblem).
+public enum ProblemOutcome: Equatable, Sendable {
+ case sent(ref: String)
+ case tooMany
+ case gone
+ case failed
+
+ /// `{"ref":"K7Q2M9PD"}` or `{"outcome":"too-many"|"gone"|"failed"}`.
+ public init(bridgeAnswer: String) {
+ struct Answer: Decodable {
+ let ref: String?
+ let outcome: String?
+ }
+ guard let answer = try? JSONDecoder().decode(Answer.self, from: Data(bridgeAnswer.utf8)) else {
+ self = .failed
+ return
+ }
+ if let ref = answer.ref, !ref.isEmpty {
+ self = .sent(ref: ref)
+ return
+ }
+ switch answer.outcome {
+ case "too-many": self = .tooMany
+ case "gone": self = .gone
+ default: self = .failed
+ }
+ }
+}
+
+/// The words, exactly as the ruling has them.
+public enum ProblemWords {
+ public static let title = "Report a problem"
+ public static let noteLabel = "What went wrong?"
+ public static let disclosure = "Sends this call's connection diagnostics to the Calls server log. No audio or video."
+ public static let sent = "Sent — thank you"
+ public static func reference(_ ref: String) -> String { "Reference \(ref)" }
+ public static let tooMany = "Too many reports from this call — try again in a minute."
+ public static let gone = "This call can no longer be reported."
+ public static let failed = "The report could not be sent."
+
+ static func problem(_ outcome: ProblemOutcome) -> String? {
+ switch outcome {
+ case .sent: nil
+ case .tooMany: tooMany
+ case .gone: gone
+ case .failed: failed
+ }
+ }
+}
+
+/// The sheet's state: editing, sending, sent with a reference, or a
+/// sentence about why not (and the note kept, to send again).
+@MainActor public final class ProblemReportModel: ObservableObject, Identifiable {
+ public enum Phase: Equatable, Sendable {
+ case editing
+ case sending
+ case sent(ref: String)
+ case refused(String)
+ }
+
+ @Published public var note = "" {
+ didSet {
+ // The server counts characters as code points (utf8.RuneCount).
+ if note.unicodeScalars.count > QualityBounds.note {
+ note = String(String.UnicodeScalarView(note.unicodeScalars.prefix(QualityBounds.note)))
+ }
+ }
+ }
+ @Published public private(set) var phase = Phase.editing
+ private let deliver: @Sendable (String) async -> ProblemOutcome
+
+ /// `deliver` sends the note (with whatever else the report carries) and
+ /// answers what became of it. Call supplies one; the tests supply theirs.
+ public init(deliver: @escaping @Sendable (String) async -> ProblemOutcome) {
+ self.deliver = deliver
+ }
+
+ /// "312/1000".
+ public var counter: String { "\(note.unicodeScalars.count)/\(QualityBounds.note)" }
+ /// The counter, for VoiceOver: "312 of 1000 characters".
+ public var counterWords: String { "\(note.unicodeScalars.count) of \(QualityBounds.note) characters" }
+ public var canSend: Bool {
+ switch phase {
+ case .editing, .refused: true
+ case .sending, .sent: false
+ }
+ }
+
+ public func send() async {
+ guard canSend else { return }
+ phase = .sending
+ let outcome = await deliver(note)
+ switch outcome {
+ case .sent(let ref): phase = .sent(ref: ref)
+ default: phase = .refused(ProblemWords.problem(outcome) ?? ProblemWords.failed)
+ }
+ }
+}
+
+/// The body a report is sent as: protocol.ProblemReport.
+struct ProblemReportBody: Encodable {
+ let note: String
+ let diagnostics: String
+ let client: QualityClient
+
+ /// JSON within the server's 64 KB: diagnostics are cut from the front,
+ /// keeping the newest lines, which are the ones about the problem.
+ func encoded() -> String {
+ var diagnostics = self.diagnostics
+ while true {
+ let body = ProblemReportBody(note: note, diagnostics: diagnostics, client: client)
+ let data = (try? JSONEncoder().encode(body)) ?? Data()
+ if data.count <= QualityBounds.problemBody - 256 || diagnostics.isEmpty {
+ return String(data: data, encoding: .utf8) ?? "{}"
+ }
+ diagnostics = "(earlier lines cut)\n" + String(diagnostics.suffix(diagnostics.count * 3 / 4))
+ }
+ }
+}
+
+/// The sheet.
+public struct ReportProblemSheet: View {
+ @ObservedObject var model: ProblemReportModel
+ let done: () -> Void
+ @State private var copied = false
+ @FocusState private var noteFocused: Bool
+
+ public init(model: ProblemReportModel, done: @escaping () -> Void) {
+ self.model = model
+ self.done = done
+ }
+
+ public var body: some View {
+ NavigationStack {
+ Form {
+ if case .sent(let ref) = model.phase {
+ sent(ref)
+ } else {
+ compose
+ }
+ }
+ .formStyle(.grouped)
+ .navigationTitle(ProblemWords.title)
+ #if os(iOS)
+ .navigationBarTitleDisplayMode(.inline)
+ #endif
+ .toolbar {
+ if case .sent = model.phase {
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Done", action: done).accessibilityIdentifier("reportProblemDone")
+ }
+ } else {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel", action: done).accessibilityIdentifier("reportProblemCancel")
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Send") { Task { await model.send() } }
+ .disabled(!model.canSend)
+ .accessibilityIdentifier("reportProblemSend")
+ }
+ }
+ }
+ }
+ #if os(macOS)
+ .frame(width: 440)
+ .frame(minHeight: 340)
+ #endif
+ .tint(CallsAppearance.accent)
+ }
+
+ @ViewBuilder private var compose: some View {
+ Section {
+ TextEditor(text: $model.note)
+ .frame(minHeight: 110)
+ .focused($noteFocused)
+ .accessibilityLabel(ProblemWords.noteLabel)
+ .accessibilityHint("Optional")
+ .accessibilityIdentifier("reportProblemNote")
+ .disabled(model.phase == .sending)
+ HStack {
+ Text("Optional").foregroundStyle(.secondary)
+ Spacer()
+ Text(model.counter).monospacedDigit().foregroundStyle(.secondary)
+ .accessibilityLabel(model.counterWords)
+ .accessibilityIdentifier("reportProblemCounter")
+ }
+ .font(.footnote)
+ } header: {
+ Text(ProblemWords.noteLabel)
+ } footer: {
+ Text(ProblemWords.disclosure)
+ .fixedSize(horizontal: false, vertical: true)
+ .accessibilityIdentifier("reportProblemDisclosure")
+ }
+ if model.phase == .sending {
+ Section { ProgressView("Sending…") }
+ }
+ if case .refused(let words) = model.phase {
+ Section {
+ Label(words, systemImage: "exclamationmark.circle.fill")
+ .foregroundStyle(.red).fixedSize(horizontal: false, vertical: true)
+ .accessibilityLabel("Error: \(words)")
+ .accessibilityIdentifier("reportProblemError")
+ }
+ }
+ }
+
+ @ViewBuilder private func sent(_ ref: String) -> some View {
+ Section {
+ Label(ProblemWords.sent, systemImage: "checkmark.circle.fill")
+ .font(.headline)
+ .accessibilityIdentifier("reportProblemSent")
+ HStack {
+ Text(ProblemWords.reference(ref))
+ .font(.body.monospaced())
+ .textSelection(.enabled)
+ .accessibilityIdentifier("reportProblemReference")
+ Spacer()
+ Button(copied ? "Copied" : "Copy", systemImage: "doc.on.doc") {
+ AudioDiagnosticsReport.copy(ref)
+ copied = true
+ }
+ #if os(iOS)
+ .buttonStyle(.borderless)
+ #endif
+ .accessibilityLabel(copied ? "Reference copied" : "Copy reference")
+ .accessibilityIdentifier("reportProblemCopy")
+ }
+ } footer: {
+ Text("Quote the reference if you write to us about this call.")
+ }
+ }
+}
+
+/// "Report a problem" as a plain button, wherever it appears.
+public struct ReportProblemButton: View {
+ let action: () -> Void
+ public init(action: @escaping () -> Void) { self.action = action }
+ public var body: some View {
+ Button(ProblemWords.title, systemImage: "exclamationmark.bubble", action: action)
+ .accessibilityIdentifier("reportProblem")
+ }
+}
+
+extension View {
+ /// The sheet, shown while `call.problemReport` is set.
+ public func reportProblemSheet(_ call: Call) -> some View {
+ modifier(ReportProblemPresenter(call: call))
+ }
+}
+
+private struct ReportProblemPresenter: ViewModifier {
+ @ObservedObject var call: Call
+ func body(content: Content) -> some View {
+ content.sheet(item: $call.problemReport) { model in
+ ReportProblemSheet(model: model) { call.problemReport = nil }
+ #if os(iOS)
+ .presentationDetents([.medium, .large])
+ #endif
+ }
+ }
+}
diff --git a/native/apple/Sources/CallsKit/ServerSettings.swift b/native/apple/Sources/CallsKit/ServerSettings.swift
index 0de8899..e655f62 100644
--- a/native/apple/Sources/CallsKit/ServerSettings.swift
+++ b/native/apple/Sources/CallsKit/ServerSettings.swift
@@ -126,6 +126,7 @@ import SwiftUI
)
}
ServerSettingsSection(model: model)
+ QualityReportsSection()
}
.formStyle(.grouped)
.navigationTitle("Settings")
@@ -139,3 +140,25 @@ import SwiftUI
.tint(CallsAppearance.accent)
}
}
+
+/// The automatic quality reports (spec §15.26): said, and a switch. Off,
+/// no automatic reports go; "Report a problem" still works, because that
+/// one is sent by the person on purpose.
+@MainActor public struct QualityReportsSection: View {
+ @AppStorage(QualityReporting.enabledKey) private var send = true
+
+ public init() {}
+
+ public var body: some View {
+ Section {
+ Toggle("Send anonymous quality reports", isOn: $send)
+ .accessibilityIdentifier("qualityReports")
+ } header: {
+ Text("Call quality")
+ } footer: {
+ Text(
+ QualityReporting.disclosure
+ + " They hold connection numbers only — no names, no audio or video, nothing you type.")
+ }
+ }
+}
diff --git a/native/apple/Sources/CallsMac/CallsMac.swift b/native/apple/Sources/CallsMac/CallsMac.swift
index 93e0348..31b4ab4 100644
--- a/native/apple/Sources/CallsMac/CallsMac.swift
+++ b/native/apple/Sources/CallsMac/CallsMac.swift
@@ -127,6 +127,10 @@ private struct CallMenuButtons: View {
== .unavailable)
}
}
+ Divider()
+ // Spec §15.26: during a call, and for ten minutes after it.
+ Button("Report a Problem\u{2026}") { call.reportProblem() }
+ .disabled(!call.canReportProblem)
}
}
diff --git a/native/apple/Tests/CallsKitTests/QualityReportTests.swift b/native/apple/Tests/CallsKitTests/QualityReportTests.swift
new file mode 100644
index 0000000..b7e8927
--- /dev/null
+++ b/native/apple/Tests/CallsKitTests/QualityReportTests.swift
@@ -0,0 +1,450 @@
+import XCTest
+
+@testable @_spi(Fixtures) import CallsKit
+
+/// Spec §15.26: the quality report built from fixture statistics — counts
+/// over the window, rates, the schema's enums and bounds — and nothing in
+/// it that names a person, a device or an address; the notices tallied;
+/// the device kinds; "Report a problem"'s anonymous diagnostics and its
+/// sheet's states.
+final class QualityReportTests: XCTestCase {
+ // MARK: - Fixtures
+
+ static let client = QualityClient(app: "mac", version: "0.4.8", os: "macos", osVersion: "26.0.1")
+
+ /// One connection's statistics, as WebRTC's report has them, with the
+ /// things that must never be reported planted in it: an address, a
+ /// port, a name in a track's label, a device's name.
+ static func sample(
+ generation: Int64 = 1, scale: Double = 1, screen: Bool = false, lostOut: Double = 3,
+ relayTLS: Bool = true, nan: Bool = false
+ ) -> QualityPeerSample {
+ var stats: [String: QualityStat] = [
+ "OA": QualityStat(
+ type: "outbound-rtp", numbers: ["bytesSent": 120_000 * scale, "packetsSent": 1500 * scale],
+ strings: ["kind": "audio", "mid": "0", "trackIdentifier": "Vicky Wallace's microphone"]),
+ "RIA": QualityStat(
+ type: "remote-inbound-rtp",
+ numbers: ["packetsLost": lostOut * scale, "roundTripTime": 0.042, "jitter": 0.004],
+ strings: ["kind": "audio", "localId": "OA"]),
+ "OV": QualityStat(
+ type: "outbound-rtp",
+ numbers: [
+ "bytesSent": 1_500_000 * scale, "packetsSent": 1800 * scale, "frameWidth": 1280, "frameHeight": 720,
+ "framesPerSecond": nan ? .nan : 29.97,
+ ],
+ strings: ["kind": "video", "mid": "1", "qualityLimitationReason": "bandwidth"]),
+ "IA": QualityStat(
+ type: "inbound-rtp",
+ numbers: [
+ "bytesReceived": 110_000 * scale, "packetsReceived": 1490 * scale, "packetsLost": 10 * scale,
+ "jitter": 0.012, "totalSamplesReceived": 1_440_000 * scale, "concealedSamples": 4800 * scale,
+ "insertedSamplesForDeceleration": 960 * scale, "removedSamplesForAcceleration": 480 * scale,
+ "jitterBufferDelay": 72_000 * 0.06 * scale, "jitterBufferEmittedCount": 72_000 * scale,
+ ],
+ strings: ["kind": "audio", "mid": "0"]),
+ "IV": QualityStat(
+ type: "inbound-rtp",
+ numbers: [
+ "bytesReceived": 1_200_000 * scale, "packetsReceived": 1700 * scale, "packetsLost": 5 * scale,
+ "jitter": 0.02, "freezeCount": 1 * scale, "totalFreezesDuration": 0.5 * scale,
+ "framesDecoded": 890 * scale, "framesDropped": 2 * scale, "frameWidth": 640, "frameHeight": 480,
+ "framesPerSecond": 30,
+ ],
+ strings: ["kind": "video", "mid": "1"]),
+ "T": QualityStat(type: "transport", strings: ["selectedCandidatePairId": "CP"]),
+ "CP": QualityStat(
+ type: "candidate-pair", numbers: ["currentRoundTripTime": 0.038, "nominated": 1],
+ strings: ["localCandidateId": "LC", "remoteCandidateId": "RC", "state": "succeeded"]),
+ "LC": QualityStat(
+ type: "local-candidate", numbers: ["port": 54321],
+ strings: [
+ "candidateType": relayTLS ? "relay" : "host", "protocol": "udp", "relayProtocol": "tls",
+ "address": "192.168.1.20", "url": "turns:relay.example.com:443",
+ ]),
+ "RC": QualityStat(
+ type: "remote-candidate", numbers: ["port": 61000],
+ strings: ["candidateType": "srflx", "protocol": "udp", "address": "203.0.113.7"]),
+ "MS": QualityStat(type: "media-source", strings: ["kind": "audio", "deviceName": "Paul's AirPods Pro"]),
+ ]
+ if screen {
+ stats["OS"] = QualityStat(
+ type: "outbound-rtp",
+ numbers: [
+ "bytesSent": 3_000_000 * scale, "packetsSent": 3000 * scale, "frameWidth": 1920, "frameHeight": 1080,
+ "framesPerSecond": 8,
+ ],
+ strings: ["kind": "video", "mid": "3", "qualityLimitationReason": "cpu"])
+ stats["IS"] = QualityStat(
+ type: "inbound-rtp",
+ numbers: ["bytesReceived": 900_000 * scale, "framesDecoded": 300 * scale, "freezeCount": 2 * scale],
+ strings: ["kind": "video", "mid": "4"])
+ }
+ return QualityPeerSample(
+ generation: generation, ownScreenMid: screen ? "3" : nil, screenMid: screen ? "4" : "", stats: stats)
+ }
+
+ func build(
+ _ builder: inout QualityReportBuilder, at now: Double, _ samples: [String: QualityPeerSample],
+ tally: QualityEventTally = QualityEventTally(), final: Bool = false
+ ) -> QualityReport {
+ var tally = tally
+ return builder.build(
+ at: now, samples: samples, events: &tally, client: Self.client,
+ devices: QualityDevices(input: "bluetooth", output: "built-in"), final: final)
+ }
+
+ // MARK: - Counts over the window
+
+ func testCountsAreTheWindowsAndRatesAreOverIt() throws {
+ var builder = QualityReportBuilder()
+ builder.start(at: 100)
+ _ = build(&builder, at: 130, ["abc123def": Self.sample(scale: 1)])
+ // Thirty seconds later every counter has doubled: the window's counts
+ // are the second half alone.
+ let report = build(&builder, at: 160, ["abc123def": Self.sample(scale: 2)])
+ XCTAssertEqual(report.seconds, 30)
+ XCTAssertNil(report.isFinal)
+ let peer = try XCTUnwrap(report.peers?.first)
+ XCTAssertEqual(peer.peer, "abc123def")
+ let outAudio = try XCTUnwrap(peer.out?.audio)
+ XCTAssertEqual(outAudio.packets, 1500)
+ XCTAssertEqual(outAudio.kbps, 32, "120,000 bytes in 30 s is 32 kbit/s")
+ XCTAssertEqual(outAudio.lost, 3)
+ XCTAssertEqual(outAudio.rtt, 42)
+ XCTAssertEqual(outAudio.jitter, 4)
+ let outVideo = try XCTUnwrap(peer.out?.video)
+ XCTAssertEqual(outVideo.kbps, 400)
+ XCTAssertEqual(outVideo.limit, "bandwidth")
+ XCTAssertEqual(outVideo.width, 1280)
+ XCTAssertEqual(outVideo.fps, 30, "rounded to a tenth")
+ let inAudio = try XCTUnwrap(peer.in?.audio)
+ XCTAssertEqual(inAudio.packets, 1490)
+ XCTAssertEqual(inAudio.lost, 10)
+ XCTAssertEqual(inAudio.jitter, 12)
+ XCTAssertEqual(inAudio.samples, 1_440_000)
+ XCTAssertEqual(inAudio.concealed, 4800)
+ XCTAssertEqual(inAudio.inserted, 960)
+ XCTAssertEqual(inAudio.removed, 480)
+ XCTAssertEqual(inAudio.jitterBufferMs, 60)
+ let inVideo = try XCTUnwrap(peer.in?.video)
+ XCTAssertEqual(inVideo.freezes, 1)
+ XCTAssertEqual(inVideo.freezeSeconds, 0.5)
+ XCTAssertEqual(inVideo.decoded, 890)
+ XCTAssertEqual(inVideo.dropped, 2)
+ XCTAssertNil(peer.in?.screen)
+ XCTAssertNil(peer.out?.screen)
+ XCTAssertEqual(peer.path, QualityPath(local: "relay", remote: "srflx", protocol: "tls"))
+ XCTAssertEqual(peer.rtt, 38)
+ }
+
+ func testTheFirstWindowOfAConnectionCountsFromItsStart() throws {
+ var builder = QualityReportBuilder()
+ builder.start(at: 0)
+ let report = build(&builder, at: 30, ["p1": Self.sample(scale: 1)])
+ XCTAssertEqual(report.peers?.first?.out?.audio?.packets, 1500)
+ }
+
+ func testANewGenerationOrACounterGoingBackStartsAgain() throws {
+ var builder = QualityReportBuilder()
+ builder.start(at: 0)
+ _ = build(&builder, at: 30, ["p1": Self.sample(generation: 1, scale: 4)])
+ // A new connection: its counts are its own, not less than the old one's.
+ var report = build(&builder, at: 60, ["p1": Self.sample(generation: 2, scale: 1)])
+ XCTAssertEqual(report.peers?.first?.in?.audio?.packets, 1490)
+ // The same connection, a counter gone backwards (a receiver rebuilt).
+ report = build(&builder, at: 90, ["p1": Self.sample(generation: 2, scale: 0.5)])
+ XCTAssertEqual(report.peers?.first?.in?.audio?.packets, 745)
+ }
+
+ func testScreensAreToldApartByMid() throws {
+ var builder = QualityReportBuilder()
+ builder.start(at: 0)
+ let report = build(&builder, at: 30, ["p1": Self.sample(screen: true)])
+ let peer = try XCTUnwrap(report.peers?.first)
+ XCTAssertEqual(peer.out?.screen?.width, 1920)
+ XCTAssertEqual(peer.out?.screen?.limit, "cpu")
+ XCTAssertEqual(peer.out?.video?.width, 1280, "the camera is not the screen")
+ XCTAssertEqual(peer.in?.screen?.decoded, 300)
+ XCTAssertEqual(peer.in?.screen?.freezes, 2)
+ XCTAssertEqual(peer.in?.video?.decoded, 890)
+ }
+
+ func testBoundsNaNAndNegativesAndThePeerCeiling() throws {
+ var builder = QualityReportBuilder()
+ builder.start(at: 0)
+ var samples: [String: QualityPeerSample] = [:]
+ for i in 0..<6 { samples["peer\(i)"] = Self.sample(lostOut: -5, nan: true) }
+ // A window longer than the schema's ten minutes.
+ let report = build(&builder, at: 900, samples)
+ XCTAssertEqual(report.seconds, 600)
+ XCTAssertEqual(report.peers?.count, 4, "no more than the schema's four")
+ XCTAssertEqual(report.peers?.map(\.peer), ["peer0", "peer1", "peer2", "peer3"])
+ let video = try XCTUnwrap(report.peers?.first?.out?.video)
+ XCTAssertNil(video.fps, "NaN is left out, not sent")
+ XCTAssertNil(report.peers?.first?.out?.audio?.lost, "a negative count is left out")
+ XCTAssertEqual(QualityReportBuilder.bound(2e6, QualityBounds.kbps), 1e6)
+ XCTAssertNil(QualityReportBuilder.bound(.infinity, 10))
+ XCTAssertNil(QualityReportBuilder.bound(-1, 10))
+ XCTAssertEqual(QualityReportBuilder.bound(1.26, 10), 1.3)
+ }
+
+ func testTheEnums() {
+ XCTAssertEqual(QualityWords.limit("none"), "none")
+ XCTAssertEqual(QualityWords.limit("cpu"), "cpu")
+ XCTAssertEqual(QualityWords.limit("something new"), "other")
+ XCTAssertNil(QualityWords.limit(""))
+ XCTAssertEqual(QualityWords.candidate("prflx"), "prflx")
+ XCTAssertEqual(QualityWords.candidate("mystery"), "unknown")
+ XCTAssertEqual(QualityWords.candidate(nil), "unknown")
+ XCTAssertEqual(QualityWords.transport("tcp", relayProtocol: nil, relay: false), "tcp")
+ XCTAssertEqual(QualityWords.transport("udp", relayProtocol: "tls", relay: true), "tls")
+ XCTAssertEqual(QualityWords.transport("udp", relayProtocol: "tls", relay: false), "udp")
+ XCTAssertEqual(QualityWords.transport(nil, relayProtocol: nil, relay: false), "unknown")
+ }
+
+ // MARK: - Nothing that names anyone
+
+ func testTheEncodedReportCarriesNoNamesAddressesOrDevices() throws {
+ var builder = QualityReportBuilder()
+ builder.start(at: 0)
+ var tally = QualityEventTally()
+ tally.observe(at: 1, call: false, reconnecting: ["p1"], cantHear: [])
+ let report = build(&builder, at: 30, ["p1": Self.sample(screen: true)], tally: tally, final: true)
+ let json = try XCTUnwrap(report.encoded())
+ for forbidden in [
+ "Vicky", "Wallace", "AirPods", "Paul", "192.168", "203.0.113", "54321", "61000", "relay.example.com",
+ "turns:", "address", "trackIdentifier", "deviceName",
+ ] {
+ XCTAssertFalse(json.contains(forbidden), "the report carries \(forbidden): \(json)")
+ }
+ // And it is the schema's shape.
+ let object = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any])
+ XCTAssertEqual(object["v"] as? Int, 1)
+ XCTAssertEqual(object["final"] as? Bool, true)
+ XCTAssertEqual(object["seconds"] as? Double, 30)
+ XCTAssertEqual((object["client"] as? [String: String])?["app"], "mac")
+ XCTAssertEqual((object["devices"] as? [String: String])?["input"], "bluetooth")
+ let peer = try XCTUnwrap((object["peers"] as? [[String: Any]])?.first)
+ XCTAssertNotNil(peer["in"], "\"in\" is the key, as the schema spells it")
+ XCTAssertNotNil((peer["path"] as? [String: Any])?["protocol"])
+ XCTAssertEqual((peer["events"] as? [String: Any])?["reconnecting"] as? Double, 1)
+ XCTAssertNil(object["isFinal"])
+ }
+
+ @MainActor func testTheClientIsDigitsAndDots() {
+ XCTAssertEqual(QualityClientInfo.version("0.4.8"), "0.4.8")
+ XCTAssertNil(QualityClientInfo.version("0.4.8 (dev)"))
+ XCTAssertNil(QualityClientInfo.version("dev"))
+ XCTAssertNil(QualityClientInfo.version("1..2"))
+ XCTAssertNil(QualityClientInfo.version(nil))
+ XCTAssertEqual(
+ QualityClientInfo.osVersion(OperatingSystemVersion(majorVersion: 26, minorVersion: 0, patchVersion: 1)), "26.0.1")
+ let current = QualityClientInfo.current()
+ XCTAssertEqual(current.app, "mac")
+ XCTAssertEqual(current.os, "macos")
+ XCTAssertNotNil(current.osVersion)
+ }
+
+ // MARK: - Notices
+
+ func testNoticesAreCountedWhenTheyBeginAndTimedWhileTheyShow() {
+ var tally = QualityEventTally()
+ tally.observe(at: 0, call: false, reconnecting: [], cantHear: ["b"])
+ tally.observe(at: 0.2, call: true, reconnecting: ["a"], cantHear: ["b"], noSound: ["a"])
+ tally.observe(at: 0.4, call: true, reconnecting: ["a"], cantHear: [])
+ // A gap of a minute (the Mac asleep) counts as no more than a second.
+ tally.observe(at: 60.4, call: false, reconnecting: [], cantHear: [], noSoundFromUs: ["c"], audioBlocked: ["c"])
+ tally.count(.keyframe, for: "a")
+ tally.count(.reattach, for: "c")
+ var builder = QualityReportBuilder()
+ builder.start(at: 0)
+ var mutable = tally
+ let report = builder.build(
+ at: 61, samples: [:], events: &mutable, client: Self.client, devices: nil, final: false)
+ XCTAssertEqual(report.events?.reconnecting, 1)
+ // Each look counts the time since the one before for what it sees.
+ XCTAssertEqual(report.events?.reconnectingSeconds, 0.4)
+ let peers = Dictionary(uniqueKeysWithValues: (report.peers ?? []).map { ($0.peer, $0) })
+ XCTAssertEqual(peers["a"]?.events?.reconnecting, 1)
+ XCTAssertEqual(peers["a"]?.events?.reconnectingSeconds, 0.4)
+ XCTAssertEqual(peers["a"]?.events?.noSound, true)
+ XCTAssertEqual(peers["a"]?.out?.screen?.keyframes, 1)
+ XCTAssertEqual(peers["b"]?.events?.cantHearSeconds, 0.2)
+ XCTAssertEqual(peers["c"]?.events?.noSoundFromUs, true)
+ XCTAssertEqual(peers["c"]?.events?.audioBlocked, true)
+ XCTAssertEqual(peers["c"]?.in?.screen?.reattaches, 1)
+ XCTAssertNil(peers["b"]?.events?.noSound)
+ }
+
+ func testANoticeStillShowingIsNotCountedAgainInTheNextWindow() {
+ var tally = QualityEventTally()
+ tally.observe(at: 0, call: true, reconnecting: ["a"], cantHear: [])
+ tally.observe(at: 1, call: true, reconnecting: ["a"], cantHear: [])
+ var builder = QualityReportBuilder()
+ builder.start(at: 0)
+ _ = builder.build(at: 1, samples: [:], events: &tally, client: Self.client, devices: nil, final: false)
+ tally.observe(at: 2, call: true, reconnecting: ["a"], cantHear: [])
+ let report = builder.build(at: 2, samples: [:], events: &tally, client: Self.client, devices: nil, final: false)
+ XCTAssertNil(report.events?.reconnecting, "the same notice, carried over")
+ XCTAssertEqual(report.events?.reconnectingSeconds, 1)
+ XCTAssertNil(report.peers?.first?.events?.reconnecting)
+ XCTAssertEqual(report.peers?.first?.events?.reconnectingSeconds, 1)
+ }
+
+ // MARK: - Devices
+
+ func testDeviceKindsFromCoreAudioTransports() {
+ XCTAssertEqual(DeviceKind.mac(transport: "built-in"), "built-in")
+ XCTAssertEqual(DeviceKind.mac(transport: "bluetooth"), "bluetooth")
+ XCTAssertEqual(DeviceKind.mac(transport: "usb"), "usb")
+ XCTAssertEqual(DeviceKind.mac(transport: "airplay"), "airplay")
+ XCTAssertEqual(DeviceKind.mac(transport: "virtual"), "virtual")
+ XCTAssertEqual(DeviceKind.mac(transport: "aggregate"), "virtual")
+ XCTAssertEqual(DeviceKind.mac(transport: "continuity"), "continuity")
+ XCTAssertEqual(DeviceKind.mac(transport: "thunderbolt"), "other")
+ XCTAssertEqual(DeviceKind.mac(transport: "0x12345678"), "other")
+ XCTAssertEqual(DeviceKind.mac(transport: nil), "unknown")
+ }
+
+ func testDeviceKindsFromAudioSessionPorts() {
+ // AVAudioSession.Port's raw values.
+ for (port, kind) in [
+ ("MicrophoneBuiltIn", "built-in"), ("Speaker", "built-in"), ("Receiver", "built-in"),
+ ("BluetoothHFP", "bluetooth"), ("BluetoothA2DPOutput", "bluetooth"), ("BluetoothLEOutput", "bluetooth"),
+ ("USBAudio", "usb"), ("Headphones", "wired"), ("MicrophoneWired", "wired"), ("AirPlay", "airplay"),
+ ("CarAudio", "other"), ("LineIn", "other"),
+ ] {
+ XCTAssertEqual(DeviceKind.ios(port: port), kind, port)
+ }
+ XCTAssertEqual(DeviceKind.ios(port: nil), "none")
+ }
+
+ func testEveryKindIsOneTheSchemaAllows() {
+ let allowed: Set = ["built-in", "bluetooth", "usb", "wired", "airplay", "virtual", "continuity", "other", "none", "unknown"]
+ for t in ["built-in", "bluetooth", "usb", "airplay", "virtual", "aggregate", "continuity", "display", "", "x"] {
+ XCTAssertTrue(allowed.contains(DeviceKind.mac(transport: t)))
+ }
+ for p in ["Speaker", "BluetoothHFP", "USBAudio", "Headphones", "AirPlay", "CarAudio", "", "x"] {
+ XCTAssertTrue(allowed.contains(DeviceKind.ios(port: p)))
+ }
+ }
+
+ // MARK: - Report a problem
+
+ static let roster = #"""
+ {"phase":"admitted","peer":"me0000aaaa","boot":"b","waiting":[],"peers":[
+ {"peer":"me0000aaaa","name":"Paul Campbell","kind":"member","gen":1},
+ {"peer":"abc123zzzz","name":"Vicky Wallace","kind":"member","gen":2,
+ "media":{"muted":true,"cameraOff":false,"noSoundFrom":["me0000aaaa"],"hearing":[]}}
+ ]}
+ """#
+
+ @MainActor func testTheAnonymousDiagnosticsNameNobody() {
+ let call = Call(playAudio: false)
+ call.showFixture(title: "Planning with Vicky", state: Self.roster)
+ let named = call.audioDiagnosticsText()
+ XCTAssertTrue(named.contains("Vicky Wallace: "), "Copy call diagnostics still names people")
+ let anonymous = call.anonymousDiagnosticsText()
+ XCTAssertTrue(anonymous.contains("peer abc123: "), anonymous)
+ XCTAssertTrue(anonymous.contains("muted"))
+ XCTAssertTrue(anonymous.contains("hears no sound from you"))
+ for forbidden in ["Vicky", "Wallace", "Paul Campbell", "Planning"] {
+ XCTAssertFalse(anonymous.contains(forbidden), "the report names \(forbidden)")
+ }
+ }
+
+ func testTheBridgesAnswers() {
+ XCTAssertEqual(ProblemOutcome(bridgeAnswer: #"{"ref":"K7Q2M9PD"}"#), .sent(ref: "K7Q2M9PD"))
+ XCTAssertEqual(ProblemOutcome(bridgeAnswer: #"{"outcome":"too-many"}"#), .tooMany)
+ XCTAssertEqual(ProblemOutcome(bridgeAnswer: #"{"outcome":"gone"}"#), .gone)
+ XCTAssertEqual(ProblemOutcome(bridgeAnswer: #"{"outcome":"failed"}"#), .failed)
+ XCTAssertEqual(ProblemOutcome(bridgeAnswer: "nonsense"), .failed)
+ XCTAssertEqual(ProblemOutcome(bridgeAnswer: #"{"ref":""}"#), .failed)
+ }
+
+ @MainActor func testTheSheetSendsAndShowsTheReference() async {
+ let sent = Box()
+ let model = ProblemReportModel { note in
+ await sent.set(note)
+ return .sent(ref: "K7Q2M9PD")
+ }
+ XCTAssertEqual(model.phase, .editing)
+ XCTAssertTrue(model.canSend, "the note is optional")
+ model.note = "Vicky sounded robotic"
+ XCTAssertEqual(model.counter, "21/1000")
+ await model.send()
+ XCTAssertEqual(model.phase, .sent(ref: "K7Q2M9PD"))
+ XCTAssertFalse(model.canSend, "sent once")
+ let note = await sent.value
+ XCTAssertEqual(note, "Vicky sounded robotic")
+ XCTAssertEqual(ProblemWords.reference("K7Q2M9PD"), "Reference K7Q2M9PD")
+ }
+
+ @MainActor func testTheSheetSaysWhyNotAndCanTryAgain() async {
+ let answers = Answers([.tooMany, .gone, .failed, .sent(ref: "ABCDEFGH")])
+ let model = ProblemReportModel { _ in await answers.next() }
+ await model.send()
+ XCTAssertEqual(model.phase, .refused("Too many reports from this call — try again in a minute."))
+ XCTAssertTrue(model.canSend)
+ await model.send()
+ XCTAssertEqual(model.phase, .refused("This call can no longer be reported."))
+ await model.send()
+ XCTAssertEqual(model.phase, .refused("The report could not be sent."))
+ await model.send()
+ XCTAssertEqual(model.phase, .sent(ref: "ABCDEFGH"))
+ }
+
+ @MainActor func testTheNoteIsAThousandCharactersAsTheServerCountsThem() {
+ let model = ProblemReportModel { _ in .failed }
+ model.note = String(repeating: "é", count: 1200)
+ XCTAssertEqual(model.note.unicodeScalars.count, 1000)
+ XCTAssertEqual(model.counter, "1000/1000")
+ }
+
+ func testTheWordsAreTheRulings() {
+ XCTAssertEqual(
+ ProblemWords.disclosure, "Sends this call's connection diagnostics to the Calls server log. No audio or video.")
+ XCTAssertEqual(ProblemWords.sent, "Sent — thank you")
+ XCTAssertEqual(
+ QualityReporting.disclosure, "Calls sends anonymous connection quality reports to help fix call problems.")
+ }
+
+ func testAProblemReportFitsTheServersLimit() throws {
+ let body = ProblemReportBody(
+ note: "x", diagnostics: String(repeating: "line of diagnostics\n", count: 10_000), client: Self.client
+ ).encoded()
+ XCTAssertLessThanOrEqual(body.utf8.count, QualityBounds.problemBody)
+ let object = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(body.utf8)) as? [String: Any])
+ XCTAssertEqual(object["note"] as? String, "x")
+ XCTAssertTrue((object["diagnostics"] as? String)?.hasPrefix("(earlier lines cut)") == true)
+ }
+
+ func testTheReportingSwitchIsOnUnlessTurnedOff() throws {
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: "quality-\(UUID().uuidString)"))
+ XCTAssertTrue(QualityReporting.enabled(defaults))
+ defaults.set(false, forKey: QualityReporting.enabledKey)
+ XCTAssertFalse(QualityReporting.enabled(defaults))
+ }
+
+ @MainActor func testAnEndedCallOffersReportAProblem() {
+ let ended = CallNotices.notices(
+ phase: "ended", status: "This meeting has ended", sharingScreen: false, screenNote: "", floor: false)
+ XCTAssertEqual(ended.first?.action, .reportProblem)
+ let call = Call(playAudio: false)
+ XCTAssertFalse(call.canReportProblem, "no call, nothing to report")
+ call.reportProblem()
+ XCTAssertNil(call.problemReport)
+ }
+}
+
+private actor Box {
+ var value = ""
+ func set(_ v: String) { value = v }
+}
+
+private actor Answers {
+ var queue: [ProblemOutcome]
+ init(_ queue: [ProblemOutcome]) { self.queue = queue }
+ func next() -> ProblemOutcome { queue.removeFirst() }
+}
diff --git a/native/bridge/bridge.go b/native/bridge/bridge.go
index 5831dfb..57b3dc7 100644
--- a/native/bridge/bridge.go
+++ b/native/bridge/bridge.go
@@ -157,6 +157,45 @@ func (c *Client) SetHearing(peersJSON string) error {
return c.room.SetHearing(ids)
}
+// SendQuality posts one quality report (spec §15.26), a
+// protocol.QualityReport as JSON, with the hello's report token. It
+// answers what became of it, as a word — gomobile hands Swift every error
+// as the same NSError, so an outcome the host acts on is a value:
+//
+// - "sent": taken;
+// - "stop": the server's kill switch; send no more this call;
+// - "too-many", "gone", "failed": skipped; the next window tries again.
+//
+// It works after Leave and Close (the final report goes at leave), and
+// blocks for up to ten seconds: call it off the main thread.
+func (c *Client) SendQuality(report string) string {
+ stop, err := c.room.SendQuality([]byte(report))
+ if err == nil && stop {
+ return string(callscore.ReportStop)
+ }
+ return string(callscore.OutcomeOf(err))
+}
+
+// SendProblem posts "Report a problem" (spec §15.26), a
+// protocol.ProblemReport as JSON, and answers JSON: {"ref":"K7Q2M9PD"}
+// when the server took it, or {"outcome":"too-many"|"gone"|"failed"}.
+// Like SendQuality it works for as long as the server keeps the token —
+// ten minutes past the call — and blocks; call it off the main thread.
+func (c *Client) SendProblem(report string) string {
+ ref, err := c.room.SendProblem([]byte(report))
+ var out struct {
+ Ref string `json:"ref,omitempty"`
+ Outcome string `json:"outcome,omitempty"`
+ }
+ if err == nil {
+ out.Ref = ref
+ } else {
+ out.Outcome = string(callscore.OutcomeOf(err))
+ }
+ b, _ := json.Marshal(out)
+ return string(b)
+}
+
// RegisterDevice hands the server an Apple device token so a knock can
// reach this device when the app is closed. Best effort: the caller
// ignores the error beyond logging, because a device that did not
diff --git a/native/bridge/bridge_test.go b/native/bridge/bridge_test.go
index 701f3b5..8d4b68c 100644
--- a/native/bridge/bridge_test.go
+++ b/native/bridge/bridge_test.go
@@ -6,6 +6,7 @@ import (
"net/http/httptest"
"sync/atomic"
"testing"
+ "time"
)
type discardEvents struct{}
@@ -114,3 +115,79 @@ func TestOpenLinkKeepsNoMeetingAndNoSessionApart(t *testing.T) {
t.Fatal("a replaced link opened")
}
}
+
+// TestReportsCarryTheHellosTokenAfterLeave is spec §15.26 through the
+// bridge: the token from the stream's hello goes on both report requests
+// with the session, the answers come back as values the host can act on,
+// and both still work once the call has been left.
+func TestReportsCarryTheHellosTokenAfterLeave(t *testing.T) {
+ const report = "0123456789abcdef0123456789abcdef"
+ session, cookies := loopbackSession()
+ var quality, problem atomic.Int32
+ hello := make(chan struct{})
+ var once atomic.Bool
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if c, err := r.Cookie("rastrillo_session"); err != nil || c.Value != session {
+ t.Error("session not sent")
+ }
+ switch r.URL.Path {
+ case "/m/room/stream":
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Write([]byte("data: {\"type\":\"hello\",\"peer\":\"self\",\"report\":\"" + report + "\"}\n\n"))
+ w.(http.Flusher).Flush()
+ if once.CompareAndSwap(false, true) {
+ close(hello)
+ }
+ <-r.Context().Done()
+ case "/m/room/quality":
+ if r.Header.Get("X-Calls-Report") != report {
+ t.Error("quality without the token")
+ }
+ if quality.Add(1) == 1 {
+ w.WriteHeader(http.StatusAccepted)
+ } else {
+ w.WriteHeader(http.StatusNoContent)
+ }
+ case "/m/room/problem":
+ if r.Header.Get("X-Calls-Report") != report {
+ t.Error("problem without the token")
+ }
+ if problem.Add(1) == 1 {
+ w.Write([]byte(`{"ref":"K7Q2M9PD"}`))
+ } else {
+ w.WriteHeader(http.StatusTooManyRequests)
+ }
+ case "/m/room/info":
+ w.Write([]byte(`{"title":"x"}`))
+ default:
+ // The join, and the leave.
+ w.WriteHeader(http.StatusOK)
+ }
+ }))
+ defer server.Close()
+ c, err := NewClient(server.URL, "room", cookies, discardEvents{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := c.SendQuality(`{}`); got != "gone" {
+ t.Fatalf("before the hello: %q", got)
+ }
+ if err := c.Start(); err != nil {
+ t.Fatal(err)
+ }
+ <-hello
+ // The hello is applied after it is read; give the core its turn.
+ for i := 0; i < 100 && c.SendQuality(`{"v":1}`) == "gone"; i++ {
+ time.Sleep(20 * time.Millisecond)
+ }
+ c.Leave()
+ if got := c.SendQuality(`{"v":1,"final":true}`); got != "stop" {
+ t.Fatalf("after leave, the kill switch: %q", got)
+ }
+ if got := c.SendProblem(`{"note":"x"}`); got != `{"ref":"K7Q2M9PD"}` {
+ t.Fatalf("problem: %s", got)
+ }
+ if got := c.SendProblem(`{"note":"x"}`); got != `{"outcome":"too-many"}` {
+ t.Fatalf("second problem: %s", got)
+ }
+}
diff --git a/native/ios/Sources/CallsPhone.swift b/native/ios/Sources/CallsPhone.swift
index 926362b..b644fda 100644
--- a/native/ios/Sources/CallsPhone.swift
+++ b/native/ios/Sources/CallsPhone.swift
@@ -256,6 +256,7 @@ struct PhoneCall: View {
.onChange(of: call.receivedChatCount) { _, count in
if showingChat { readChatCount = count }
}
+ .reportProblemSheet(call)
}
/// The call as it was before the full-screen layout: title and stage
@@ -331,6 +332,7 @@ struct PhoneCall: View {
}.frame(maxHeight: 90)
}
if call.state?.phase == "full" { Button("Try joining again") { call.retryJoin() } }
+ if call.state?.phase == "ended" { ReportProblemButton { call.reportProblem() }.buttonStyle(.bordered) }
}.padding(.horizontal, 16).padding(.top, 8)
.safeAreaInset(edge: .bottom) {
HStack(spacing: 14) {
diff --git a/native/ios/Sources/PhoneFullScreenCall.swift b/native/ios/Sources/PhoneFullScreenCall.swift
index b4b0129..194cf26 100644
--- a/native/ios/Sources/PhoneFullScreenCall.swift
+++ b/native/ios/Sources/PhoneFullScreenCall.swift
@@ -2,7 +2,7 @@
import SwiftUI
/// What the controls sheet was closed in order to open.
-enum MoreAction { case devices, chat }
+enum MoreAction { case devices, chat, report }
/// The full-screen call: the picture under the status bar and the home
/// indicator, both of which go with the controls. A tap shows the
@@ -59,6 +59,7 @@ extension PhoneCall {
case .chat?:
readChatCount = call.receivedChatCount
showingChat = true
+ case .report?: call.reportProblem()
case nil: break
}
afterMore = nil
@@ -528,6 +529,9 @@ struct MoreControls: View {
.accessibilityIdentifier("openChat")
}
}
+ // Spec §15.26: this call's diagnostics, sent rather than copied.
+ ReportProblemButton { open(.report) }
+ .disabled(!call.canReportProblem)
VStack(alignment: .leading, spacing: 4) {
Toggle("Reduce background noise", isOn: $call.reduceNoise)
.accessibilityIdentifier("noiseReduction")
diff --git a/native/ios/UITests/CallsPhoneUITests.swift b/native/ios/UITests/CallsPhoneUITests.swift
index 807feba..7ef1c47 100644
--- a/native/ios/UITests/CallsPhoneUITests.swift
+++ b/native/ios/UITests/CallsPhoneUITests.swift
@@ -210,6 +210,48 @@ final class CallsPhoneUITests: XCTestCase {
XCTAssertTrue(app.textFields["chatComposer"].waitForExistence(timeout: 5))
}
+ /// Spec §15.26: "Report a problem" from the call's controls menu opens
+ /// its own sheet — the optional note with its counter, the disclosure in
+ /// the ruling's words, Send and Cancel — and a sent report shows its
+ /// reference. The fixture call answers the report itself.
+ @MainActor func testReportAProblemFromTheCallScreen() throws {
+ XCUIDevice.shared.orientation = .portrait
+ let app = launchCall("focus", ["-CallsFixtureControls", "shown"])
+ let more = app.buttons["moreControls"]
+ XCTAssertTrue(more.waitForExistence(timeout: 15))
+ more.tap()
+ let report = app.buttons["reportProblem"]
+ XCTAssertTrue(report.waitForExistence(timeout: 5), "Report a problem is in the controls menu")
+ if !report.isHittable { app.swipeUp() }
+ report.tap()
+
+ let note = app.textViews["reportProblemNote"]
+ XCTAssertTrue(note.waitForExistence(timeout: 5), "the sheet opens once the menu has gone")
+ XCTAssertTrue(app.navigationBars["Report a problem"].exists)
+ XCTAssertTrue(
+ app.staticTexts["Sends this call's connection diagnostics to the Calls server log. No audio or video."].exists,
+ "the disclosure, word for word")
+ let counter = app.staticTexts["reportProblemCounter"]
+ XCTAssertTrue(counter.exists)
+ XCTAssertTrue(app.buttons["reportProblemCancel"].exists)
+ let send = app.buttons["reportProblemSend"]
+ XCTAssertTrue(send.isEnabled, "the note is optional")
+ note.tap()
+ note.typeText("Echo")
+ XCTAssertEqual(counter.label, "4 of 1000 characters", "the counter follows the note")
+ try snapshot(app, "report-problem")
+ send.tap()
+
+ let reference = app.staticTexts["reportProblemReference"]
+ XCTAssertTrue(reference.waitForExistence(timeout: 5))
+ XCTAssertEqual(reference.label, "Reference K7Q2M9PD")
+ XCTAssertTrue(app.staticTexts["Sent — thank you"].exists)
+ XCTAssertTrue(app.buttons["reportProblemCopy"].exists)
+ try snapshot(app, "report-problem-sent")
+ app.buttons["reportProblemDone"].tap()
+ XCTAssertFalse(reference.waitForExistence(timeout: 2), "Done puts it away")
+ }
+
/// No title pill (2026-10-02): a meeting someone named shows its title,
/// small and centred at the top, and nothing about how the call is going
/// beside it; a meeting Calls named ("Call, 24 Sep 10:21") shows none.