mirror of
git://f0xx.org/android_cast
synced 2026-07-29 04:18:09 +03:00
sync with BE requested by AI Auto Agent
This commit is contained in:
149
docs/OPUS_SPEEX_VALIDATION.md
Normal file
149
docs/OPUS_SPEEX_VALIDATION.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Opus / Speex / stream protection — validation checklist (task 3)
|
||||
|
||||
E2E validation guide for **audio codecs** and **UDP stream protection** (FEC, NACK, negotiation). Code is on **`next`**; this doc is the operator checklist.
|
||||
|
||||
**See also:** [ndk/README.md](../ndk/README.md), [ROADMAP.md](ROADMAP.md), [AV_QUALITY_QA_SESSION.md](AV_QUALITY_QA_SESSION.md).
|
||||
|
||||
## Table of contents
|
||||
|
||||
<!-- toc -->
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Automated baseline (CI / dev machine)](#automated-baseline-ci-dev-machine)
|
||||
- [User settings (both peers)](#user-settings-both-peers)
|
||||
- [Developer settings](#developer-settings)
|
||||
- [E2E matrix — run on two devices](#e2e-matrix-run-on-two-devices)
|
||||
- [What to capture per run](#what-to-capture-per-run)
|
||||
- [Pass criteria](#pass-criteria)
|
||||
- [Known limits (next)](#known-limits-next)
|
||||
<!-- /toc -->
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Two Android devices (or sender + receiver) on **same LAN** for first pass; repeat subset on lossy/WAN if possible.
|
||||
- Debug builds with NDK codecs built if testing native Opus/Speex probes:
|
||||
|
||||
```bash
|
||||
./scripts/build-native-codecs.sh arm64-v8a # optional; see ndk/README.md
|
||||
./gradlew :app:assembleDebug
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Automated baseline (CI / dev machine)
|
||||
|
||||
Run before field soak:
|
||||
|
||||
```bash
|
||||
./gradlew :app:testDebugUnitTest --tests 'com.foxx.androidcast.network.transport.StreamProtectionNegotiatorTest'
|
||||
./gradlew :app:testDebugUnitTest --tests 'com.foxx.androidcast.media.AudioNegotiatorTest'
|
||||
./gradlew :app:testDebugUnitTest --tests 'com.foxx.androidcast.media.codec.*'
|
||||
```
|
||||
|
||||
Or:
|
||||
|
||||
```bash
|
||||
bash scripts/validate_opus_speex.sh
|
||||
```
|
||||
|
||||
Expect **BUILD SUCCESSFUL** and all tests green.
|
||||
|
||||
---
|
||||
|
||||
## User settings (both peers)
|
||||
|
||||
**Global settings** (main UI):
|
||||
|
||||
| Control | Location | Notes |
|
||||
|---------|----------|--------|
|
||||
| Transport | Sender / receiver | Use **UDP** for FEC/NACK tests |
|
||||
| Stream protection (UDP) | Global settings | Default **None**; try FEC 3/4, NACK, FEC+NACK |
|
||||
| Audio codec | Global settings | **AUTO**, **Opus**, **Speex**, AAC baseline |
|
||||
|
||||
After changing protection, **start a new session** — negotiation runs at handshake (`CastSession` / `StreamProtectionNegotiator`).
|
||||
|
||||
---
|
||||
|
||||
## Developer settings
|
||||
|
||||
Enable **Developer options** on both devices if testing passthrough / debug audio:
|
||||
|
||||
| Control | Purpose |
|
||||
|---------|---------|
|
||||
| Passthrough / debug audio | `PASSTHROUGH_DEBUG` path |
|
||||
| Codec overrides | Align with `CodecCatalog` / `PassthroughCodecPolicy` |
|
||||
| Diagnostics export | Session stats + negotiated mime + protection string |
|
||||
|
||||
Check **session diagnostics** after connect: protection line should match `PassthroughCodecPolicy.protectionDescription()`.
|
||||
|
||||
---
|
||||
|
||||
## E2E matrix — run on two devices
|
||||
|
||||
Mark each cell **PASS / FAIL / SKIP** in a ticket tagged `20260604`.
|
||||
|
||||
### A. Handshake and negotiation
|
||||
|
||||
| # | Sender protection | Receiver capability | Audio | Expected |
|
||||
|---|-------------------|---------------------|-------|----------|
|
||||
| A1 | None | Any | AUTO | No FEC/NACK; stable video+audio |
|
||||
| A2 | FEC 3/4 | FEC 3/4 | AUTO | Agreed FEC 3/4; loss recovery under artificial drop |
|
||||
| A3 | NACK | NACK | AUTO | Retransmit under single-packet loss |
|
||||
| A4 | FEC+NACK | FEC+NACK | AUTO | Combined behavior; no deadlock |
|
||||
| A5 | FEC 3/4 | NONE only | AUTO | Prompt or downgrade per `StreamProtectionNegotiator` |
|
||||
|
||||
### B. Audio codec
|
||||
|
||||
| # | Sender audio | Receiver audio | Expected |
|
||||
|---|--------------|----------------|----------|
|
||||
| B1 | AUTO | AUTO | AAC or negotiated fallback |
|
||||
| B2 | Opus | Opus | Opus native if `.so` present; else stub/fallback per `AudioNegotiator` |
|
||||
| B3 | Speex | Speex | Speex native if built; else fallback |
|
||||
| B4 | Opus | AUTO (no Opus) | Fallback — no silent failure |
|
||||
|
||||
### C. Controls (user vs developer)
|
||||
|
||||
| # | Check |
|
||||
|---|--------|
|
||||
| C1 | User can change transport UDP ↔ TCP without crash |
|
||||
| C2 | User protection change applies on **next** session |
|
||||
| C3 | Developer diagnostics show negotiated video mime + audio + protection |
|
||||
| C4 | Receiver UI shows aligned protection label after handshake |
|
||||
|
||||
### D. Stress (optional)
|
||||
|
||||
| # | Check |
|
||||
|---|--------|
|
||||
| D1 | Background/rotate sender — session recovers or fails cleanly |
|
||||
| D2 | ~5 min soak with FEC on — no memory runaway (logcat) |
|
||||
| D3 | Switch Wi‑Fi ↔ mobile data mid-cast — defined behavior (fail or reconnect) |
|
||||
|
||||
---
|
||||
|
||||
## What to capture per run
|
||||
|
||||
1. **Diagnostics** export from both devices (or logcat filter `androidcast|CastSession|StreamProtection|AudioNegotiator`).
|
||||
2. Negotiated lines: `negotiatedVideoMime`, `audioCodec`, `streamProtection`, `wantsNack`.
|
||||
3. Subjective: audio dropouts, video freezes, recovery after packet loss (FEC/NACK test: weak Wi‑Fi or `tc netem` on Linux AP if available).
|
||||
|
||||
---
|
||||
|
||||
## Pass criteria
|
||||
|
||||
- All **automated** tests green (`validate_opus_speex.sh`).
|
||||
- Matrix **A1–A4** and **B1–B2** pass on reference hardware.
|
||||
- No unexplained **silent** fallback (user sees toast/prompt when negotiation downgrades).
|
||||
- Diagnostics string matches actual wire behavior for UDP protection.
|
||||
|
||||
---
|
||||
|
||||
## Known limits (next)
|
||||
|
||||
| Item | Status |
|
||||
|------|--------|
|
||||
| Opus / Speex native encode | Probe + bridge; full native path per [ndk/README.md](../ndk/README.md) |
|
||||
| FEC 3/4 + RS | Per-shard wire (`FecShardWire`); v1 monolithic still accepted |
|
||||
| TCP transport | Protection spinners disabled / N/A for TCP |
|
||||
|
||||
File issues under Tickets → tag **`20260604`**, component Opus/Speex.
|
||||
@@ -35,6 +35,7 @@ Click a link to open a guide. Section links jump within the document (GitHub, Cu
|
||||
| [GRAFANA_vs_others_graphvis_pivot.md](GRAFANA_vs_others_graphvis_pivot.md) | Custom graphs vs Grafana decision memo. |
|
||||
| [INFRA.md](INFRA.md) | Production topology: Gentoo FE → Alpine BE, ports, nginx, sshfs. |
|
||||
| [OPEN_API.md](OPEN_API.md) | Crash ingest API design, triage tags, heuristics. |
|
||||
| [OPUS_SPEEX_VALIDATION.md](OPUS_SPEEX_VALIDATION.md) | Opus/Speex + UDP stream protection E2E checklist. |
|
||||
| [OTA.md](OTA.md) | OTA channel layout v0, manifests, MQTT, publish flow. |
|
||||
| [PRO_AAR.md](PRO_AAR.md) | cast-core / cast-pro AAR split and Play Billing. |
|
||||
| [REMOTE_ACCESS_IMPL.md](REMOTE_ACCESS_IMPL.md) | WireGuard v1 impl + deploy |
|
||||
@@ -77,6 +78,7 @@ Click a link to open a guide. Section links jump within the document (GitHub, Cu
|
||||
- [PRO_AAR.md](PRO_AAR.md) — Pro AAR split
|
||||
- [USB_HDMI_CAST.md](USB_HDMI_CAST.md) — HDMI / USB-tether
|
||||
- [AV_QUALITY_QA_SESSION.md](AV_QUALITY_QA_SESSION.md) — AV enhancement Q&A
|
||||
- [OPUS_SPEEX_VALIDATION.md](OPUS_SPEEX_VALIDATION.md) — codec + FEC/NACK field matrix ([§E2E matrix](OPUS_SPEEX_VALIDATION.md#e2e-matrix-run-on-two-devices))
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ Companion to [REMOTE_ACCESS_IMPL.md](REMOTE_ACCESS_IMPL.md). Use this when verif
|
||||
- [Scripts](#scripts)
|
||||
- [Quick start (local orchestration)](#quick-start-local-orchestration)
|
||||
- [Production / staging BE](#production--staging-be)
|
||||
- [UDP / WireGuard data plane (`ra_udp_debug.sh`)](#udp--wireguard-data-plane-ra_udp_debugsh)
|
||||
- [Optional: bring up WireGuard on Linux](#optional-bring-up-wireguard-on-linux)
|
||||
- [Mobile vs CLI checklist](#mobile-vs-cli-checklist)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
@@ -47,6 +48,7 @@ All under `examples/crash_reporter/backend/scripts/`:
|
||||
| `ra_e2e_cli.sh` | Full flow: disable → wait → whitelist → open → connect → close → disable |
|
||||
| `test_remote_access_api.sh` | Minimal API smoke (grep, no jq) |
|
||||
| `verify_remote_access_prod.sh` | BE preflight: config, wg tools, DB tables, optional smoke |
|
||||
| `ra_udp_debug.sh` | DNS + UDP port + HTTP smoke + connect + `wg-quick` + ping BE (`10.66.66.1`) |
|
||||
|
||||
```bash
|
||||
cd examples/crash_reporter/backend
|
||||
@@ -110,6 +112,27 @@ RA_E2E=1 BASE="$CRASHES_BASE" ./scripts/verify_remote_access_prod.sh
|
||||
|
||||
---
|
||||
|
||||
## UDP / WireGuard data plane (`ra_udp_debug.sh`)
|
||||
|
||||
After HTTP E2E is green, run from a Linux laptop with `curl`, `jq`, `dig`, and optional `wireguard-tools`:
|
||||
|
||||
```bash
|
||||
cd examples/crash_reporter/backend
|
||||
export CRASHES_BASE=https://apps.f0xx.org/app/androidcast_project/crashes
|
||||
export ADMIN_USER=admin ADMIN_PASS='…'
|
||||
|
||||
./scripts/ra_udp_debug.sh # DNS, UDP nc, HTTP smoke, connect, wg conf
|
||||
./scripts/ra_udp_debug.sh --apply # also wg-quick up + ping 10.66.66.1 + teardown
|
||||
```
|
||||
|
||||
Steps: CNAME check (`ra.apps.f0xx.org` → same A as `apps.f0xx.org`) → UDP `51820` → `test_remote_access_api.sh` → whitelist + open + poll connect → write `/tmp/ra-udp-<id>.conf`. With `--apply`, brings tunnel up and pings the BE WG address.
|
||||
|
||||
Skip HTTP re-check when control plane was just verified: `./scripts/ra_udp_debug.sh --skip-http --apply`.
|
||||
|
||||
See also [20260602_REVERSE_SSH_proposals_summary.md — Appendix 1](20260602_REVERSE_SSH_proposals_summary.md) (Linux laptop WG test).
|
||||
|
||||
---
|
||||
|
||||
## Optional: bring up WireGuard on Linux
|
||||
|
||||
After `poll --until connect` returns credentials:
|
||||
|
||||
@@ -1459,11 +1459,19 @@ a:hover { text-decoration: underline; }
|
||||
border-bottom: 1px solid var(--border, rgba(255, 255, 255, 0.1));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.graph-detail-header-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.graph-detail-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.graph-detail-stats {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.graph-detail-canvas-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
|
||||
@@ -12,10 +12,7 @@
|
||||
"lang.en": "English",
|
||||
"lang.ru": "Russian",
|
||||
"home.title": "Issues console",
|
||||
"home.intro": "Anonymous Android Cast crash ingest is active. Use Reports to analyze grouped fingerprints.",
|
||||
"home.upload_api": "Upload API:",
|
||||
"home.schema": "Schema:",
|
||||
"home.ticket_upload_api": "Ticket upload API:",
|
||||
"home.intro": "Crash ingest and operator tools. Open Issues for grouped fingerprints, or use the links below.",
|
||||
"reports.title": "Issues",
|
||||
"reports.by_time": "By time",
|
||||
"reports.grouped": "Grouped",
|
||||
|
||||
@@ -15,9 +15,7 @@
|
||||
"lang.en": "English",
|
||||
"lang.ru": "Русский",
|
||||
"home.title": "Консоль сбоев",
|
||||
"home.intro": "Приём анонимных отчётов Android Cast активен. Откройте «Отчёты» для анализа сгруппированных отпечатков.",
|
||||
"home.upload_api": "API загрузки:",
|
||||
"home.schema": "Схема:",
|
||||
"home.intro": "Приём сбоев и операторские инструменты. Откройте Issues для сгруппированных отпечатков или ссылки ниже.",
|
||||
"reports.title": "Issues",
|
||||
"reports.by_time": "По времени",
|
||||
"reports.grouped": "Группы",
|
||||
|
||||
@@ -76,17 +76,19 @@
|
||||
if (y > maxY) maxY = y;
|
||||
});
|
||||
if (maxY <= 0) maxY = 1;
|
||||
const padL = interactive ? 52 : 36;
|
||||
const padR = interactive ? 120 : 12;
|
||||
const padT = 16;
|
||||
const padB = 32;
|
||||
const padL = interactive ? 56 : 36;
|
||||
const padR = interactive ? 148 : 12;
|
||||
const padT = interactive ? 24 : 16;
|
||||
const padB = interactive ? 40 : 32;
|
||||
const plotW = w - padL - padR;
|
||||
const plotH = h - padT - padB;
|
||||
const lineColor = color || '#3d8bfd';
|
||||
const title = (opts && opts.title) || '';
|
||||
|
||||
ctx.strokeStyle = 'rgba(120,140,170,0.45)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.fillStyle = '#94a3b8';
|
||||
ctx.font = '10px system-ui, sans-serif';
|
||||
ctx.font = interactive ? '11px system-ui, sans-serif' : '10px system-ui, sans-serif';
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const y = padT + (plotH * i) / 4;
|
||||
ctx.beginPath();
|
||||
@@ -95,18 +97,18 @@
|
||||
ctx.stroke();
|
||||
if (interactive) {
|
||||
const tickVal = Math.round((maxY * (4 - i)) / 4);
|
||||
ctx.fillText(String(tickVal), 4, y + 4);
|
||||
ctx.fillText(String(tickVal), 8, y + 4);
|
||||
}
|
||||
}
|
||||
|
||||
const plotPoints = points.map((p, idx) => {
|
||||
const x = padL + (plotW * idx) / Math.max(1, points.length - 1);
|
||||
const y = padT + plotH - (plotH * Number(p.y || 0)) / maxY;
|
||||
return { x, y, raw: p };
|
||||
return { x, y, raw: p, idx };
|
||||
});
|
||||
|
||||
ctx.strokeStyle = color || '#3d8bfd';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeStyle = lineColor;
|
||||
ctx.lineWidth = interactive ? 2.5 : 2;
|
||||
ctx.beginPath();
|
||||
plotPoints.forEach((pt, idx) => {
|
||||
if (idx === 0) ctx.moveTo(pt.x, pt.y);
|
||||
@@ -117,62 +119,180 @@
|
||||
if (interactive) {
|
||||
plotPoints.forEach((pt) => {
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = color || '#3d8bfd';
|
||||
ctx.arc(pt.x, pt.y, 4, 0, Math.PI * 2);
|
||||
ctx.fillStyle = lineColor;
|
||||
ctx.arc(pt.x, pt.y, 5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
if (title) {
|
||||
const lx = w - padR + 12;
|
||||
ctx.fillStyle = lineColor;
|
||||
ctx.fillRect(lx, padT, 12, 12);
|
||||
ctx.fillStyle = '#e2e8f0';
|
||||
ctx.font = '12px system-ui, sans-serif';
|
||||
const legend = title.length > 18 ? title.slice(0, 17) + '…' : title;
|
||||
ctx.fillText(legend, lx + 18, padT + 10);
|
||||
ctx.fillStyle = '#94a3b8';
|
||||
ctx.font = '10px system-ui, sans-serif';
|
||||
ctx.fillText('Y: value · X: date', lx, padT + 28);
|
||||
}
|
||||
const xTicks = Math.min(6, points.length);
|
||||
for (let t = 0; t < xTicks; t++) {
|
||||
const idx = xTicks === 1 ? 0 : Math.round((t * (points.length - 1)) / (xTicks - 1));
|
||||
const pt = plotPoints[idx];
|
||||
const label = String(points[idx].x || '');
|
||||
ctx.fillStyle = '#94a3b8';
|
||||
ctx.font = '10px system-ui, sans-serif';
|
||||
ctx.fillText(label, Math.max(padL, pt.x - 20), h - 12);
|
||||
}
|
||||
} else {
|
||||
ctx.fillStyle = '#94a3b8';
|
||||
ctx.font = '10px system-ui, sans-serif';
|
||||
if (points.length) {
|
||||
ctx.fillText(String(points[0].x || ''), padL, h - 8);
|
||||
const last = points[points.length - 1];
|
||||
ctx.fillText(String(last.x || ''), padL + plotW - 48, h - 8);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillStyle = '#94a3b8';
|
||||
ctx.font = '10px system-ui, sans-serif';
|
||||
if (points.length) {
|
||||
ctx.fillText(String(points[0].x || ''), padL, h - 8);
|
||||
const last = points[points.length - 1];
|
||||
ctx.fillText(String(last.x || ''), padL + plotW - 48, h - 8);
|
||||
}
|
||||
|
||||
return { points: plotPoints, padL, padT, plotW, plotH, maxY, w, h };
|
||||
return {
|
||||
points: plotPoints,
|
||||
padL,
|
||||
padT,
|
||||
padR,
|
||||
plotW,
|
||||
plotH,
|
||||
maxY,
|
||||
w,
|
||||
h,
|
||||
series: points,
|
||||
color: lineColor,
|
||||
title,
|
||||
};
|
||||
}
|
||||
|
||||
function drawPie(canvas, rows) {
|
||||
if (!canvas) return;
|
||||
function detailCanvasSize() {
|
||||
const wrap = el('graph-detail-canvas-wrap');
|
||||
const maxW = wrap ? wrap.clientWidth - 8 : window.innerWidth - 48;
|
||||
return {
|
||||
w: Math.max(640, Math.min(1280, maxW)),
|
||||
h: Math.max(380, window.innerHeight - 200),
|
||||
};
|
||||
}
|
||||
|
||||
function redrawDetailChart(highlightIdx) {
|
||||
const state = detailState;
|
||||
if (!state || state.type !== 'line' || !state.canvas) return;
|
||||
const canvas = state.canvas;
|
||||
const meta = drawLineChart(canvas, state.series, state.def.color, {
|
||||
interactive: true,
|
||||
title: state.def.title,
|
||||
});
|
||||
if (meta && highlightIdx != null && meta.points[highlightIdx]) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const pt = meta.points[highlightIdx];
|
||||
ctx.strokeStyle = 'rgba(148,163,184,0.55)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pt.x, meta.padT);
|
||||
ctx.lineTo(pt.x, meta.h - meta.padT);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = state.def.color || '#3d8bfd';
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.arc(pt.x, pt.y, 7, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
detailState.meta = meta;
|
||||
}
|
||||
|
||||
function drawPie(canvas, rows, opts) {
|
||||
if (!canvas) return null;
|
||||
const interactive = opts && opts.interactive;
|
||||
const highlightIdx = opts && opts.highlightIdx;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
if (!ctx) return null;
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
if (interactive) {
|
||||
ctx.fillStyle = 'rgba(90,110,140,0.12)';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
}
|
||||
const data = Array.isArray(rows) ? rows.filter((r) => Number(r.value) > 0) : [];
|
||||
if (!data.length) {
|
||||
ctx.fillStyle = '#94a3b8';
|
||||
ctx.font = '12px system-ui, sans-serif';
|
||||
ctx.fillText('No data', 24, h / 2);
|
||||
return;
|
||||
return { slices: [], cx: 0, cy: 0, r: 0, data: [], total: 0, w, h };
|
||||
}
|
||||
const total = data.reduce((s, r) => s + Number(r.value || 0), 0);
|
||||
const cx = w * 0.35;
|
||||
const cx = w * (interactive ? 0.38 : 0.35);
|
||||
const cy = h * 0.5;
|
||||
const r = Math.min(w, h) * 0.32;
|
||||
const r = Math.min(w, h) * (interactive ? 0.36 : 0.32);
|
||||
let angle = -Math.PI / 2;
|
||||
const slices = [];
|
||||
data.forEach((row, i) => {
|
||||
const slice = (Number(row.value) / total) * Math.PI * 2;
|
||||
const start = angle;
|
||||
const end = angle + slice;
|
||||
slices.push({ start, end, idx: i, row, color: COLORS[i % COLORS.length] });
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy);
|
||||
ctx.fillStyle = COLORS[i % COLORS.length];
|
||||
ctx.arc(cx, cy, r, angle, angle + slice);
|
||||
if (interactive && highlightIdx === i) {
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.35)';
|
||||
ctx.shadowBlur = 10;
|
||||
} else {
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
ctx.arc(cx, cy, r, start, end);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
angle += slice;
|
||||
ctx.shadowBlur = 0;
|
||||
angle = end;
|
||||
});
|
||||
let ly = 16;
|
||||
const legendX = w * (interactive ? 0.58 : 0.62);
|
||||
let ly = interactive ? 28 : 16;
|
||||
data.forEach((row, i) => {
|
||||
ctx.fillStyle = COLORS[i % COLORS.length];
|
||||
ctx.fillRect(w * 0.62, ly, 10, 10);
|
||||
ctx.fillStyle = '#cbd5e1';
|
||||
ctx.font = '11px system-ui, sans-serif';
|
||||
const color = COLORS[i % COLORS.length];
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(legendX, ly, interactive ? 14 : 10, interactive ? 14 : 10);
|
||||
ctx.fillStyle = highlightIdx === i && interactive ? '#f8fafc' : '#cbd5e1';
|
||||
ctx.font = interactive ? '13px system-ui, sans-serif' : '11px system-ui, sans-serif';
|
||||
const pct = total > 0 ? Math.round((100 * Number(row.value)) / total) : 0;
|
||||
ctx.fillText(String(row.label || '') + ' (' + pct + '%)', w * 0.62 + 16, ly + 9);
|
||||
ly += 18;
|
||||
const label = String(row.label || '') + ' (' + pct + '%)';
|
||||
ctx.fillText(label, legendX + (interactive ? 22 : 16), ly + (interactive ? 12 : 9));
|
||||
ly += interactive ? 22 : 18;
|
||||
});
|
||||
return { slices, cx, cy, r, data, total, w, h };
|
||||
}
|
||||
|
||||
function redrawDetailPie(highlightIdx) {
|
||||
const state = detailState;
|
||||
if (!state || state.type !== 'pie' || !state.canvas) return;
|
||||
const meta = drawPie(state.canvas, state.series, { interactive: true, highlightIdx: highlightIdx });
|
||||
detailState.meta = meta;
|
||||
}
|
||||
|
||||
function pieSliceAt(meta, mx, my) {
|
||||
if (!meta || !meta.slices.length || meta.total <= 0) return -1;
|
||||
const dx = mx - meta.cx;
|
||||
const dy = my - meta.cy;
|
||||
if (Math.sqrt(dx * dx + dy * dy) > meta.r) return -1;
|
||||
let rel = Math.atan2(dy, dx) + Math.PI / 2;
|
||||
if (rel < 0) rel += Math.PI * 2;
|
||||
if (rel >= Math.PI * 2) rel -= Math.PI * 2;
|
||||
let acc = 0;
|
||||
for (let i = 0; i < meta.data.length; i++) {
|
||||
const slice = (Number(meta.data[i].value) / meta.total) * Math.PI * 2;
|
||||
if (rel >= acc && rel < acc + slice) return i;
|
||||
acc += slice;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function renderBreakdownList(target, rows) {
|
||||
@@ -286,12 +406,34 @@
|
||||
});
|
||||
}
|
||||
|
||||
function detachDetailResize() {
|
||||
if (detailState && detailState.resizeHandler) {
|
||||
window.removeEventListener('resize', detailState.resizeHandler);
|
||||
}
|
||||
}
|
||||
|
||||
function attachDetailResize(canvas, redrawFn) {
|
||||
detachDetailResize();
|
||||
const handler = function () {
|
||||
if (!detailState || detailState.canvas !== canvas) return;
|
||||
const size = detailCanvasSize();
|
||||
canvas.width = size.w;
|
||||
canvas.height = size.h;
|
||||
redrawFn(null);
|
||||
};
|
||||
if (detailState) detailState.resizeHandler = handler;
|
||||
window.addEventListener('resize', handler, { passive: true });
|
||||
}
|
||||
|
||||
function closeGraphDetail() {
|
||||
detachDetailResize();
|
||||
const overlay = el('graph-detail-overlay');
|
||||
if (overlay) overlay.hidden = true;
|
||||
detailState = null;
|
||||
const tip = el('graph-detail-tooltip');
|
||||
if (tip) tip.hidden = true;
|
||||
const statsEl = el('graph-detail-stats');
|
||||
if (statsEl) statsEl.textContent = '';
|
||||
}
|
||||
|
||||
function openGraphDetail(card) {
|
||||
@@ -320,17 +462,46 @@
|
||||
|
||||
if (def.type === 'line' && canvasEl) {
|
||||
canvasEl.hidden = false;
|
||||
canvasEl.width = 960;
|
||||
canvasEl.height = 420;
|
||||
const meta = drawLineChart(canvasEl, fieldVal, def.color, { interactive: true });
|
||||
detailState = { type: 'line', meta, def };
|
||||
attachLineHover(canvasEl, meta, def);
|
||||
const size = detailCanvasSize();
|
||||
canvasEl.width = size.w;
|
||||
canvasEl.height = size.h;
|
||||
detailState = {
|
||||
type: 'line',
|
||||
def,
|
||||
series: fieldVal,
|
||||
canvas: canvasEl,
|
||||
meta: null,
|
||||
};
|
||||
redrawDetailChart(null);
|
||||
attachLineHover(canvasEl, def);
|
||||
attachDetailResize(canvasEl, redrawDetailChart);
|
||||
const statsEl = el('graph-detail-stats');
|
||||
if (statsEl) {
|
||||
statsEl.textContent =
|
||||
(fieldVal && fieldVal.length ? fieldVal.length + ' points · ' : '') +
|
||||
'Move pointer over chart for values';
|
||||
}
|
||||
} else if (def.type === 'pie' && canvasEl) {
|
||||
canvasEl.hidden = false;
|
||||
canvasEl.width = 720;
|
||||
canvasEl.height = 400;
|
||||
drawPie(canvasEl, fieldVal);
|
||||
detailState = { type: 'pie' };
|
||||
const size = detailCanvasSize();
|
||||
canvasEl.width = size.w;
|
||||
canvasEl.height = size.h;
|
||||
detailState = {
|
||||
type: 'pie',
|
||||
def,
|
||||
series: fieldVal,
|
||||
canvas: canvasEl,
|
||||
meta: null,
|
||||
};
|
||||
redrawDetailPie(null);
|
||||
attachPieHover(canvasEl, def);
|
||||
attachDetailResize(canvasEl, redrawDetailPie);
|
||||
const statsEl = el('graph-detail-stats');
|
||||
if (statsEl) {
|
||||
const n = Array.isArray(fieldVal) ? fieldVal.filter((r) => Number(r.value) > 0).length : 0;
|
||||
statsEl.textContent =
|
||||
(n ? n + ' segments · ' : '') + 'Hover slices for value breakdown';
|
||||
}
|
||||
} else if (def.type === 'breakdown' || def.type === 'toplist') {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'graph-detail-table graph-breakdown';
|
||||
@@ -360,43 +531,113 @@
|
||||
overlay.hidden = false;
|
||||
}
|
||||
|
||||
function attachLineHover(canvas, meta, def) {
|
||||
function valueUnit(def) {
|
||||
if (!def || !def.title) return '';
|
||||
if (def.title.indexOf('kbps') >= 0) return ' kbps';
|
||||
if (def.title.indexOf('duration') >= 0) return ' s';
|
||||
return '';
|
||||
}
|
||||
|
||||
function attachLineHover(canvas, def) {
|
||||
const tip = el('graph-detail-tooltip');
|
||||
const statsEl = el('graph-detail-stats');
|
||||
const wrap = canvas && canvas.closest('.graph-detail-canvas-wrap');
|
||||
if (!tip || !wrap || !meta || !meta.points.length) return;
|
||||
if (!wrap) return;
|
||||
|
||||
canvas.onmousemove = function (ev) {
|
||||
const meta = detailState && detailState.meta;
|
||||
if (!meta || !meta.points.length) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const x = (ev.clientX - rect.left) * scaleX;
|
||||
let best = null;
|
||||
let bestD = Infinity;
|
||||
meta.points.forEach((pt) => {
|
||||
let bestIdx = -1;
|
||||
meta.points.forEach((pt, idx) => {
|
||||
const d = Math.abs(pt.x - x);
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
best = pt;
|
||||
bestIdx = idx;
|
||||
}
|
||||
});
|
||||
if (!best || bestD > 28) {
|
||||
tip.hidden = true;
|
||||
if (!best || bestD > 32) {
|
||||
if (tip) tip.hidden = true;
|
||||
redrawDetailChart(null);
|
||||
return;
|
||||
}
|
||||
redrawDetailChart(bestIdx);
|
||||
const raw = best.raw || {};
|
||||
tip.hidden = false;
|
||||
const unit =
|
||||
def && def.title && def.title.indexOf('kbps') >= 0
|
||||
? ' kbps'
|
||||
: def && def.title && def.title.indexOf('duration') >= 0
|
||||
? ' s'
|
||||
: '';
|
||||
tip.textContent = String(raw.x || '') + ': ' + Number(raw.y || 0) + unit;
|
||||
const wrapRect = wrap.getBoundingClientRect();
|
||||
tip.style.left = ev.clientX - wrapRect.left + 12 + 'px';
|
||||
tip.style.top = ev.clientY - wrapRect.top - 8 + 'px';
|
||||
const unit = valueUnit(def);
|
||||
const label = String(raw.x || '') + ' → ' + Number(raw.y || 0) + unit;
|
||||
if (tip) {
|
||||
tip.hidden = false;
|
||||
tip.textContent = label;
|
||||
const wrapRect = wrap.getBoundingClientRect();
|
||||
tip.style.left = ev.clientX - wrapRect.left + 14 + 'px';
|
||||
tip.style.top = ev.clientY - wrapRect.top - 10 + 'px';
|
||||
}
|
||||
if (statsEl) {
|
||||
statsEl.textContent = def.title + ' · ' + label;
|
||||
}
|
||||
};
|
||||
canvas.onmouseleave = function () {
|
||||
tip.hidden = true;
|
||||
if (tip) tip.hidden = true;
|
||||
redrawDetailChart(null);
|
||||
if (statsEl && detailState && detailState.series) {
|
||||
statsEl.textContent =
|
||||
detailState.series.length + ' points · Move pointer over chart for values';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function attachPieHover(canvas, def) {
|
||||
const tip = el('graph-detail-tooltip');
|
||||
const statsEl = el('graph-detail-stats');
|
||||
const wrap = canvas && canvas.closest('.graph-detail-canvas-wrap');
|
||||
if (!wrap) return;
|
||||
|
||||
canvas.onmousemove = function (ev) {
|
||||
const meta = detailState && detailState.meta;
|
||||
if (!meta || !meta.slices.length) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
const mx = (ev.clientX - rect.left) * scaleX;
|
||||
const my = (ev.clientY - rect.top) * scaleY;
|
||||
const idx = pieSliceAt(meta, mx, my);
|
||||
if (idx < 0) {
|
||||
if (tip) tip.hidden = true;
|
||||
redrawDetailPie(null);
|
||||
if (statsEl) {
|
||||
statsEl.textContent =
|
||||
meta.data.length + ' segments · Hover slices for value breakdown';
|
||||
}
|
||||
return;
|
||||
}
|
||||
redrawDetailPie(idx);
|
||||
const row = meta.data[idx] || {};
|
||||
const pct = meta.total > 0 ? Math.round((100 * Number(row.value)) / meta.total) : 0;
|
||||
const label =
|
||||
String(row.label || '') + ': ' + Number(row.value || 0) + ' (' + pct + '%)';
|
||||
if (tip) {
|
||||
tip.hidden = false;
|
||||
tip.textContent = label;
|
||||
const wrapRect = wrap.getBoundingClientRect();
|
||||
tip.style.left = ev.clientX - wrapRect.left + 14 + 'px';
|
||||
tip.style.top = ev.clientY - wrapRect.top - 10 + 'px';
|
||||
}
|
||||
if (statsEl) {
|
||||
statsEl.textContent = def.title + ' · ' + label;
|
||||
}
|
||||
};
|
||||
canvas.onmouseleave = function () {
|
||||
if (tip) tip.hidden = true;
|
||||
redrawDetailPie(null);
|
||||
if (statsEl && detailState && detailState.series) {
|
||||
const n = detailState.series.filter((r) => Number(r.value) > 0).length;
|
||||
statsEl.textContent = n + ' segments · Hover slices for value breakdown';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
129
examples/crash_reporter/backend/scripts/ra_udp_debug.sh
Executable file
129
examples/crash_reporter/backend/scripts/ra_udp_debug.sh
Executable file
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env bash
|
||||
# UDP / WireGuard data-plane debug from a Linux laptop (task 4).
|
||||
# HTTP control plane must work first (ra_e2e_cli.sh). See docs/REMOTE_ACCESS_VALIDATION.md
|
||||
# and proposals Appendix 1.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
# shellcheck source=ra_lib.sh
|
||||
source "$ROOT/scripts/ra_lib.sh"
|
||||
|
||||
WG_HOST="${WG_HOST:-ra.apps.f0xx.org}"
|
||||
WG_PORT="${WG_PORT:-51820}"
|
||||
BE_WG_IP="${BE_WG_IP:-10.66.66.1}"
|
||||
DEVICE_ID="${RA_DEVICE_ID:-ra-udp-$(hostname -s | tr ' ' '-')}"
|
||||
CONF="/tmp/ra-udp-${DEVICE_ID}.conf"
|
||||
APPLY="${RA_UDP_APPLY:-0}"
|
||||
SKIP_HTTP="${RA_UDP_SKIP_HTTP:-0}"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $0 [--apply] [--device-id ID] [--skip-http]
|
||||
|
||||
Env:
|
||||
CRASHES_BASE, ADMIN_USER, ADMIN_PASS — operator API
|
||||
RA_DEVICE_ID, WG_HOST (default ra.apps.f0xx.org), WG_PORT (51820)
|
||||
BE_WG_IP (10.66.66.1) — ping target on tunnel
|
||||
RA_UDP_APPLY=1 — same as --apply (wg-quick up)
|
||||
|
||||
Steps: DNS → UDP port → optional HTTP E2E → connect JSON → wg conf → optional tunnel → ping
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--apply) APPLY=1; shift ;;
|
||||
--device-id) DEVICE_ID="$2"; shift 2 ;;
|
||||
--skip-http) SKIP_HTTP=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "Unknown: $1" >&2; usage; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
export RA_DEVICE_ID="$DEVICE_ID"
|
||||
export RA_RANDOM_FILE="/tmp/androidcast-ra-${DEVICE_ID}.random"
|
||||
|
||||
note() { echo "== $*"; }
|
||||
ok() { echo "OK $*"; }
|
||||
warn() { echo "WARN $*"; }
|
||||
fail() { echo "FAIL $*"; exit 1; }
|
||||
|
||||
note "RA UDP debug — device_id=$DEVICE_ID"
|
||||
note "BASE=$(ra_base)"
|
||||
note "WG endpoint ${WG_HOST}:${WG_PORT}"
|
||||
|
||||
note "1. DNS (CNAME chain)"
|
||||
APPS_IP="$(dig +short apps.f0xx.org A 2>/dev/null | head -1)"
|
||||
RA_IP="$(dig +short "$WG_HOST" A 2>/dev/null | head -1)"
|
||||
echo "apps.f0xx.org A: ${APPS_IP:-?}"
|
||||
echo "${WG_HOST} A: ${RA_IP:-?}"
|
||||
if [[ -n "$APPS_IP" && -n "$RA_IP" && "$APPS_IP" == "$RA_IP" ]]; then
|
||||
ok "ra host resolves same as apps (CNAME OK)"
|
||||
else
|
||||
warn "A records differ or missing — check CNAME/DNS"
|
||||
fi
|
||||
|
||||
note "2. UDP reachability"
|
||||
if command -v nc >/dev/null 2>&1; then
|
||||
if nc -zvu -w 3 "$WG_HOST" "$WG_PORT" 2>&1 | grep -qiE 'succeeded|open'; then
|
||||
ok "UDP ${WG_PORT} appears open (nc)"
|
||||
else
|
||||
warn "UDP ${WG_PORT} not confirmed open — FE DNAT / firewall?"
|
||||
fi
|
||||
else
|
||||
warn "nc not found — skip UDP probe"
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_HTTP" -eq 0 ]]; then
|
||||
note "3. HTTP control plane (quick)"
|
||||
if CRASHES_BASE="$(ra_base)" bash "$ROOT/scripts/test_remote_access_api.sh" >/dev/null 2>&1; then
|
||||
ok "test_remote_access_api.sh"
|
||||
else
|
||||
warn "API smoke failed — fix HTTP before UDP"
|
||||
fi
|
||||
else
|
||||
note "3. HTTP skipped"
|
||||
fi
|
||||
|
||||
note "4. Operator: whitelist + open session (if not already)"
|
||||
COOKIE_JAR="$(mktemp)"
|
||||
trap 'rm -f "$COOKIE_JAR"; sudo wg-quick down "$CONF" 2>/dev/null || true' EXIT
|
||||
ra_admin_login "$COOKIE_JAR"
|
||||
ra_admin_post "$COOKIE_JAR" whitelist "{\"device_id\":\"$DEVICE_ID\",\"whitelisted\":true}" >/dev/null
|
||||
ra_admin_post "$COOKIE_JAR" open_session "{\"device_id\":\"$DEVICE_ID\"}" >/dev/null
|
||||
ok "whitelist + open_session"
|
||||
|
||||
note "5. Device poll → connect (may wait min_poll_interval_s)"
|
||||
export RA_DEVICE_ID="$DEVICE_ID"
|
||||
bash "$ROOT/scripts/ra_device_sim.sh" poll --until connect --device-id "$DEVICE_ID" --base "$(ra_base)" > /tmp/ra-udp-connect.json
|
||||
grep -q '"action":"connect"' /tmp/ra-udp-connect.json || fail "no connect in response"
|
||||
ok "heartbeat connect"
|
||||
|
||||
note "6. Build wg-quick config"
|
||||
RESP="$(cat /tmp/ra-udp-connect.json)"
|
||||
ra_wg_quick_from_connect "$RESP" >"$CONF"
|
||||
echo "--- $CONF ---"
|
||||
sed 's/PrivateKey = .*/PrivateKey = <redacted>/' "$CONF"
|
||||
ok "wrote $CONF"
|
||||
|
||||
if [[ "$APPLY" -eq 1 ]]; then
|
||||
command -v wg-quick >/dev/null 2>&1 || fail "wg-quick not installed"
|
||||
note "7. Bring up tunnel"
|
||||
sudo wg-quick up "$CONF"
|
||||
sudo wg show
|
||||
note "8. Ping BE wg (${BE_WG_IP})"
|
||||
if ping -c 3 -W 2 "$BE_WG_IP"; then
|
||||
ok "ping ${BE_WG_IP}"
|
||||
else
|
||||
warn "ping failed — routing or AllowedIPs (expected 10.66.66.1/32)"
|
||||
fi
|
||||
note "9. Teardown"
|
||||
sudo wg-quick down "$CONF"
|
||||
ok "wg-quick down"
|
||||
else
|
||||
note "7–9. Skipped tunnel (pass --apply or RA_UDP_APPLY=1 to test handshake)"
|
||||
echo " sudo wg-quick up $CONF && ping -c3 $BE_WG_IP && sudo wg-quick down $CONF"
|
||||
fi
|
||||
|
||||
note "UDP debug done"
|
||||
@@ -155,19 +155,9 @@
|
||||
</nav>
|
||||
<main class="main-pane">
|
||||
<?php if (($view ?? 'home') === 'home'): ?>
|
||||
<h1 data-i18n="home.title">Crash console</h1>
|
||||
<p data-i18n="home.intro">Anonymous Android Cast crash ingest is active. Use <strong>Reports</strong> to analyze grouped fingerprints.</p>
|
||||
<ul>
|
||||
<li><span data-i18n="home.upload_api">Upload API:</span> <code><?= h(Auth::basePath()) ?>/api/upload.php</code></li>
|
||||
<li><span data-i18n="home.schema">Schema:</span> <code>schema_version: 1</code></li>
|
||||
<li><span data-i18n="home.ticket_upload_api">Ticket upload API:</span> <code><?= h(Auth::basePath()) ?>/api/ticket_upload.php</code></li>
|
||||
<?php if (Rbac::can('remote_access_view')): ?>
|
||||
<li>Remote access dashboard: <a href="<?= h(Auth::basePath()) ?>/?view=remote_access">Remote access</a> · API <code><?= h(Auth::basePath()) ?>/api/remote_access.php</code></li>
|
||||
<?php endif; ?>
|
||||
<?php if (Rbac::canManageRbac()): ?>
|
||||
<li>Access control: <a href="<?= h(Auth::basePath()) ?>/?view=rbac">Users & roles</a> · API <code><?= h(Auth::basePath()) ?>/api/rbac.php</code></li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
<h1 data-i18n="home.title">Issues console</h1>
|
||||
<p data-i18n="home.intro">Crash ingest and operator tools. Open <strong>Issues</strong> for grouped fingerprints, or use the links below.</p>
|
||||
<?php $skip_home_link = true; require __DIR__ . '/partials/console_quick_links.php'; ?>
|
||||
<?php elseif (($view ?? '') === 'report' && !empty($report)): ?>
|
||||
<?php require __DIR__ . '/report_detail.php'; ?>
|
||||
<?php elseif (($view ?? '') === 'ticket' && !empty($ticket)): ?>
|
||||
@@ -362,7 +352,10 @@
|
||||
<div id="graph-detail-overlay" class="graph-detail-overlay" hidden>
|
||||
<div class="graph-detail-panel" role="dialog" aria-modal="true" aria-labelledby="graph-detail-title">
|
||||
<header class="graph-detail-header">
|
||||
<h2 id="graph-detail-title">Graph</h2>
|
||||
<div class="graph-detail-header-text">
|
||||
<h2 id="graph-detail-title">Graph</h2>
|
||||
<p id="graph-detail-stats" class="graph-detail-stats muted" aria-live="polite"></p>
|
||||
</div>
|
||||
<button type="button" class="btn graph-detail-close" id="graph-detail-close" aria-label="Close full view">Close</button>
|
||||
</header>
|
||||
<div class="graph-detail-canvas-wrap">
|
||||
|
||||
@@ -3,18 +3,26 @@ declare(strict_types=1);
|
||||
/**
|
||||
* Horizontal service links with the same icons as the left nav (8px gap).
|
||||
* @var string|null $extra_class optional class on <nav>
|
||||
* @var bool $skip_home_link omit "Console home" (use on home view)
|
||||
*/
|
||||
$bp = Auth::basePath();
|
||||
$proj = '/app/androidcast_project';
|
||||
$navClass = 'console-quick-links graphs-quick-links' . (isset($extra_class) ? ' ' . $extra_class : '');
|
||||
$items = [
|
||||
['Console home', $bp . '/?view=home', 'nav-icon--home'],
|
||||
['Issues', $bp . '/?view=reports', 'nav-icon--reports'],
|
||||
['Tickets', $bp . '/?view=tickets', 'nav-icon--tickets'],
|
||||
['Graphs', $proj . '/graphs/', 'nav-icon--graphs'],
|
||||
['Builder', $proj . '/build/', 'nav-icon--builder'],
|
||||
['Hub', $proj . '/', 'nav-icon--home'],
|
||||
];
|
||||
$items = [];
|
||||
if (empty($skip_home_link)) {
|
||||
$items[] = ['Console home', $bp . '/?view=home', 'nav-icon--home'];
|
||||
}
|
||||
$items[] = ['Issues', $bp . '/?view=reports', 'nav-icon--reports'];
|
||||
$items[] = ['Tickets', $bp . '/?view=tickets', 'nav-icon--tickets'];
|
||||
if (Rbac::can('remote_access_view')) {
|
||||
$items[] = ['Remote access', $bp . '/?view=remote_access', 'nav-icon--remote'];
|
||||
}
|
||||
if (Rbac::canManageRbac()) {
|
||||
$items[] = ['Access control', $bp . '/?view=rbac', 'nav-icon--tickets'];
|
||||
}
|
||||
$items[] = ['Graphs', $proj . '/graphs/', 'nav-icon--graphs'];
|
||||
$items[] = ['Builder', $proj . '/build/', 'nav-icon--builder'];
|
||||
$items[] = ['Hub', $proj . '/', 'nav-icon--home'];
|
||||
?>
|
||||
<nav class="<?= h($navClass) ?>" aria-label="Related services">
|
||||
<?php foreach ($items as [$label, $href, $icon]): ?>
|
||||
|
||||
12
scripts/validate_opus_speex.sh
Executable file
12
scripts/validate_opus_speex.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# Unit-test baseline for Opus/Speex negotiation and stream protection (task 3).
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
echo "== Opus/Speex + stream protection unit tests =="
|
||||
./gradlew :app:testDebugUnitTest \
|
||||
--tests 'com.foxx.androidcast.network.transport.StreamProtectionNegotiatorTest' \
|
||||
--tests 'com.foxx.androidcast.media.AudioNegotiatorTest' \
|
||||
--tests 'com.foxx.androidcast.media.codec.*' \
|
||||
"$@"
|
||||
echo "== OK =="
|
||||
Reference in New Issue
Block a user