fix(data): route data_dir() through a per-platform shim so Android persists

dirs::data_dir() returns None on Android, which silently disabled
every persistence path (settings, stats, achievements, replays,
game-state, time-attack sessions, user themes). New
solitaire_data::platform::data_dir() shim falls through to
dirs::data_dir() on desktop and returns the per-app sandbox at
/data/data/com.solitairequest.app/files on Android — no JNI needed,
since the package id is pinned in
[package.metadata.android].

CLAUDE.md §10 already flagged this as a known pitfall; the shim
pays it down at the one chokepoint instead of per feature.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-07 17:55:49 -07:00
parent f2d2119db5
commit 4b51e50203
8 changed files with 148 additions and 79 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ const FILE_NAME: &str = "achievements.json";
/// Platform-specific default path for `achievements.json`.
pub fn achievements_file_path() -> Option<PathBuf> {
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
+3
View File
@@ -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;
+92
View File
@@ -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/<file>`. 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/<file>` 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/<package>/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/<file>` themselves. See the
/// module-level doc comment for the per-platform behaviour and
/// why Android uses a hardcoded path.
pub fn data_dir() -> Option<PathBuf> {
#[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"));
}
}
+1 -1
View File
@@ -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<PathBuf> {
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.
+4 -4
View File
@@ -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<PathBuf> {
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<PathBuf> {
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` →
+2 -2
View File
@@ -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<PathBuf> {
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
+7 -7
View File
@@ -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<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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