From f168e8a962b47f11b55bd56dc4b5cc31b09dd9e2 Mon Sep 17 00:00:00 2001 From: Alvin Date: Thu, 12 Feb 2026 17:14:22 +0800 Subject: [PATCH 1/2] feat: handle app lifecycle events (Pause/Resume/Background/Foreground) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Save and restore app state across lifecycle transitions to support mobile platforms (especially Android) where the OS may suspend or background the app at any time. Key changes: - Replace monolithic Shutdown handler with a match on lifecycle events - On Pause/Background: save window geometry + AppState, stop sync service - On Foreground/Resume: restore state, restart sync, trigger full redraw - On Shutdown: save everything including TSP wallet (destructive, exit-only) - Add `state_saved` flag to deduplicate saves and gate restores - Factor save/restore logic into three methods on App: `save_lifecycle_state`, `save_all_state`, `restore_all_state` Design notes: - TSP's close_and_serialize() consumes ownership and tsp_init() uses OnceLock, so TSP state can only be saved on Shutdown (not Pause) - SyncService stop()/start() are idempotent — safe to call repeatedly - All blocking async calls use timeouts (2-3s) to avoid ANR on Android - Preserves logged_in state on restore to avoid stale persisted values Closes #458 --- src/app.rs | 145 +++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 118 insertions(+), 27 deletions(-) diff --git a/src/app.rs b/src/app.rs index eeb63f18..b3fc763e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -194,6 +194,10 @@ pub struct App { /// This can be either a room we're waiting to join, or one we're waiting to be invited to. /// Also includes an optional room ID to be closed once the awaited room has been loaded. #[rust] waiting_to_navigate_to_room: Option<(BasicRoomDetails, Option)>, + /// Whether app state has been saved in the current outgoing lifecycle + /// sequence (Pause → Background → Shutdown) and not yet restored. + /// Used to deduplicate saves and gate restores. + #[rust] state_saved: bool, } impl LiveRegister for App { @@ -650,38 +654,52 @@ fn clear_all_app_state(cx: &mut Cx) { impl AppMain for App { fn handle_event(&mut self, cx: &mut Cx, event: &Event) { - if let Event::Shutdown = event { - let window_ref = self.ui.window(ids!(main_window)); - if let Err(e) = persistence::save_window_state(window_ref, cx) { - error!("Failed to save window state. Error: {e}"); - } - if let Some(user_id) = current_user_id() { - let app_state = self.app_state.clone(); - if let Err(e) = persistence::save_app_state(app_state, user_id) { - error!("Failed to save app state. Error: {e}"); + match event { + // Outgoing: app is being suspended or hidden. + // Save state (deduplicated) and stop sync to save battery. + Event::Pause | Event::Background => { + if !self.state_saved { + self.save_lifecycle_state(cx); + } + // stop() is idempotent — safe if already stopped by a prior Pause. + if let Some(sync_service) = crate::sliding_sync::get_sync_service() { + if crate::sliding_sync::block_on_async_with_timeout( + Some(std::time::Duration::from_secs(2)), + async move { sync_service.stop().await }, + ).is_err() { + error!("Timed out while stopping sync service."); + } } } - #[cfg(feature = "tsp")] { - // Save the TSP wallet state, if it exists, with a 3-second timeout. - let tsp_state = std::mem::take(&mut *crate::tsp::tsp_state_ref().lock().unwrap()); - let res = crate::sliding_sync::block_on_async_with_timeout( - Some(std::time::Duration::from_secs(3)), - async move { - match tsp_state.close_and_serialize().await { - Ok(saved_state) => match persistence::save_tsp_state_async(saved_state).await { - Ok(_) => { } - Err(e) => error!("Failed to save TSP wallet state. Error: {e}"), - } - Err(e) => error!("Failed to close and serialize TSP wallet state. Error: {e}"), - } - }, - ); - if let Err(_e) = res { - error!("Failed to save TSP wallet state before app shutdown. Error: Timed Out."); + + // Incoming: app is returning to the foreground. + // Restore state (only if we previously saved) and restart sync. + Event::Foreground | Event::Resume => { + if self.state_saved { + self.restore_all_state(cx); + } + // start() is idempotent — no-op if already Running. + if let Some(sync_service) = crate::sliding_sync::get_sync_service() { + if crate::sliding_sync::block_on_async_with_timeout( + Some(std::time::Duration::from_secs(2)), + async move { sync_service.start().await }, + ).is_err() { + error!("Timed out while restarting sync service."); + } } + // Full redraw to ensure UI reflects any state changes. + self.ui.redraw(cx); } + + // Terminal: process is about to exit. + // Save everything including TSP (destructive, but process is ending). + Event::Shutdown => { + self.save_all_state(cx); + } + + _ => {} } - + // Forward events to the MatchEvent trait implementation. self.match_event(cx, event); let scope = &mut Scope::with_data(&mut self.app_state); @@ -722,6 +740,79 @@ impl AppMain for App { } impl App { + /// Saves window geometry and AppState to persistent storage. + /// Does NOT save TSP state (see `save_all_state` for that). + /// Best-effort: continues saving remaining parts even if one fails. + fn save_lifecycle_state(&mut self, cx: &mut Cx) { + let window_ref = self.ui.window(ids!(main_window)); + if let Err(e) = persistence::save_window_state(window_ref, cx) { + error!("Failed to save window state. Error: {e}"); + } + if let Some(user_id) = current_user_id() { + let app_state = self.app_state.clone(); + if let Err(e) = persistence::save_app_state(app_state, user_id) { + error!("Failed to save app state. Error: {e}"); + } + } + self.state_saved = true; + } + + /// Saves ALL app state including TSP wallet state. + /// TSP save is destructive (`close_and_serialize` consumes TspState), + /// so this should only be called when the process is about to exit. + fn save_all_state(&mut self, cx: &mut Cx) { + self.save_lifecycle_state(cx); + #[cfg(feature = "tsp")] { + let tsp_state = std::mem::take(&mut *crate::tsp::tsp_state_ref().lock().unwrap()); + let res = crate::sliding_sync::block_on_async_with_timeout( + Some(std::time::Duration::from_secs(3)), + async move { + match tsp_state.close_and_serialize().await { + Ok(saved_state) => match persistence::save_tsp_state_async(saved_state).await { + Ok(_) => { } + Err(e) => error!("Failed to save TSP wallet state. Error: {e}"), + } + Err(e) => error!("Failed to close and serialize TSP wallet state. Error: {e}"), + } + }, + ); + if let Err(_e) = res { + error!("Failed to save TSP wallet state before app shutdown. Error: Timed Out."); + } + } + } + + /// Restores window geometry and AppState from persistent storage. + /// TSP state is NOT restored — it's initialized once at startup via `tsp_init()` + /// and cannot be re-initialized (one-time CryptoProvider + channel setup). + /// Best-effort: continues restoring remaining parts even if one fails. + fn restore_all_state(&mut self, cx: &mut Cx) { + let window_ref = self.ui.window(ids!(main_window)); + if let Err(e) = persistence::load_window_state(window_ref, cx) { + error!("Failed to restore window state. Error: {e}"); + } + if let Some(user_id) = current_user_id() { + match crate::sliding_sync::block_on_async_with_timeout( + Some(std::time::Duration::from_secs(3)), + persistence::load_app_state(&user_id), + ) { + Ok(Ok(restored_state)) => { + // Preserve the actual logged_in state — the persisted value may be stale. + let logged_in = self.app_state.logged_in; + self.app_state = restored_state; + self.app_state.logged_in = logged_in; + cx.action(MainDesktopUiAction::LoadDockFromAppState); + } + Ok(Err(e)) => error!("Failed to restore app state. Error: {e}"), + Err(_) => error!("Timed out while restoring app state."), + } + } + + // Clear the pending-save flag after each restore attempt so + // future outgoing lifecycle events can persist fresh state. + self.state_saved = false; + } + fn update_login_visibility(&self, cx: &mut Cx) { let show_login = !self.app_state.logged_in; if !show_login { From 62cfbb93302ebc7be4fc86ff1b48371b4d3092f1 Mon Sep 17 00:00:00 2001 From: Alvin Date: Sun, 15 Feb 2026 18:59:41 +0800 Subject: [PATCH 2/2] fix(lifecycle): harden save/restore state gating --- src/app.rs | 50 +++++++++++++++++++++++++++++++++++++------------- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/src/app.rs b/src/app.rs index 0fbe751f..e76b403a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -662,8 +662,8 @@ impl AppMain for App { // Outgoing: app is being suspended or hidden. // Save state (deduplicated) and stop sync to save battery. Event::Pause | Event::Background => { - if !self.state_saved { - self.save_lifecycle_state(cx); + if !self.state_saved && !self.save_lifecycle_state(cx) { + error!("Lifecycle save incomplete; will retry on next outgoing event."); } // stop() is idempotent — safe if already stopped by a prior Pause. if let Some(sync_service) = crate::sliding_sync::get_sync_service() { @@ -679,8 +679,8 @@ impl AppMain for App { // Incoming: app is returning to the foreground. // Restore state (only if we previously saved) and restart sync. Event::Foreground | Event::Resume => { - if self.state_saved { - self.restore_all_state(cx); + if self.state_saved && !self.restore_all_state(cx) { + error!("Lifecycle restore incomplete; will retry on next incoming event."); } // start() is idempotent — no-op if already Running. if let Some(sync_service) = crate::sliding_sync::get_sync_service() { @@ -747,25 +747,35 @@ impl App { /// Saves window geometry and AppState to persistent storage. /// Does NOT save TSP state (see `save_all_state` for that). /// Best-effort: continues saving remaining parts even if one fails. - fn save_lifecycle_state(&mut self, cx: &mut Cx) { + /// Returns `true` only if all required saves succeed. + fn save_lifecycle_state(&mut self, cx: &mut Cx) -> bool { + let mut save_succeeded = true; let window_ref = self.ui.window(ids!(main_window)); if let Err(e) = persistence::save_window_state(window_ref, cx) { error!("Failed to save window state. Error: {e}"); + save_succeeded = false; } if let Some(user_id) = current_user_id() { let app_state = self.app_state.clone(); if let Err(e) = persistence::save_app_state(app_state, user_id) { error!("Failed to save app state. Error: {e}"); + save_succeeded = false; } } - self.state_saved = true; + + // Only mark pending restore when lifecycle save succeeded. + self.state_saved = save_succeeded; + save_succeeded } /// Saves ALL app state including TSP wallet state. /// TSP save is destructive (`close_and_serialize` consumes TspState), /// so this should only be called when the process is about to exit. fn save_all_state(&mut self, cx: &mut Cx) { - self.save_lifecycle_state(cx); + // Avoid redundant re-save if Pause/Background already persisted state. + if !self.state_saved && !self.save_lifecycle_state(cx) { + error!("Final lifecycle save incomplete during shutdown."); + } #[cfg(feature = "tsp")] { let tsp_state = std::mem::take(&mut *crate::tsp::tsp_state_ref().lock().unwrap()); let res = crate::sliding_sync::block_on_async_with_timeout( @@ -790,10 +800,13 @@ impl App { /// TSP state is NOT restored — it's initialized once at startup via `tsp_init()` /// and cannot be re-initialized (one-time CryptoProvider + channel setup). /// Best-effort: continues restoring remaining parts even if one fails. - fn restore_all_state(&mut self, cx: &mut Cx) { + /// Returns `true` only if all required restores succeed. + fn restore_all_state(&mut self, cx: &mut Cx) -> bool { + let mut restore_succeeded = true; let window_ref = self.ui.window(ids!(main_window)); if let Err(e) = persistence::load_window_state(window_ref, cx) { error!("Failed to restore window state. Error: {e}"); + restore_succeeded = false; } if let Some(user_id) = current_user_id() { match crate::sliding_sync::block_on_async_with_timeout( @@ -807,14 +820,25 @@ impl App { self.app_state.logged_in = logged_in; cx.action(MainDesktopUiAction::LoadDockFromAppState); } - Ok(Err(e)) => error!("Failed to restore app state. Error: {e}"), - Err(_) => error!("Timed out while restoring app state."), + Ok(Err(e)) => { + error!("Failed to restore app state. Error: {e}"); + restore_succeeded = false; + } + Err(_) => { + error!("Timed out while restoring app state."); + restore_succeeded = false; + } } } - // Clear the pending-save flag after each restore attempt so - // future outgoing lifecycle events can persist fresh state. - self.state_saved = false; + // Only clear pending-save flag after a successful restore. + // If restore failed, keep `state_saved = true` so next lifecycle + // incoming event (e.g., Resume after Foreground) can retry restore. + if restore_succeeded { + self.state_saved = false; + } + + restore_succeeded } fn update_login_visibility(&self, cx: &mut Cx) {