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