diff --git a/solitaire_data/src/achievements.rs b/solitaire_data/src/achievements.rs index d129510..b6aca92 100644 --- a/solitaire_data/src/achievements.rs +++ b/solitaire_data/src/achievements.rs @@ -15,7 +15,7 @@ const FILE_NAME: &str = "achievements.json"; /// Platform-specific default path for `achievements.json`. pub fn achievements_file_path() -> Option { - dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(FILE_NAME)) + crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(FILE_NAME)) } /// Load achievements from an explicit path. Returns `Vec::new()` if the file diff --git a/solitaire_data/src/lib.rs b/solitaire_data/src/lib.rs index 446be01..b952a3e 100644 --- a/solitaire_data/src/lib.rs +++ b/solitaire_data/src/lib.rs @@ -163,3 +163,6 @@ pub use replay::{ replay_history_path, save_replay_history_to, Replay, ReplayHistory, ReplayMove, REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, }; + +pub mod platform; +pub use platform::data_dir; diff --git a/solitaire_data/src/platform.rs b/solitaire_data/src/platform.rs new file mode 100644 index 0000000..a8bd36c --- /dev/null +++ b/solitaire_data/src/platform.rs @@ -0,0 +1,92 @@ +//! Per-platform resolution of the per-user data directory. +//! +//! The rest of `solitaire_data` (settings, stats, achievements, +//! replays, progress, game state) and the engine's user-themes +//! discovery all need a base path under which to nest +//! `solitaire_quest/`. On desktop the right answer is +//! `dirs::data_dir()` (which resolves to platform-appropriate +//! locations: `~/.local/share` on Linux, `~/Library/Application +//! Support` on macOS, `%APPDATA%` on Windows). On Android the +//! `dirs` crate returns `None`, which would silently disable +//! every persistence path — settings, stats, replays, the lot. +//! +//! [`data_dir`] is a thin shim that returns the right base path +//! per target. Callers continue to append +//! `solitaire_quest/` themselves, so the on-disk layout is +//! identical across platforms (the per-app Android sandbox makes +//! the extra `solitaire_quest/` segment harmless, and a `tar` +//! export from one platform deserialises cleanly on another). +//! +//! # Why hardcode on Android? +//! +//! The "proper" Android answer is JNI: call back into Java to +//! invoke `Activity.getFilesDir()`. That requires plumbing an +//! `AndroidApp` context through Bevy's startup hooks and a +//! per-call JNI bridge — meaningfully more code than the +//! sandbox-guaranteed `/data/data//files` path. The +//! package name `com.solitairequest.app` is fixed at compile +//! time in `solitaire_app/Cargo.toml`'s +//! `[package.metadata.android]` block, so a hardcoded path is +//! safe until that ever changes (at which point this constant +//! moves with it). + +use std::path::PathBuf; + +/// Hardcoded per-app private files directory on Android. +/// +/// Matches `[package.metadata.android]` in `solitaire_app/Cargo.toml`. +/// The Android sandbox guarantees this path exists, is writable, +/// and is private to the app — no JNI needed. Update both this +/// constant and the Cargo metadata together if the package id +/// ever changes. +#[cfg(target_os = "android")] +const ANDROID_APP_FILES_DIR: &str = "/data/data/com.solitairequest.app/files"; + +/// Returns the per-user data directory for the current target, +/// or `None` if the platform doesn't expose one (rare; usually +/// indicates a broken `$HOME` or `$XDG_*` configuration on a +/// minimal Linux container). +/// +/// Callers append `solitaire_quest/` themselves. See the +/// module-level doc comment for the per-platform behaviour and +/// why Android uses a hardcoded path. +pub fn data_dir() -> Option { + #[cfg(target_os = "android")] + { + Some(PathBuf::from(ANDROID_APP_FILES_DIR)) + } + #[cfg(not(target_os = "android"))] + { + dirs::data_dir() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// On every supported desktop target the OS reports a usable + /// data directory. This test only runs on desktop because the + /// Android branch returns a fixed string regardless of host + /// state, and asserting on a fixed string is a tautology. + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] + #[test] + fn data_dir_returns_some_on_desktop_targets() { + let dir = data_dir().expect("desktop targets must report a data dir"); + assert!( + dir.is_absolute(), + "data_dir() must return an absolute path on desktop, got {dir:?}", + ); + } + + /// On Android the hardcoded path matches the package id pinned + /// in `solitaire_app/Cargo.toml`'s `[package.metadata.android]`. + /// If a future change rotates that id, this test fails loudly + /// so the path constant moves with it. + #[cfg(target_os = "android")] + #[test] + fn data_dir_returns_sandbox_path_on_android() { + let dir = data_dir().expect("android must report a data dir"); + assert_eq!(dir, PathBuf::from("/data/data/com.solitairequest.app/files")); + } +} diff --git a/solitaire_data/src/progress.rs b/solitaire_data/src/progress.rs index 778f86c..a3cc95b 100644 --- a/solitaire_data/src/progress.rs +++ b/solitaire_data/src/progress.rs @@ -46,7 +46,7 @@ pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 { /// Platform-specific default path for `progress.json`. pub fn progress_file_path() -> Option { - dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(FILE_NAME)) + crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(FILE_NAME)) } /// Load progress from an explicit path. Returns `default()` if missing/corrupt. diff --git a/solitaire_data/src/replay.rs b/solitaire_data/src/replay.rs index 25c4f22..6e3e9e9 100644 --- a/solitaire_data/src/replay.rs +++ b/solitaire_data/src/replay.rs @@ -230,21 +230,21 @@ impl ReplayHistory { } /// Returns the platform-specific path to `latest_replay.json`, or `None` -/// if `dirs::data_dir()` is unavailable (e.g. minimal Linux containers). +/// if `crate::data_dir()` is unavailable (e.g. minimal Linux containers). #[deprecated( note = "single-slot replay storage replaced by the rolling history at \ replay_history_path(); kept for the one-shot legacy migration \ in migrate_legacy_latest_replay" )] pub fn latest_replay_path() -> Option { - dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(LATEST_REPLAY_FILE_NAME)) + crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(LATEST_REPLAY_FILE_NAME)) } /// Returns the platform-specific path to `replays.json`, the rolling -/// history file, or `None` if `dirs::data_dir()` is unavailable (e.g. +/// history file, or `None` if `crate::data_dir()` is unavailable (e.g. /// minimal Linux containers). pub fn replay_history_path() -> Option { - dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(REPLAY_HISTORY_FILE_NAME)) + crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(REPLAY_HISTORY_FILE_NAME)) } /// Save a [`Replay`] atomically to `path` using the standard `.tmp` → diff --git a/solitaire_data/src/settings.rs b/solitaire_data/src/settings.rs index 83cc454..390054c 100644 --- a/solitaire_data/src/settings.rs +++ b/solitaire_data/src/settings.rs @@ -400,9 +400,9 @@ impl Settings { } /// Returns the platform-specific path to `settings.json`, or `None` if -/// `dirs::data_dir()` is unavailable. +/// the platform's data directory is unavailable. pub fn settings_file_path() -> Option { - dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(SETTINGS_FILE_NAME)) + crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(SETTINGS_FILE_NAME)) } /// Load settings from an explicit path. Returns `Settings::default()` if the diff --git a/solitaire_data/src/storage.rs b/solitaire_data/src/storage.rs index 478de25..0da45f8 100644 --- a/solitaire_data/src/storage.rs +++ b/solitaire_data/src/storage.rs @@ -19,9 +19,9 @@ const GAME_STATE_FILE_NAME: &str = "game_state.json"; const TIME_ATTACK_SESSION_FILE_NAME: &str = "time_attack_session.json"; /// Returns the platform-specific path to `stats.json`, or `None` if -/// `dirs::data_dir()` is unavailable (e.g. minimal Linux containers). +/// `crate::data_dir()` is unavailable (e.g. minimal Linux containers). pub fn stats_file_path() -> Option { - dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(STATS_FILE_NAME)) + crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(STATS_FILE_NAME)) } /// Load stats from an explicit path. Returns `StatsSnapshot::default()` if @@ -69,9 +69,9 @@ pub fn save_stats(stats: &StatsSnapshot) -> io::Result<()> { // --------------------------------------------------------------------------- /// Returns the platform-specific path to `game_state.json`, or `None` if -/// `dirs::data_dir()` is unavailable. +/// `crate::data_dir()` is unavailable. pub fn game_state_file_path() -> Option { - dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(GAME_STATE_FILE_NAME)) + crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(GAME_STATE_FILE_NAME)) } /// Load an in-progress `GameState` from `path`. Returns `None` if the file is @@ -129,7 +129,7 @@ pub fn delete_game_state_at(path: &Path) -> io::Result<()> { /// in an atomic save. Safe to call on startup; missing or unreadable entries /// are silently skipped. pub fn cleanup_orphaned_tmp_files() -> io::Result<()> { - let dir = match dirs::data_dir() { + let dir = match crate::data_dir() { Some(d) => d.join(APP_DIR_NAME), None => return Ok(()), }; @@ -179,9 +179,9 @@ pub struct TimeAttackSession { } /// Returns the platform-specific path to `time_attack_session.json`, or -/// `None` if `dirs::data_dir()` is unavailable. +/// `None` if `crate::data_dir()` is unavailable. pub fn time_attack_session_path() -> Option { - dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(TIME_ATTACK_SESSION_FILE_NAME)) + crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(TIME_ATTACK_SESSION_FILE_NAME)) } /// Save a Time Attack session atomically. Mirrors `save_game_state_to`'s diff --git a/solitaire_engine/src/assets/user_dir.rs b/solitaire_engine/src/assets/user_dir.rs index 6fb8cb2..d31dcaa 100644 --- a/solitaire_engine/src/assets/user_dir.rs +++ b/solitaire_engine/src/assets/user_dir.rs @@ -1,13 +1,12 @@ //! Per-platform resolution of the user-themes directory. //! //! The path is determined exactly once and exposed via -//! [`user_theme_dir`]. On desktop platforms it is derived from -//! `dirs::data_dir()` (matching the rest of the project's -//! per-app-storage convention); on mobile it must be supplied by the -//! platform entry point via [`set_user_theme_dir`] before any code -//! that needs the path executes — there is deliberately no silent -//! fallback because mobile sandboxing makes any guess we'd hard-code -//! wrong. +//! [`user_theme_dir`]. The base directory comes from +//! [`solitaire_data::data_dir`] (desktop: `dirs::data_dir()`; +//! Android: the hardcoded `/data/data//files` sandbox +//! path). Mobile entry points may still override the path via +//! [`set_user_theme_dir`] when they need to point at a non-default +//! location (e.g. tests, custom AssetManager wiring). //! //! # Why panic instead of returning Result? //! @@ -35,17 +34,18 @@ const APP_DIR_NAME: &str = "solitaire_quest"; /// Sub-folder under [`APP_DIR_NAME`] dedicated to user themes. const THEME_DIR_NAME: &str = "themes"; -/// Sets the user-themes directory at runtime — mobile-only API. +/// Sets the user-themes directory at runtime — escape hatch for +/// embedders or tests that need to override the platform default. /// /// Returns `Err` containing the rejected path if the override has /// already been set. The first caller wins and subsequent calls are /// silently a no-op-with-feedback so a mis-configured embedder can't /// flip the path mid-session. /// -/// On desktop platforms this is functional but unnecessary — -/// [`user_theme_dir`] derives the path from `dirs::data_dir` directly -/// and ignores the override. Setting it on desktop is harmless but -/// nearly always a sign of confusion. +/// Mostly unnecessary now that [`solitaire_data::data_dir`] handles +/// every supported target — the override is kept for tests and for +/// embedders that want a non-default location (e.g. a sandboxed +/// AssetManager root on a future iOS port). pub fn set_user_theme_dir(path: PathBuf) -> Result<(), PathBuf> { USER_THEME_DIR_OVERRIDE.set(path) } @@ -55,16 +55,10 @@ pub fn set_user_theme_dir(path: PathBuf) -> Result<(), PathBuf> { /// /// # Panics /// -/// Panics on: -/// -/// - Desktop, if `dirs::data_dir()` returns `None` (rare; usually -/// indicates a broken `$HOME` or `$XDG_*` configuration). -/// - Mobile, if no entry point has called [`set_user_theme_dir`] yet. -/// - Any other target, where the embedder is required to supply the -/// path manually. -/// -/// The panic message names the missing piece so the failure is -/// immediately actionable. +/// Panics if [`solitaire_data::data_dir`] returns `None`, which on +/// desktop indicates a broken `$HOME` / `$XDG_*` configuration. +/// Android always returns `Some`. The panic message names the +/// supported workaround ([`set_user_theme_dir`]). pub fn user_theme_dir() -> PathBuf { if let Some(p) = USER_THEME_DIR_OVERRIDE.get() { return p.clone(); @@ -79,45 +73,23 @@ fn user_theme_dir_for(data_dir: PathBuf) -> PathBuf { data_dir.join(APP_DIR_NAME).join(THEME_DIR_NAME) } -/// Per-target-os resolution of the platform's data dir. Split out so -/// mobile branches can grow without disturbing desktop behaviour. +/// Per-target-os resolution of the platform's data dir. Delegates +/// to [`solitaire_data::data_dir`] which encapsulates the +/// per-target shape (desktop: `dirs::data_dir()`; android: the +/// hardcoded `/data/data//files` sandbox path). Panics +/// only when the underlying resolver returns `None`, which on +/// desktop indicates a broken `$HOME` / `$XDG_*` configuration — +/// the panic message names the supported workaround. fn detected_platform_data_dir() -> PathBuf { - #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] - { - dirs::data_dir().unwrap_or_else(|| { - panic!( - "user_theme_dir(): platform data directory is unavailable. \ - On Linux check $XDG_DATA_HOME or $HOME; on macOS / Windows \ - the OS reported no Application Support / AppData path. \ - As a workaround call solitaire_engine::assets::user_dir::\ - set_user_theme_dir() before App::run()." - ) - }) - } - - #[cfg(any(target_os = "android", target_os = "ios"))] - { + solitaire_data::data_dir().unwrap_or_else(|| { panic!( - "user_theme_dir(): mobile entry point must call \ - solitaire_engine::assets::user_dir::set_user_theme_dir() \ - before App::run() — there is no platform default." + "user_theme_dir(): platform data directory is unavailable. \ + On Linux check $XDG_DATA_HOME or $HOME; on macOS / Windows \ + the OS reported no Application Support / AppData path. \ + As a workaround call solitaire_engine::assets::user_dir::\ + set_user_theme_dir() before App::run()." ) - } - - #[cfg(not(any( - target_os = "linux", - target_os = "macos", - target_os = "windows", - target_os = "android", - target_os = "ios" - )))] - { - panic!( - "user_theme_dir(): unsupported platform; call \ - solitaire_engine::assets::user_dir::set_user_theme_dir() \ - from your entry point before App::run()." - ) - } + }) } #[cfg(test)] @@ -140,14 +112,16 @@ mod tests { assert_eq!(dir, PathBuf::from("solitaire_quest/themes")); } - #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] #[test] fn detected_data_dir_yields_a_path_with_a_parent() { - // On every supported desktop platform the OS reports a - // user-writable data directory; the test machine already has - // one for `dirs::data_dir()` to discover. We don't pin the - // exact value because it depends on the user's $HOME, but it - // must at least be a non-empty path with a parent component. + // On every supported target the platform resolver + // (`solitaire_data::data_dir`) returns a usable directory: + // desktop targets via `dirs::data_dir()` (the test machine + // already has a `$HOME` for it to discover), Android via + // the hardcoded `/data/data//files` sandbox path. + // We don't pin the exact value because it depends on the + // user's `$HOME` on desktop, but it must at least be a + // non-empty path with a parent component. let dir = detected_platform_data_dir(); assert!(dir.parent().is_some(), "data dir {dir:?} should be absolute"); }