diff --git a/android/src/app.rs b/android/src/app.rs index 28150f8..f8141f1 100644 --- a/android/src/app.rs +++ b/android/src/app.rs @@ -241,6 +241,38 @@ impl SleekApp { apply(ctx, &self.theme()); } + /// Fire a scheduled auto-reconnect once its backoff deadline elapses. + fn poll_auto_reconnect(&mut self, ctx: &egui::Context) { + let Some(at) = self.state.reconnect_at else { + return; + }; + // Keep painting so the countdown status updates and the deadline is checked. + ctx.request_repaint_after(std::time::Duration::from_millis(200)); + if std::time::Instant::now() < at { + let secs = at + .saturating_duration_since(std::time::Instant::now()) + .as_secs() + .max(1); + self.state.status_line = format!("Reconnecting in {secs}s…"); + return; + } + if self.state.connection != ConnectionState::Disconnected { + self.state.reconnect_at = None; + return; + } + if self.state.intentional_disconnect || self.state.nick.trim().is_empty() { + self.state.cancel_auto_reconnect(); + return; + } + self.state.reconnect_at = None; + self.state.error = None; + if self.state.has_saved_session() { + self.do_reconnect_session(); + } else { + self.do_connect(); + } + } + fn poll_net(&mut self, ctx: &egui::Context) { for ev in self.net.poll() { self.handle_net_event(ev); @@ -303,6 +335,14 @@ impl SleekApp { if self.state.connection == ConnectionState::Connecting { self.state.connection = ConnectionState::Disconnected; } + // A failed reconnect attempt (after EOF etc.) should keep backing off. + if !self.state.intentional_disconnect + && self.state.reconnect_attempts > 0 + && !self.state.nick.trim().is_empty() + && self.state.reconnect_at.is_none() + { + self.state.schedule_auto_reconnect(); + } } NetEvent::AuthReady(tokens) => { self.state.awaiting_oauth = false; @@ -535,6 +575,7 @@ impl SleekApp { if self.state.did.is_some() && nick.starts_with("Guest") { self.state .show_toast("Guest nick after auth — refreshing broker session…"); + self.state.intentional_disconnect = true; self.net.send(NetCmd::Quit); self.state.connection = ConnectionState::Disconnected; if self.state.has_saved_session() { @@ -547,6 +588,8 @@ impl SleekApp { self.state.form_nick = nick.clone(); self.state.status_line = format!("Online as {nick}"); self.state.error = None; + self.state.cancel_auto_reconnect(); + self.state.intentional_disconnect = false; self.state.show_toast(format!("Connected as {nick}")); self.state.persist_session(); // Seed status buffer @@ -869,8 +912,29 @@ impl SleekApp { } } Event::Disconnected { reason } => { - self.state.clear_session(); - if reason != "quit" { + let intentional = self.state.intentional_disconnect || reason == "quit"; + self.state.intentional_disconnect = false; + + // Quit after "Guest nick → refresh session" already kicked off + // Connecting — don't clobber that in-flight restore. + if intentional { + if self.state.connection == ConnectionState::Connecting { + return; + } + self.state.clear_session(); + return; + } + + // Unexpected EOF / ping timeout / socket drop — keep buffers and + // auto-reconnect with backoff (freeq-android parity). + let nick_empty = self.state.nick.trim().is_empty(); + if crate::reconnect::should_auto_reconnect(false, nick_empty, &reason) { + self.state.mark_disconnected_for_reconnect(&reason); + self.state.error = Some(format!("Disconnected: {reason}")); + self.state.show_toast(format!("Disconnected: {reason}")); + self.state.schedule_auto_reconnect(); + } else { + self.state.clear_session(); self.state.show_toast(format!("Disconnected: {reason}")); self.state.error = Some(reason); } @@ -1352,6 +1416,8 @@ impl SleekApp { return; } self.state.error = None; + self.state.intentional_disconnect = false; + self.state.reconnect_at = None; self.state.connection = ConnectionState::Connecting; self.state.awaiting_oauth = false; self.state.nick = nick.clone(); @@ -1374,6 +1440,14 @@ impl SleekApp { }); } + /// User-initiated disconnect: suppress auto-reconnect for the Quit event. + fn do_intentional_disconnect(&mut self) { + self.state.intentional_disconnect = true; + self.state.cancel_auto_reconnect(); + self.net.send(NetCmd::Quit); + self.state.clear_session(); + } + fn do_bluesky_login(&mut self, ctx: &egui::Context) { let handle = self .state @@ -1479,6 +1553,8 @@ impl SleekApp { return; }; self.state.error = None; + self.state.intentional_disconnect = false; + self.state.reconnect_at = None; self.state.connection = ConnectionState::Connecting; self.state.status_line = "Restoring session…".into(); self.net.send(NetCmd::ReconnectSession { @@ -1871,6 +1947,7 @@ fn server_time_from_tags( impl eframe::App for SleekApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.apply_fit_viewport(ctx); + self.poll_auto_reconnect(ctx); self.poll_net(ctx); self.poll_file_pick(ctx); self.poll_oauth_deep_link(ctx); @@ -2050,7 +2127,10 @@ impl eframe::App for SleekApp { if landscape && short { ui.horizontal(|ui| { title(ui, &th, "Sleek"); - if connected || self.state.connection == ConnectionState::Connecting { + if connected + || self.state.connection == ConnectionState::Connecting + || self.state.reconnect_at.is_some() + { ui.add_space(sp.sm); let blurb = if self.state.connection == ConnectionState::Registered { @@ -2067,7 +2147,10 @@ impl eframe::App for SleekApp { title(ui, &th, "Sleek"); // Subtitle only when connected or mid-connect — idle connect // already has its own form; avoid stacking the same chrome. - if connected || self.state.connection == ConnectionState::Connecting { + if connected + || self.state.connection == ConnectionState::Connecting + || self.state.reconnect_at.is_some() + { ui.add_space(sp.xs + 2.0); let blurb = if self.state.connection == ConnectionState::Registered { @@ -2244,11 +2327,12 @@ impl eframe::App for SleekApp { match ui::settings_tab(ui, &th, &mut self.state, self.mode) { SettingsAction::None => {} SettingsAction::Disconnect => { - self.net.send(NetCmd::Quit); - self.state.clear_session(); + self.do_intentional_disconnect(); } SettingsAction::Logout => { // Full clear: IRC + disk session → real guest next time. + self.state.intentional_disconnect = true; + self.state.cancel_auto_reconnect(); self.net.send(NetCmd::Quit); self.state.logout(); } @@ -2264,8 +2348,9 @@ impl eframe::App for SleekApp { } } else if self.state.connection == ConnectionState::Connecting || self.state.connection == ConnectionState::Connected + || self.state.reconnect_at.is_some() { - // Waiting for registration + // Waiting for registration / auto-reconnect backoff ui.vertical_centered(|ui| { ui.add_space(sp.xl * 2.0); title(ui, &th, "Connecting"); @@ -2273,8 +2358,7 @@ impl eframe::App for SleekApp { dim_label(ui, &th, &self.state.status_line); ui.add_space(sp.lg); if button(ui, &th, "Cancel").clicked() { - self.net.send(NetCmd::Quit); - self.state.clear_session(); + self.do_intentional_disconnect(); } }); } else { @@ -2285,6 +2369,8 @@ impl eframe::App for SleekApp { ConnectAction::ApplyCallback => self.do_apply_callback(), ConnectAction::ReconnectSession => self.do_reconnect_session(), ConnectAction::ClearAccount => { + self.state.intentional_disconnect = true; + self.state.cancel_auto_reconnect(); self.net.send(NetCmd::Quit); self.state.logout(); } diff --git a/android/src/lib.rs b/android/src/lib.rs index 1c53a2a..70a4116 100644 --- a/android/src/lib.rs +++ b/android/src/lib.rs @@ -7,6 +7,7 @@ mod av_media; mod clipboard; mod net; mod preview; +mod reconnect; mod slash; mod state; mod ui; diff --git a/android/src/reconnect.rs b/android/src/reconnect.rs new file mode 100644 index 0000000..a467a8f --- /dev/null +++ b/android/src/reconnect.rs @@ -0,0 +1,70 @@ +//! Auto-reconnect schedule after unexpected disconnects (EOF, ping timeout, …). +//! +//! Mirrors freeq-android `ReconnectBackoff`: `2^attempts` seconds, clamped at 30. + +/// Delay before the next reconnect attempt. +/// +/// `attempts` is 1-based (increment before calling). Zero → 0. +pub fn delay_secs(attempts: u32) -> u64 { + if attempts == 0 { + return 0; + } + // freeq-android: `1L shl minOf(attempts, 5)` then clamp to 30. + // attempts=1 → 2s, 2 → 4s, …, 5+ → 30s. + let shift = attempts.min(5); + (1u64 << shift).min(30) +} + +/// Whether an unexpected disconnect should trigger auto-reconnect. +pub fn should_auto_reconnect(intentional: bool, nick_empty: bool, reason: &str) -> bool { + if intentional || nick_empty { + return false; + } + // User-initiated quit from the net layer — never bounce back. + reason != "quit" +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn first_attempt_waits_two_seconds() { + assert_eq!(delay_secs(1), 2); + } + + #[test] + fn doubles_each_attempt() { + assert_eq!(delay_secs(1), 2); + assert_eq!(delay_secs(2), 4); + assert_eq!(delay_secs(3), 8); + assert_eq!(delay_secs(4), 16); + } + + #[test] + fn caps_at_thirty_seconds() { + assert_eq!(delay_secs(5), 30); + assert_eq!(delay_secs(6), 30); + assert_eq!(delay_secs(20), 30); + assert_eq!(delay_secs(u32::MAX), 30); + } + + #[test] + fn zero_attempts_returns_zero() { + assert_eq!(delay_secs(0), 0); + } + + #[test] + fn should_reconnect_on_eof() { + assert!(should_auto_reconnect(false, false, "EOF")); + assert!(should_auto_reconnect(false, false, "Ping timeout")); + assert!(should_auto_reconnect(false, false, "event channel closed")); + } + + #[test] + fn should_not_reconnect_on_quit_or_intentional() { + assert!(!should_auto_reconnect(false, false, "quit")); + assert!(!should_auto_reconnect(true, false, "EOF")); + assert!(!should_auto_reconnect(false, true, "EOF")); + } +} diff --git a/android/src/state.rs b/android/src/state.rs index f2b6982..50e639b 100644 --- a/android/src/state.rs +++ b/android/src/state.rs @@ -1029,6 +1029,13 @@ pub struct AppState { /// Recently visited channels (MRU first). Persisted in prefs.json and /// auto-rejoined on connect — server membership restore is unreliable. pub recent_channels: Vec, + + /// User clicked Disconnect / Cancel / Logout — do not auto-reconnect. + pub intentional_disconnect: bool, + /// Consecutive unexpected disconnect / failed-reconnect attempts. + pub reconnect_attempts: u32, + /// When set, fire auto-reconnect once `Instant::now() >=` this deadline. + pub reconnect_at: Option, } /// egui texture map for AV tiles (`TextureHandle` is not `Debug`). @@ -1136,6 +1143,9 @@ impl AppState { av_show_devices: false, av_video_height: 220.0, recent_channels: normalize_recent_channels(prefs.recent_channels), + intentional_disconnect: false, + reconnect_attempts: 0, + reconnect_at: None, }; if let Some(saved) = crate::auth::SavedSession::load() { if saved.has_session() { @@ -1802,6 +1812,7 @@ impl AppState { pub fn clear_session(&mut self) { self.connection = ConnectionState::Disconnected; self.awaiting_oauth = false; + self.cancel_auto_reconnect(); // Keep did / broker_token so reconnect can re-auth; use clear_auth for logout. self.channels.clear(); self.channel_order.clear(); @@ -1824,6 +1835,29 @@ impl AppState { self.status_line = "Disconnected".into(); } + /// Soft disconnect for unexpected EOF / ping timeout: keep chat buffers so + /// auto-reconnect can restore without wiping the conversation view. + pub fn mark_disconnected_for_reconnect(&mut self, reason: &str) { + self.connection = ConnectionState::Disconnected; + self.awaiting_oauth = false; + self.local_call = None; + self.clear_av_media(); + self.status_line = format!("Disconnected: {reason}"); + } + + pub fn cancel_auto_reconnect(&mut self) { + self.reconnect_at = None; + self.reconnect_attempts = 0; + } + + /// Schedule the next auto-reconnect attempt (exponential backoff). + pub fn schedule_auto_reconnect(&mut self) { + self.reconnect_attempts = self.reconnect_attempts.saturating_add(1); + let delay = crate::reconnect::delay_secs(self.reconnect_attempts); + self.reconnect_at = Some(Instant::now() + Duration::from_secs(delay)); + self.status_line = format!("Reconnecting in {delay}s…"); + } + /// Drop MoQ video store, textures, mic meter, and focus (call ended or media stopped). pub fn clear_av_media(&mut self) { self.av_video = None; diff --git a/flake.lock b/flake.lock index 875f7eb..29865ec 100644 --- a/flake.lock +++ b/flake.lock @@ -3,11 +3,11 @@ "freeq": { "flake": false, "locked": { - "lastModified": 1785144955, - "narHash": "sha256-7vpp+bIwvAamo3EIrLLmaUR39CMa0k+i4rgJ4P5McpE=", + "lastModified": 1785546552, + "narHash": "sha256-l6zuuC4S0lMAworB/1lsF05+rx+uyHb0gV6qOMBgPbA=", "owner": "codegod100", "repo": "freeq", - "rev": "1b5bd537ab49f27b40c5d2382e7a9b82d2040b3d", + "rev": "96fab1e7b3dfefb9c03dbac18d49718610e51725", "type": "github" }, "original": {