diff --git a/android/src/android_media.rs b/android/src/android_media.rs index bb70446..284a0da 100644 --- a/android/src/android_media.rs +++ b/android/src/android_media.rs @@ -52,7 +52,10 @@ pub fn android_app_handle() -> Option<&'static AndroidApp> { /// The `dirs` crate does not support Android, so auth storage must use the /// NativeActivity internal files dir instead of `dirs::config_dir()`. pub fn app_storage_dir() -> Option { - let base = android_app()?.internal_data_path()?; + let app = android_app()?; + let base = app + .internal_data_path() + .or_else(|| app.external_data_path())?; Some(base.join("sleek")) } diff --git a/android/src/app.rs b/android/src/app.rs index ded284d..af408eb 100644 --- a/android/src/app.rs +++ b/android/src/app.rs @@ -313,9 +313,6 @@ impl SleekApp { { return; } - self.state - .handle_typeahead - .sync_from_input(&self.state.form_handle); if let Some((request_id, query)) = self.state.handle_typeahead.take_ready_fetch() { self.net.send(NetCmd::SearchHandles { request_id, query }); } diff --git a/android/src/auth.rs b/android/src/auth.rs index 0477129..90da0ac 100644 --- a/android/src/auth.rs +++ b/android/src/auth.rs @@ -122,12 +122,78 @@ fn storage_dir() -> PathBuf { .join("sleek") } +#[cfg(target_os = "android")] +fn android_legacy_storage_roots() -> Vec { + let mut roots = vec![PathBuf::from(".").join("sleek")]; + if let Some(app) = crate::android_media::android_app_handle() { + if let Some(ext) = app.external_data_path() { + let ext_root = ext.join("sleek"); + if !roots.iter().any(|r| r == &ext_root) { + roots.push(ext_root); + } + } + } + roots +} + +/// Before #42, Android wrote prefs/session to the process cwd (`./sleek/`) because +/// `dirs::config_dir()` is unavailable. Migrate those files into the app files dir. +#[cfg(target_os = "android")] +fn migrate_legacy_android_file(legacy: &Path, target: &Path) -> bool { + migrate_storage_file(legacy, target) +} + +/// Move or copy `legacy` to `target` when the target does not exist yet. +fn migrate_storage_file(legacy: &Path, target: &Path) -> bool { + if target.exists() || !legacy.exists() { + return false; + } + if let Some(parent) = target.parent() { + if std::fs::create_dir_all(parent).is_err() { + return false; + } + } + match std::fs::rename(legacy, target) { + Ok(()) => true, + Err(_) => { + if std::fs::copy(legacy, target).is_ok() { + let _ = std::fs::remove_file(legacy); + true + } else { + false + } + } + } +} + +#[cfg(target_os = "android")] +fn ensure_android_storage_migrated(file_name: &str) { + let target = storage_dir().join(file_name); + for root in android_legacy_storage_roots() { + let legacy = root.join(file_name); + if legacy == target { + continue; + } + if migrate_legacy_android_file(&legacy, &target) { + log::info!( + "android: migrated {} -> {}", + legacy.display(), + target.display() + ); + break; + } + } +} + impl SavedPrefs { pub fn path() -> PathBuf { storage_dir().join("prefs.json") } pub fn load() -> Self { + #[cfg(target_os = "android")] + ensure_android_storage_migrated("prefs.json"); + let mut prefs = load_prefs(&Self::path()).unwrap_or_default(); // Drop virtual camera prefs by name/id substring (OBS / loopback). // Device paths like `/dev/video10` are scrubbed at dial time once we @@ -214,6 +280,9 @@ impl SavedSession { } pub fn load() -> Option { + #[cfg(target_os = "android")] + ensure_android_storage_migrated("session.json"); + load_session(&Self::path()) } @@ -752,4 +821,80 @@ mod tests { Some("a.bsky.social") ); } + + #[test] + fn prefs_save_load_roundtrip_preserves_handle_history() { + let dir = std::env::temp_dir().join(format!( + "sleek-prefs-test-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("prefs.json"); + + let mut prefs = SavedPrefs::default(); + prefs.last_bsky_handle = Some("alice.bsky.social".into()); + prefs.recent_handles = vec![ + "alice.bsky.social".into(), + "bob.bsky.social".into(), + ]; + save_prefs(&path, &prefs).unwrap(); + + let loaded = load_prefs(&path).expect("prefs.json should parse"); + assert_eq!( + loaded.recent_handles, + vec![ + "alice.bsky.social".to_string(), + "bob.bsky.social".to_string(), + ] + ); + assert_eq!( + loaded.last_bsky_handle.as_deref(), + Some("alice.bsky.social") + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn migrate_storage_file_moves_legacy_prefs() { + let dir = std::env::temp_dir().join(format!( + "sleek-migrate-test-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + let legacy_dir = dir.join("legacy"); + let target_dir = dir.join("target"); + std::fs::create_dir_all(&legacy_dir).unwrap(); + let legacy = legacy_dir.join("prefs.json"); + let target = target_dir.join("prefs.json"); + std::fs::write(&legacy, r#"{"recent_handles":["a.bsky.social"]}"#).unwrap(); + + assert!(migrate_storage_file(&legacy, &target)); + assert!(!legacy.exists()); + assert!(target.exists()); + let loaded = load_prefs(&target).unwrap(); + assert_eq!(loaded.recent_handles, vec!["a.bsky.social".to_string()]); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn migrate_storage_file_skips_when_target_exists() { + let dir = std::env::temp_dir().join(format!( + "sleek-migrate-skip-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let legacy = dir.join("legacy.json"); + let target = dir.join("target.json"); + std::fs::write(&legacy, "{}").unwrap(); + std::fs::write(&target, "{}").unwrap(); + + assert!(!migrate_storage_file(&legacy, &target)); + assert!(legacy.exists()); + + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/android/src/state.rs b/android/src/state.rs index bdb8393..c654111 100644 --- a/android/src/state.rs +++ b/android/src/state.rs @@ -1336,6 +1336,13 @@ impl AppState { } if let Some(h) = &state.handle { state.form_handle = h.clone(); + // Ensure saved Bluesky handle appears in login history even + // if prefs were lost / written to a legacy path before #42. + let prior = state.recent_handles.clone(); + push_mru(&mut state.recent_handles, h, MAX_RECENT_HANDLES); + if state.recent_handles != prior { + state.persist_prefs(); + } } state.connect_mode = ConnectMode::Bluesky; } else if saved.has_guest() { @@ -1693,6 +1700,17 @@ impl AppState { self.form_handle.clear(); self.auto_guest_connect = false; crate::auth::SavedSession::clear(); + // Reload MRU lists from disk in case in-memory state was cleared without + // a matching prefs write (e.g. process death mid-login). + if self.recent_handles.is_empty() || self.recent_nicks.is_empty() { + let prefs = crate::auth::SavedPrefs::load(); + if self.recent_handles.is_empty() { + self.recent_handles = normalize_recent_handles(prefs.recent_handles); + } + if self.recent_nicks.is_empty() { + self.recent_nicks = normalize_recent_nicks(prefs.recent_nicks); + } + } // Restore remembered handle from prefs / MRU so it's pre-filled for next login. if let Some(handle) = self.recent_handles.first().cloned() { self.form_handle = handle; diff --git a/android/src/ui/connect.rs b/android/src/ui/connect.rs index 6b0c921..e2db457 100644 --- a/android/src/ui/connect.rs +++ b/android/src/ui/connect.rs @@ -31,7 +31,7 @@ pub fn connect_screen(ui: &mut egui::Ui, th: &Theme, state: &mut AppState) -> Co body(ui, th, "Bluesky handle"); ui.add_space(sp.xs); handle_field(ui, th, state, loading); - if !state.handle_typeahead.open && !state.recent_handles.is_empty() { + if !handle_typeahead_dropdown_visible(state) && !state.recent_handles.is_empty() { ui.add_space(sp.xs); let handles = state.recent_handles.clone(); history_chips(ui, th, "Recent", &handles, |picked| { @@ -199,6 +199,12 @@ pub fn connect_screen(ui: &mut egui::Ui, th: &Theme, state: &mut AppState) -> Co action } +/// True when the Bluesky handle typeahead popover is on screen (loading or results). +fn handle_typeahead_dropdown_visible(state: &AppState) -> bool { + state.handle_typeahead.open + && (!state.handle_typeahead.suggestions.is_empty() || state.handle_typeahead.loading) +} + /// Clickable MRU chips under a connect-form field (recent nicks / handles). fn history_chips( ui: &mut egui::Ui, @@ -250,10 +256,13 @@ fn handle_field(ui: &mut egui::Ui, th: &Theme, state: &mut AppState, login_loadi .interactive(!login_loading), ); - state.handle_typeahead.sync_from_input(&state.form_handle); + // Only sync while the user is editing — prefilled handles from prefs should + // not open typeahead on cold start and hide the Recent chips. + if resp.has_focus() || resp.changed() { + state.handle_typeahead.sync_from_input(&state.form_handle); + } - let typeahead_open = state.handle_typeahead.open - && (!state.handle_typeahead.suggestions.is_empty() || state.handle_typeahead.loading); + let typeahead_open = handle_typeahead_dropdown_visible(state); if resp.has_focus() && typeahead_open { let mut accept = false;