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