nandi/jolt-nativepublic Fork 0
main
Commits
Clone
git clone https://git.rickub.com/nandi/jolt-native.git
git clone ssh://git@rickub.com/nandi/jolt-native.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

av.rs · 735 lines · 25.6 KBRust Blame HistoryRaw
Clear the clippy backlog the new CI enforces 4956d1e nandi 13d ago1// This file tracks sleek's copy in `android/src` closely enough that a fix can
2// be moved between the two by eye, so it is deliberately not idiomatised to
3// this workspace's clippy settings. The lints below are the ones that would
4// rewrite it away from its original; everything else still applies.
5#![allow(
6 clippy::chunks_exact_to_as_chunks,
7 clippy::identity_op,
8 clippy::manual_filter,
9 clippy::manual_is_multiple_of,
10 clippy::redundant_closure,
11 clippy::too_many_arguments,
12 clippy::unnecessary_sort_by
13)]
14
Lift freeq's AV media plane out of sleek 90f8b89 nandi 19d ago15//! Call state and framing for the freeq AV media plane.
16//!
17//! Extracted from sleek, which reached this by calling Rust from Rust. The
18//! *signaling* half went the other way and stayed there: a call is opened,
19//! joined and left over IRC TAGMSGs, and any client that speaks IRC already
20//! has what it needs for that in whatever language it speaks IRC in. What is
21//! here is the half that cannot reasonably be written twice — the SFU's dial
22//! rules, the broadcast paths, and the store that carries decoded frames from
23//! the media task to whatever paints them.
24//!
25//! Media rides MoQ (Media over QUIC) through the freeq SFU at `{origin}/av/moq`.
26use std::collections::HashMap;
27use std::fmt;
28use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
29use std::sync::{Arc, Mutex};
30
31/// Whether to claim camera hardware when dialing the media plane.
32///
33/// `permission_granted` is the Android runtime CAMERA check (pass `true` on
34/// desktop). Opening Camera2 before the system dialog is answered always fails
35/// and used to stick the call in audio-only with "camera unavailable".
36pub fn camera_publish_at_dial(intent: bool, permission_granted: bool) -> bool {
37 intent && permission_granted
38}
39
40/// Frame-store key for the local self-view tile (capture tee / preview pump).
41pub const LOCAL_PREVIEW_KEY: &str = "__local__";
42
43/// One decoded remote video frame (RGBA8), shared UI ↔ media task.
44#[derive(Clone)]
45pub struct RgbaVideoFrame {
46 pub width: u32,
47 pub height: u32,
48 pub rgba: Arc<[u8]>,
49 /// Monotonic id for this key — UI skips texture upload when unchanged.
50 pub gen: u64,
51}
52
53impl fmt::Debug for RgbaVideoFrame {
54 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55 f.debug_struct("RgbaVideoFrame")
56 .field("width", &self.width)
57 .field("height", &self.height)
58 .field("rgba_len", &self.rgba.len())
59 .field("gen", &self.gen)
60 .finish()
61 }
62}
63
64/// Latest-frame map for remote participants (and optional local preview).
65///
66/// The media task writes; the UI thread snapshots for texture upload.
67/// Intermediate frames are dropped so a 30 fps encode path cannot flood egui.
68#[derive(Clone, Default)]
69pub struct VideoFrameStore {
70 frames: Arc<Mutex<HashMap<String, RgbaVideoFrame>>>,
71 next_gen: Arc<AtomicU64>,
72}
73
74impl fmt::Debug for VideoFrameStore {
75 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76 let n = self.frames.lock().map(|g| g.len()).unwrap_or(0);
Run the formatter over the tree 3e8c6f0 nandi 13d ago77 f.debug_struct("VideoFrameStore").field("len", &n).finish()
Lift freeq's AV media plane out of sleek 90f8b89 nandi 19d ago78 }
79}
80
81impl VideoFrameStore {
82 pub fn new() -> Self {
83 Self::default()
84 }
85
86 pub fn set(&self, nick: impl Into<String>, width: u32, height: u32, rgba: Arc<[u8]>) {
87 if width == 0 || height == 0 {
88 return;
89 }
Run the formatter over the tree 3e8c6f0 nandi 13d ago90 let expected = (width as usize)
91 .saturating_mul(height as usize)
92 .saturating_mul(4);
Lift freeq's AV media plane out of sleek 90f8b89 nandi 19d ago93 if rgba.len() != expected {
94 return;
95 }
96 // Camera / decoder paths sometimes leave alpha at 0 (OBS virtual cam,
97 // some MJPEG converters). egui then draws a fully transparent tile.
98 let rgba = force_opaque_rgba(rgba);
Run the formatter over the tree 3e8c6f0 nandi 13d ago99 let gen = self
100 .next_gen
101 .fetch_add(1, Ordering::Relaxed)
102 .wrapping_add(1);
Lift freeq's AV media plane out of sleek 90f8b89 nandi 19d ago103 if let Ok(mut g) = self.frames.lock() {
104 g.insert(
105 nick.into(),
106 RgbaVideoFrame {
107 width,
108 height,
109 rgba,
110 gen,
111 },
112 );
113 }
114 }
115
116 pub fn remove(&self, nick: &str) {
117 if let Ok(mut g) = self.frames.lock() {
118 g.remove(nick);
119 }
120 }
121
122 pub fn clear(&self) {
123 if let Ok(mut g) = self.frames.lock() {
124 g.clear();
125 }
126 }
127
128 /// Copy frames from `other` for keys we do not already hold.
129 ///
130 /// Used when MoQ re-dials: the new session store starts empty, but the UI
131 /// still has last-good tiles. Seeding then attaching the new store keeps
132 /// stale pixels visible until live frames overwrite them — and ensures new
133 /// decoder writes land in the store the UI is painting (no orphan Arc).
134 pub fn seed_missing_from(&self, other: &Self) {
135 // Same Arc — nothing to copy.
136 if Arc::ptr_eq(&self.frames, &other.frames) {
137 return;
138 }
139 for (key, frame) in other.snapshot() {
140 let missing = self
141 .frames
142 .lock()
143 .map(|g| !g.contains_key(&key))
144 .unwrap_or(true);
145 if missing {
146 self.set(key, frame.width, frame.height, frame.rgba);
147 }
148 }
149 }
150
151 /// Snapshot of all latest frames (for UI paint).
152 pub fn snapshot(&self) -> Vec<(String, RgbaVideoFrame)> {
153 self.frames
154 .lock()
155 .map(|g| g.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
156 .unwrap_or_default()
157 }
158
159 pub fn is_empty(&self) -> bool {
160 self.frames.lock().map(|g| g.is_empty()).unwrap_or(true)
161 }
162
163 pub fn len(&self) -> usize {
164 self.frames.lock().map(|g| g.len()).unwrap_or(0)
165 }
166}
167
168/// Ensure every pixel has alpha = 255 (opaque). Cheap in-place when already opaque.
169fn force_opaque_rgba(rgba: Arc<[u8]>) -> Arc<[u8]> {
170 Arc::from(opaque_rgba_bytes(rgba.as_ref()))
171}
172
173/// Force every alpha byte to 255 for egui texture upload.
174///
175/// Camera / OBS virtual paths sometimes deliver `A=0`; unpremultiplied upload
176/// then paints a fully transparent tile (looks like a blank square).
177pub fn opaque_rgba_bytes(rgba: &[u8]) -> Vec<u8> {
178 let mut v = rgba.to_vec();
179 for a in v.iter_mut().skip(3).step_by(4) {
180 *a = 255;
181 }
182 v
183}
184
185/// Pad/truncate to `width * height * 4` and force opaque alpha — used by the
186/// call tile texture path so A=0 frames cannot paint transparent.
187pub fn prepare_opaque_rgba_for_upload(width: usize, height: usize, rgba: &[u8]) -> Vec<u8> {
188 let n = width.saturating_mul(height).saturating_mul(4);
189 let mut buf = vec![0u8; n];
190 let copy = rgba.len().min(n);
191 buf[..copy].copy_from_slice(&rgba[..copy]);
192 for a in buf.iter_mut().skip(3).step_by(4) {
193 *a = 255;
194 }
195 buf
196}
197
198/// Live mic input level (0.0..=1.0) shared between the capture thread and UI.
199///
200/// Updated from PCM samples *before* mute silence is applied, so the meter
201/// still moves while muted (useful for checking that the mic works).
202#[derive(Clone, Default)]
203pub struct MicLevel {
204 /// Envelope 0.0..=1.0 stored as f32 bits.
205 bits: Arc<AtomicU32>,
206}
207
208impl fmt::Debug for MicLevel {
209 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
210 f.debug_struct("MicLevel")
211 .field("level", &self.get())
212 .finish()
213 }
214}
215
216impl MicLevel {
217 pub fn new() -> Self {
218 Self::default()
219 }
220
221 pub fn get(&self) -> f32 {
222 f32::from_bits(self.bits.load(Ordering::Relaxed)).clamp(0.0, 1.0)
223 }
224
225 pub fn set(&self, level: f32) {
226 self.bits
227 .store(level.clamp(0.0, 1.0).to_bits(), Ordering::Relaxed);
228 }
229
230 pub fn clear(&self) {
231 self.set(0.0);
232 }
233
234 /// Update from a PCM buffer: peak+RMS blend with soft attack / release.
235 pub fn observe(&self, samples: &[f32]) {
236 if samples.is_empty() {
237 // Slow release when the capture thread is idle.
238 let prev = self.get();
239 self.set(prev * 0.85);
240 return;
241 }
242 let mut sum_sq = 0.0f32;
243 let mut peak = 0.0f32;
244 for &s in samples {
245 let a = s.abs();
246 if a > peak {
247 peak = a;
248 }
249 sum_sq += s * s;
250 }
251 let rms = (sum_sq / samples.len() as f32).sqrt();
252 // Speech is well below full-scale; blend + expand quiet levels.
253 let combined = (0.65 * rms + 0.35 * peak).min(1.0);
254 let linear = combined.sqrt().clamp(0.0, 1.0);
255 let prev = self.get();
256 let next = if linear > prev {
257 // Fast attack so peaks register immediately.
258 prev + (linear - prev) * 0.55
259 } else {
260 // Slower release so the bar doesn't flicker.
261 prev * 0.88 + linear * 0.12
262 };
263 self.set(next);
264 }
265}
266
267#[derive(Debug, Clone, PartialEq, Eq)]
268pub enum MediaStatus {
269 /// Not dialing the SFU yet (or signaling-only platforms).
270 Idle,
271 /// MoQ connect in flight.
272 Connecting,
273 /// Publishing / receiving media.
274 Live,
275 /// Connect failed; call signaling may still be active.
276 Failed(String),
277 /// Legacy: native MoQ was unavailable (pre-Android media plane).
278 /// Kept for status matching; no longer emitted by the dial path.
279 BrowserOnly,
280}
281
282impl MediaStatus {
283 pub fn label(&self) -> String {
284 match self {
285 Self::Idle => "Not connected".into(),
286 Self::Connecting => "Connecting media…".into(),
287 Self::Live => "In call".into(),
288 Self::Failed(e) => format!("Media: {e}"),
289 Self::BrowserOnly => "Open in browser for media".into(),
290 }
291 }
292}
293
294/// Parse the winning session id from a start-collision reason string.
295///
296/// freeq formats these as:
297/// `Channel #test already has an active session: 01KYRJEW9XCSYB2T10HM9RE1VN`
298/// Prefer `+freeq.at/av-id` when present; this is a belt-and-suspenders fallback.
299pub fn session_id_from_collision_reason(reason: &str) -> Option<String> {
300 const MARKER: &str = "already has an active session:";
301 let idx = reason.to_ascii_lowercase().find(MARKER)?;
302 // Use original slice with same index (marker is ASCII).
303 let rest = reason.get(idx + MARKER.len()..)?.trim();
304 let id = rest.split_whitespace().next()?.trim();
305 if id.is_empty() {
306 None
307 } else {
308 Some(id.to_string())
309 }
310}
311
312/// Parse the channel name from a start-collision reason (`Channel #foo already…`).
313pub fn channel_from_collision_reason(reason: &str) -> Option<String> {
314 let rest = reason
315 .strip_prefix("Channel ")
316 .or_else(|| reason.strip_prefix("channel "))?;
317 let ch = rest.split_whitespace().next()?.trim();
318 if ch.starts_with('#') || ch.starts_with('&') {
319 Some(ch.to_string())
320 } else {
321 None
322 }
323}
324
325/// Build the MoQ SFU dial URL from the IRC server host.
326///
327/// Examples:
328/// - `irc.freeq.at:6697` → `https://irc.freeq.at/av/moq`
329/// - `wss://irc.freeq.at/irc` → `https://irc.freeq.at/av/moq`
330///
331/// When `jwt` is set, appends `?jwt=…` (required when the SFU enforces tokens).
332/// Prefer [`sfu_moq_dial_url`] when you also have a per-call instance id
333/// (`?inst=…`, freeq-app / freeq SFU media-revocation parity).
334pub fn sfu_moq_url(server: &str, jwt: Option<&str>) -> Result<url::Url, String> {
335 sfu_moq_dial_url(server, jwt, None)
336}
337
338/// SFU dial URL with optional JWT + per-call instance query params.
339///
340/// freeq-app dials `wss://host/av/moq?inst={instance}&jwt={token}` (inst always,
341/// jwt when minted). Matching that shape keeps native clients on the same
342/// media-admission path as the working web client.
343pub fn sfu_moq_dial_url(
344 server: &str,
345 jwt: Option<&str>,
346 instance: Option<&str>,
347) -> Result<url::Url, String> {
348 let trimmed = server.trim();
349 if trimmed.is_empty() {
350 return Err("server is empty".into());
351 }
352 let normalized = if trimmed.starts_with("ws://")
353 || trimmed.starts_with("wss://")
354 || trimmed.starts_with("http://")
355 || trimmed.starts_with("https://")
356 {
357 trimmed.to_string()
358 } else {
359 // host:port from the connect form — freeq public hosts are TLS.
360 let host = trimmed.split(':').next().unwrap_or(trimmed);
361 if host.eq_ignore_ascii_case("localhost") || host.starts_with("127.") {
362 format!("http://{trimmed}")
363 } else {
364 format!("https://{host}")
365 }
366 };
367 let mut u: url::Url = normalized
368 .parse()
369 .map_err(|e| format!("parse server for SFU: {e}"))?;
370 match u.scheme() {
371 "https" | "wss" => {
372 u.set_scheme("https").ok();
373 }
374 "http" | "ws" => {
375 u.set_scheme("http").ok();
376 }
377 other => return Err(format!("unsupported scheme for SFU: {other}")),
378 }
379 if u.host_str().map(|h| h.is_empty()).unwrap_or(true) {
380 return Err("server URL has no host".into());
381 }
382 u.set_path("/av/moq");
383 u.set_query(None);
384 let mut pairs: Vec<String> = Vec::new();
385 if let Some(inst) = instance.filter(|i| !i.is_empty()) {
386 pairs.push(format!("inst={}", urlencoding::encode(inst)));
387 }
388 if let Some(tok) = jwt.filter(|t| !t.is_empty()) {
389 // JWTs are base64url; pass through (matches freeq-sdk-ffi / freeq-app).
390 pairs.push(format!("jwt={tok}"));
391 }
392 if !pairs.is_empty() {
393 u.set_query(Some(&pairs.join("&")));
394 }
395 Ok(u)
396}
397
398/// Whether we should dial the MoQ SFU now.
399///
400/// Production freeq SFUs require `?jwt=…`. Dialing without a token opens a
401/// connection that the SFU immediately closes — moq-lite then floods
402/// `transport error err=connection closed` once per live stream. Local/dev
403/// SFUs (localhost / 127.*) are allowed without a JWT.
404pub fn can_dial_sfu(server: &str, jwt: Option<&str>) -> bool {
405 if jwt.map(|t| !t.is_empty()).unwrap_or(false) {
406 return true;
407 }
408 let trimmed = server.trim();
409 let host = if let Ok(u) = url::Url::parse(trimmed) {
410 u.host_str().unwrap_or("").to_string()
411 } else if trimmed.starts_with("ws://")
412 || trimmed.starts_with("wss://")
413 || trimmed.starts_with("http://")
414 || trimmed.starts_with("https://")
415 {
416 // Malformed absolute URL — treat as remote (need JWT).
417 String::new()
418 } else {
419 trimmed
420 .split('/')
421 .next()
422 .unwrap_or(trimmed)
423 .split(':')
424 .next()
425 .unwrap_or(trimmed)
426 .to_string()
427 };
428 host.eq_ignore_ascii_case("localhost") || host.starts_with("127.")
429}
430
431/// MoQ broadcast path: `{session}/{nick}~{instance}`.
432pub fn broadcast_path(session_id: &str, nick: &str, instance: &str) -> String {
433 if instance.is_empty() {
434 format!("{session_id}/{nick}")
435 } else {
436 format!("{session_id}/{nick}~{instance}")
437 }
438}
439
440/// Stable map key for a broadcast path: last segment (`nick` or `nick~instance`).
441///
442/// Prefer this over [`path_nick`] for frame stores so two devices with the same
443/// nick do not overwrite each other.
444pub fn path_key(path: &str) -> &str {
445 path.rsplit('/').next().unwrap_or(path)
446}
447
448/// Display nick from a broadcast path (or a [`path_key`]).
449pub fn path_nick(path: &str) -> &str {
450 let last = path_key(path);
451 last.split('~').next().unwrap_or(last)
452}
453
454/// Whether an announced path belongs to this session and is not us.
455pub fn should_tap(path: &str, session_id: &str, our_broadcast: &str, my_nick: &str) -> bool {
456 if path == our_broadcast {
457 return false;
458 }
459 // Session prefix filter (unscoped SFU announces everything).
460 let prefix = format!("{session_id}/");
461 if !path.starts_with(&prefix) && path != session_id {
462 // Also accept relative paths when scoped (`nick~inst` only).
463 if path.contains('/') {
464 return false;
465 }
466 }
467 let nick = path_nick(path);
468 if nick.eq_ignore_ascii_case(my_nick) {
469 return false;
470 }
471 true
472}
473
474#[cfg(test)]
475mod tests {
476 use super::{
477 broadcast_path, camera_publish_at_dial, can_dial_sfu, channel_from_collision_reason,
478 opaque_rgba_bytes, path_key, path_nick, prepare_opaque_rgba_for_upload,
479 session_id_from_collision_reason, sfu_moq_dial_url, sfu_moq_url, VideoFrameStore,
480 LOCAL_PREVIEW_KEY,
481 };
482 use std::sync::Arc;
483
484 #[test]
485 fn path_key_keeps_instance_path_nick_strips() {
Run the formatter over the tree 3e8c6f0 nandi 13d ago486 assert_eq!(path_key("01SESSION/alice~phone"), "alice~phone");
Lift freeq's AV media plane out of sleek 90f8b89 nandi 19d ago487 assert_eq!(path_nick("01SESSION/alice~phone"), "alice");
488 assert_eq!(path_key("01SESSION/bob"), "bob");
489 assert_eq!(path_nick("bob~desk"), "bob");
490 }
491
492 #[test]
493 fn opaque_rgba_bytes_forces_alpha_255_on_zero_alpha_input() {
494 // Two pixels: red + blue, both fully transparent (A=0).
495 let input = [255u8, 0, 0, 0, 0, 0, 255, 0];
496 let out = opaque_rgba_bytes(&input);
497 assert_eq!(out.len(), 8);
498 assert_eq!(&out[0..4], &[255, 0, 0, 255]);
499 assert_eq!(&out[4..8], &[0, 0, 255, 255]);
500 }
501
502 #[test]
503 fn prepare_opaque_rgba_for_upload_pads_and_forces_alpha() {
504 // 2×2 frame but only one pixel of data (undersized / partial buffer).
505 let input = [10u8, 20, 30, 0];
506 let out = prepare_opaque_rgba_for_upload(2, 2, &input);
507 assert_eq!(out.len(), 2 * 2 * 4);
508 assert_eq!(out[3], 255, "first pixel alpha forced");
509 assert_eq!(out[7], 255);
510 assert_eq!(out[11], 255);
511 assert_eq!(out[15], 255);
512 assert_eq!(&out[0..3], &[10, 20, 30]);
513 }
514
515 #[test]
516 fn video_frame_store_set_local_key_forces_opaque_alpha() {
517 let store = VideoFrameStore::new();
518 // 2×2 with A=0 on every pixel (the “alpha blank” failure mode).
519 let mut rgba = Vec::with_capacity(2 * 2 * 4);
520 for i in 0..4 {
521 rgba.extend_from_slice(&[i as u8 * 40, 80, 120, 0]);
522 }
523 store.set(LOCAL_PREVIEW_KEY, 2, 2, Arc::from(rgba));
524 let snap = store.snapshot();
525 assert_eq!(snap.len(), 1);
526 assert_eq!(snap[0].0, LOCAL_PREVIEW_KEY);
527 let frame = &snap[0].1;
528 assert_eq!(frame.width, 2);
529 assert_eq!(frame.height, 2);
530 assert_eq!(frame.rgba.len(), 16);
531 for (i, a) in frame.rgba.iter().skip(3).step_by(4).enumerate() {
532 assert_eq!(*a, 255, "pixel {i} alpha must be opaque after store.set");
533 }
534 // RGB preserved from input (first pixel was 0,80,120,0 → A forced).
535 assert_eq!(&frame.rgba[0..4], &[0, 80, 120, 255]);
536 }
537
538 #[test]
539 fn video_frame_store_seed_missing_from_copies_only_absent_keys() {
540 let old = VideoFrameStore::new();
Run the formatter over the tree 3e8c6f0 nandi 13d ago541 let rgba: Arc<[u8]> = Arc::from([
542 10u8, 20, 30, 255, 40, 50, 60, 255, 70, 80, 90, 255, 1, 2, 3, 255,
543 ]);
Lift freeq's AV media plane out of sleek 90f8b89 nandi 19d ago544 old.set("eve", 2, 2, rgba.clone());
545 old.set(LOCAL_PREVIEW_KEY, 2, 2, rgba);
546
547 let new = VideoFrameStore::new();
548 // Live key already present — must not be overwritten by seed.
Run the formatter over the tree 3e8c6f0 nandi 13d ago549 let live: Arc<[u8]> = Arc::from([9u8, 9, 9, 255, 9, 9, 9, 255, 9, 9, 9, 255, 9, 9, 9, 255]);
Lift freeq's AV media plane out of sleek 90f8b89 nandi 19d ago550 new.set("eve", 2, 2, live.clone());
551
552 new.seed_missing_from(&old);
553 let snap = new.snapshot();
554 assert_eq!(snap.len(), 2);
555 let eve = snap.iter().find(|(k, _)| k == "eve").unwrap();
556 assert_eq!(eve.1.rgba.as_ref(), live.as_ref());
557 assert!(snap.iter().any(|(k, _)| k == LOCAL_PREVIEW_KEY));
558 }
559
560 #[test]
561 fn prepare_opaque_upload_preserves_patterned_rgb_bytes() {
562 // 4×4 gradient, alternating alpha 0/128/255 — every RGB byte must
563 // survive, alpha must become 255, and result must be non-uniform.
564 let (w, h) = (4usize, 4usize);
565 let mut input = Vec::with_capacity(w * h * 4);
566 for y in 0..h {
567 for x in 0..w {
568 let i = y * w + x;
569 let alpha = match i % 3 {
570 0 => 0u8,
571 1 => 128u8,
572 _ => 255u8,
573 };
574 input.extend_from_slice(&[
575 (x * 32) as u8,
576 (y * 32) as u8,
577 ((x + y) * 16) as u8,
578 alpha, // varying alpha incl 0
579 ]);
580 }
581 }
582 let out = prepare_opaque_rgba_for_upload(w, h, &input);
583 assert_eq!(out.len(), w * h * 4);
584 for (i, in_px) in input.chunks_exact(4).enumerate() {
585 let o = i * 4;
586 assert_eq!(out[o], in_px[0], "pixel {i} R changed");
587 assert_eq!(out[o + 1], in_px[1], "pixel {i} G changed");
588 assert_eq!(out[o + 2], in_px[2], "pixel {i} B changed");
589 assert_eq!(out[o + 3], 255, "pixel {i} alpha not opaque");
590 }
591 // Non-uniform: a patterned camera-like frame must stay patterned.
592 let first = &out[0..4];
593 assert!(
594 out.chunks_exact(4).any(|p| p != first),
595 "patterned input must not collapse to a uniform tile"
596 );
597 }
598
599 #[test]
600 fn video_frame_store_preserves_rgb_variance_for_patterned_frame() {
601 // Simulated camera-ish frame: horizontal gradient, real content.
602 let store = VideoFrameStore::new();
603 let (w, h) = (16u32, 2u32);
604 let mut rgba = Vec::with_capacity((w * h * 4) as usize);
605 for y in 0..h {
606 for x in 0..w {
607 rgba.extend_from_slice(&[(x * 16) as u8, (y * 120) as u8, 200u8, 0u8]);
608 }
609 }
610 store.set(LOCAL_PREVIEW_KEY, w, h, Arc::from(rgba));
611 let frame = store.snapshot().pop().unwrap().1;
612 // Alpha opaque, RGB unchanged, non-uniform (variance > 0).
613 let r_vals: Vec<u8> = frame.rgba.iter().step_by(4).copied().collect();
614 let min = *r_vals.iter().min().unwrap();
615 let max = *r_vals.iter().max().unwrap();
Run the formatter over the tree 3e8c6f0 nandi 13d ago616 assert!(
617 max > min,
618 "real camera-like content must have pixel variance"
619 );
Lift freeq's AV media plane out of sleek 90f8b89 nandi 19d ago620 for a in frame.rgba.iter().skip(3).step_by(4) {
621 assert_eq!(*a, 255);
622 }
623 assert_eq!(frame.rgba[0], 0);
624 assert_eq!(frame.rgba[4], 16);
625 }
626
627 #[test]
628 fn broadcast_path_matches_freeq_mesh_shape() {
629 assert_eq!(
630 broadcast_path("01SESS", "desktop", "a1b2c3d4"),
631 "01SESS/desktop~a1b2c3d4"
632 );
633 assert_eq!(broadcast_path("01SESS", "guest", ""), "01SESS/guest");
634 }
635
636 #[test]
637 fn sfu_moq_url_uses_av_moq_path_and_jwt() {
638 let u = sfu_moq_url("irc.freeq.at:6697", Some("tok.jwt.value")).unwrap();
639 assert_eq!(u.scheme(), "https");
640 assert_eq!(u.host_str(), Some("irc.freeq.at"));
641 assert_eq!(u.path(), "/av/moq");
642 assert_eq!(u.query(), Some("jwt=tok.jwt.value"));
643
644 let bare = sfu_moq_url("wss://irc.freeq.at/irc", None).unwrap();
645 assert_eq!(bare.path(), "/av/moq");
646 assert!(bare.query().is_none());
647 }
648
649 #[test]
650 fn sfu_moq_dial_url_includes_inst_and_jwt_like_freeq_app() {
651 let u = sfu_moq_dial_url(
652 "https://irc.freeq.at",
653 Some("eyJhbGciOiJIUzI1NiJ9.e30.x"),
654 Some("deadbeef"),
655 )
656 .unwrap();
657 assert_eq!(u.path(), "/av/moq");
658 let q = u.query().unwrap_or("");
659 assert!(q.contains("inst=deadbeef"), "query={q}");
660 assert!(q.contains("jwt=eyJhbGciOiJIUzI1NiJ9.e30.x"), "query={q}");
661 }
662
663 #[test]
664 fn collision_reason_parses_session_and_channel() {
665 let reason = "Channel #test already has an active session: 01KYRJEW9XCSYB2T10HM9RE1VN";
666 assert_eq!(
667 session_id_from_collision_reason(reason).as_deref(),
668 Some("01KYRJEW9XCSYB2T10HM9RE1VN")
669 );
670 assert_eq!(
671 channel_from_collision_reason(reason).as_deref(),
672 Some("#test")
673 );
674 }
675
676 #[test]
677 fn collision_reason_unknown_shape() {
678 assert!(session_id_from_collision_reason("nope").is_none());
679 assert!(channel_from_collision_reason("busy").is_none());
680 }
681
682 #[test]
683 fn can_dial_sfu_remote_without_jwt_refused() {
684 assert!(!can_dial_sfu("wss://chat.example.com", None));
685 assert!(!can_dial_sfu("wss://chat.example.com", Some("")));
686 assert!(!can_dial_sfu("https://freeq.example/irc", None));
687 assert!(!can_dial_sfu("chat.example.com:8443", None));
688 }
689
690 #[test]
691 fn can_dial_sfu_localhost_without_jwt_allowed() {
692 assert!(can_dial_sfu("ws://localhost:4443", None));
693 assert!(can_dial_sfu("http://localhost/av", None));
694 assert!(can_dial_sfu("localhost", None));
695 assert!(can_dial_sfu("ws://127.0.0.1:4443", None));
696 assert!(can_dial_sfu("http://127.0.0.1", None));
697 assert!(can_dial_sfu("127.0.0.1:8080", None));
698 }
699
700 #[test]
701 fn can_dial_sfu_with_jwt_always_allowed() {
Run the formatter over the tree 3e8c6f0 nandi 13d ago702 assert!(can_dial_sfu(
703 "wss://chat.example.com",
704 Some("eyJhbGciOiJIUzI1NiJ9.e30.x")
705 ));
Lift freeq's AV media plane out of sleek 90f8b89 nandi 19d ago706 assert!(can_dial_sfu("https://remote.example", Some("tok")));
707 assert!(can_dial_sfu("ws://localhost:4443", Some("tok")));
708 assert!(can_dial_sfu("127.0.0.1", Some("tok")));
709 }
710
711 #[test]
712 fn camera_publish_at_dial_requires_permission() {
713 assert!(!camera_publish_at_dial(true, false));
714 assert!(camera_publish_at_dial(true, true));
715 assert!(!camera_publish_at_dial(false, true));
716 assert!(!camera_publish_at_dial(false, false));
717 }
718
719 /// Contract for Android JNI: CameraCapture lives in APK classes.dex and
720 /// must be loaded with Activity ClassLoader + Java binary name (dots).
721 /// `FindClass("uk/nandi/sleek/CameraCapture")` from a native worker thread
722 /// uses the system loader and returns ClassNotFound — which made AV report
723 /// "camera unavailable" even with CAMERA granted and dex injected.
724 #[test]
725 fn android_camera_capture_class_binary_name() {
726 assert_eq!(
727 "uk.nandi.sleek.CameraCapture",
728 "uk.nandi.sleek.CameraCapture"
729 );
730 assert!(
731 !"uk.nandi.sleek.CameraCapture".contains('/'),
732 "ClassLoader.loadClass wants dots, not JNI slashes"
733 );
734 }
735}