Files
Ferrous-Solitaire/solitaire_data/src/lib.rs
T
funman300 9299176b2d refactor(android): forbid unsafe workspace-wide, quarantine JNI in app (#91)
Addresses #91: the #90 posture (`deny(unsafe_code)` + three scattered
`#![allow(unsafe_code)]` across solitaire_data and solitaire_engine) punched
unsafe holes into otherwise-pure logic crates. Replace it by *reducing* the
unsafe rather than relocating it, then forbidding it everywhere it can be
forbidden.

Changes:
- solitaire_data: new safe `android_jni` bridge owning the cached `JavaVM`
  and activity `GlobalRef`; exposes `with_env` / `with_activity_env` so
  keystore/clipboard/safe-area never touch a raw handle.
- keystore: drop the `JavaVM::from_raw` init path (now in the app) and
  replace the three `unsafe { JByteArray::from_raw(x.into_raw()) }` casts
  with the safe `JByteArray::from(JObject)` conversion jni 0.21 provides.
- engine clipboard + safe_area: route through the bridge; remove their
  `#![allow(unsafe_code)]` and all `from_raw` calls.
- solitaire_app: becomes the single owner of FFI unsafe. `android_main`
  reconstructs the raw `JavaVM` / activity once (it must, as the cdylib that
  exports `#[unsafe(no_mangle)]`) and registers the safe wrappers. It opts to
  its own `deny`-level lints with two scoped `#[allow(unsafe_code)]`.
- workspace: `unsafe_code` is now `forbid`. Every crate except the app entry
  point is fully unsafe-free.

Net: 7 unsafe sites across three crates collapse to 3 at the OS boundary in
one crate. Verified with host `clippy --workspace -- -D warnings` and an
`aarch64-linux-android` clippy build of solitaire_app (transitively engine +
data); also fixed two latent android-only `collapsible_if` warnings surfaced
in the keystore by the cross-target check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:32:26 -07:00

186 lines
7.2 KiB
Rust

use async_trait::async_trait;
use solitaire_sync::{ChallengeGoal, LeaderboardEntry, SyncPayload, SyncResponse};
use thiserror::Error;
/// All errors that can arise during sync operations.
#[derive(Debug, Error)]
pub enum SyncError {
#[error("unsupported platform for this sync backend")]
UnsupportedPlatform,
#[error("network error: {0}")]
Network(String),
#[error("authentication error: {0}")]
Auth(String),
#[error("serialization error: {0}")]
Serialization(String),
}
/// Every sync backend implements this trait. The SyncPlugin only calls these
/// methods — it never matches on a backend enum variant.
#[async_trait]
pub trait SyncProvider: Send + Sync {
/// Fetch the remote sync payload. Returns the latest server state for merging.
async fn pull(&self) -> Result<SyncPayload, SyncError>;
/// Push the local payload to the backend. Returns the merged server response.
async fn push(&self, payload: &SyncPayload) -> Result<SyncResponse, SyncError>;
/// Human-readable name of this backend, used in settings UI and logs.
fn backend_name(&self) -> &'static str;
/// Returns true if the user is currently authenticated with this backend.
fn is_authenticated(&self) -> bool;
/// Fetch the global leaderboard from this backend. Returns an empty list
/// for backends that do not support leaderboards (e.g. `LocalOnlyProvider`).
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> {
Ok(vec![])
}
/// Fetch today's daily challenge from the server. Returns `None` for
/// backends that don't support it, or on any non-fatal network failure.
async fn fetch_daily_challenge(&self) -> Result<Option<ChallengeGoal>, SyncError> {
Ok(None)
}
/// Opt the authenticated player into the leaderboard with the given
/// display name. No-op for backends that don't support leaderboards.
async fn opt_in_leaderboard(&self, _display_name: &str) -> Result<(), SyncError> {
Ok(())
}
/// Remove the authenticated player from the leaderboard.
/// No-op for backends that don't support leaderboards.
async fn opt_out_leaderboard(&self) -> Result<(), SyncError> {
Ok(())
}
/// Permanently delete the authenticated player's account and all server
/// data. No-op for backends that don't support account management.
async fn delete_account(&self) -> Result<(), SyncError> {
Ok(())
}
/// Upload a winning replay to the backend. On success, returns the
/// shareable web URL the player can copy to their clipboard
/// (`<server>/replays/<id>`). Default returns `UnsupportedPlatform`
/// so backends without a server (e.g. `LocalOnlyProvider`) are
/// silently no-op'd by the engine's push-on-win system, matching
/// the same pattern `pull` / `push` follow.
async fn push_replay(&self, _replay: &Replay) -> Result<String, SyncError> {
Err(SyncError::UnsupportedPlatform)
}
}
/// Blanket impl so `Box<dyn SyncProvider + Send + Sync>` (returned by
/// `provider_for_backend`) can be passed directly to `SyncPlugin::new`.
#[async_trait]
impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
async fn pull(&self) -> Result<SyncPayload, SyncError> {
(**self).pull().await
}
async fn push(&self, payload: &SyncPayload) -> Result<SyncResponse, SyncError> {
(**self).push(payload).await
}
fn backend_name(&self) -> &'static str {
(**self).backend_name()
}
fn is_authenticated(&self) -> bool {
(**self).is_authenticated()
}
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> {
(**self).fetch_leaderboard().await
}
async fn fetch_daily_challenge(&self) -> Result<Option<ChallengeGoal>, SyncError> {
(**self).fetch_daily_challenge().await
}
async fn opt_in_leaderboard(&self, display_name: &str) -> Result<(), SyncError> {
(**self).opt_in_leaderboard(display_name).await
}
async fn opt_out_leaderboard(&self) -> Result<(), SyncError> {
(**self).opt_out_leaderboard().await
}
async fn delete_account(&self) -> Result<(), SyncError> {
(**self).delete_account().await
}
async fn push_replay(&self, replay: &Replay) -> Result<String, SyncError> {
(**self).push_replay(replay).await
}
}
pub mod stats;
pub use stats::{StatsExt, StatsSnapshot};
pub mod storage;
pub use storage::{
TimeAttackSession, cleanup_orphaned_tmp_files, delete_game_state_at,
delete_time_attack_session_at, game_state_file_path, load_game_state_from, load_stats,
load_stats_from, load_time_attack_session_from, load_time_attack_session_from_at,
save_game_state_to, save_stats, save_stats_to, save_time_attack_session_to, stats_file_path,
time_attack_session_path, time_attack_session_with_now,
};
pub mod achievements;
pub use achievements::{
AchievementRecord, achievements_file_path, load_achievements_from, save_achievements_to,
};
pub mod progress;
pub use progress::{
PlayerProgress, XpBreakdown, daily_seed_for, level_for_xp, load_progress_from,
progress_file_path, save_progress_to, xp_breakdown, xp_for_win,
};
pub mod weekly;
pub use weekly::{
WEEKLY_GOAL_XP, WEEKLY_GOALS, WeeklyGoalContext, WeeklyGoalDef, WeeklyGoalKind,
current_iso_week_key, weekly_goal_by_id,
};
pub mod challenge;
pub use challenge::{CHALLENGE_SEEDS, challenge_count, challenge_seed_for};
pub mod difficulty_seeds;
pub use difficulty_seeds::{DifficultySeeds, seeds_for};
pub mod settings;
pub use settings::{
AnimSpeed, REPLAY_MOVE_INTERVAL_MAX_SECS, REPLAY_MOVE_INTERVAL_MIN_SECS,
REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, Settings, SyncBackend,
TIME_BONUS_MULTIPLIER_MAX, TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_STEP,
TOOLTIP_DELAY_MAX_SECS, TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS, Theme, WindowGeometry,
load_settings_from, save_settings_to, settings_file_path,
};
#[cfg(target_os = "android")]
pub mod android_jni;
#[cfg(target_os = "android")]
mod android_keystore;
#[cfg(not(target_arch = "wasm32"))]
pub mod auth_tokens;
#[cfg(not(target_arch = "wasm32"))]
pub use auth_tokens::{
TokenError, delete_tokens, load_access_token, load_refresh_token, store_tokens,
};
pub mod sync_client;
pub use sync_client::LocalOnlyProvider;
#[cfg(not(target_arch = "wasm32"))]
pub use sync_client::{SolitaireServerClient, provider_for_backend};
pub mod replay;
pub use replay::{
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, Replay,
ReplayHistory, append_replay_to_history, load_replay_history_from,
migrate_legacy_latest_replay, replay_history_path, save_replay_history_to,
};
// `latest_replay_path` is still consumed by the engine's one-shot legacy
// migration; `load_latest_replay_from`/`save_latest_replay_to` had no callers
// outside `replay.rs` and were dropped from the public surface.
#[allow(deprecated)]
pub use replay::latest_replay_path;
#[cfg(not(target_arch = "wasm32"))]
pub mod matomo_client;
#[cfg(not(target_arch = "wasm32"))]
pub use matomo_client::MatomoClient;
pub mod platform;
pub use platform::data_dir;
/// Application data subdirectory name, shared by all persistence modules.
pub(crate) const APP_DIR_NAME: &str = "ferrous_solitaire";