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>
25 files changed,
+2666
−19
callscore/report.go+175 −0callscore/report_test.go+204 −0callscore/room.go+7 −0internal/callstest/browser_test.go+154 −1internal/callstest/native_browser_test.go+43 −1native/apple/Sources/CallsInterop/Interop.swift+5 −0native/apple/Sources/CallsKit/Audio.swift+15 −0native/apple/Sources/CallsKit/AudioDevices.swift+2 −0native/apple/Sources/CallsKit/Call.swift+281 −7native/apple/Sources/CallsKit/CallChrome.swift+20 −6native/apple/Sources/CallsKit/CallsLobby.swift+23 −0native/apple/Sources/CallsKit/CallsSettingsView.swift+1 −0native/apple/Sources/CallsKit/DevicePanel.swift+18 −0native/apple/Sources/CallsKit/MacCallView.swift+9 −3native/apple/Sources/CallsKit/Media.swift+16 −0native/apple/Sources/CallsKit/QualityReport.swift+781 −0native/apple/Sources/CallsKit/ReportProblem.swift+270 −0native/apple/Sources/CallsKit/ServerSettings.swift+23 −0native/apple/Sources/CallsMac/CallsMac.swift+4 −0native/apple/Tests/CallsKitTests/QualityReportTests.swift+450 −0native/bridge/bridge.go+39 −0native/bridge/bridge_test.go+77 −0native/ios/Sources/CallsPhone.swift+2 −0native/ios/Sources/PhoneFullScreenCall.swift+5 −1native/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. |