diff --git a/Cargo.toml b/Cargo.toml index 99b3179..ea2061c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ keyring = "4" keyring-core = "1" reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false } arboard = { version = "3", default-features = false } +jni = { version = "0.21", default-features = false } solitaire_core = { path = "solitaire_core" } solitaire_sync = { path = "solitaire_sync" } diff --git a/solitaire_engine/Cargo.toml b/solitaire_engine/Cargo.toml index 37fcc09..ebeaaf0 100644 --- a/solitaire_engine/Cargo.toml +++ b/solitaire_engine/Cargo.toml @@ -32,6 +32,9 @@ zip = { workspace = true } [target.'cfg(not(target_os = "android"))'.dependencies] arboard = { workspace = true } +[target.'cfg(target_os = "android")'.dependencies] +jni = { workspace = true } + [dev-dependencies] async-trait = { workspace = true } tempfile = { workspace = true } diff --git a/solitaire_engine/src/android_clipboard.rs b/solitaire_engine/src/android_clipboard.rs new file mode 100644 index 0000000..99cbcd5 --- /dev/null +++ b/solitaire_engine/src/android_clipboard.rs @@ -0,0 +1,65 @@ +/// Android clipboard bridge via JNI. +/// +/// Writes text to the system clipboard by calling into `ClipboardManager` +/// through the JNI. Only compiled and linked on `target_os = "android"`. +#[cfg(target_os = "android")] +pub fn set_text(text: &str) -> Result<(), String> { + use bevy::android::ANDROID_APP; + use jni::{ + objects::{JObject, JValue, JValueOwned}, + JavaVM, + }; + + let app = ANDROID_APP + .get() + .ok_or_else(|| "ANDROID_APP not initialized".to_string())?; + + // SAFETY: vm_as_ptr() returns the raw JavaVM* set up by the Android runtime. + let vm = unsafe { JavaVM::from_raw(app.vm_as_ptr().cast()) } + .map_err(|e| format!("JavaVM::from_raw: {e}"))?; + + let mut env = vm + .attach_current_thread_permanently() + .map_err(|e| format!("attach_current_thread: {e}"))?; + + // SAFETY: activity_as_ptr() is the NativeActivity jobject pointer — + // valid for the lifetime of the process. + let activity = unsafe { JObject::from_raw(app.activity_as_ptr() as _) }; + + (|| -> jni::errors::Result<()> { + // ClipboardManager cm = activity.getSystemService("clipboard") + let svc_name = JValueOwned::from(env.new_string("clipboard")?); + let cm = env + .call_method( + &activity, + "getSystemService", + "(Ljava/lang/String;)Ljava/lang/Object;", + &[svc_name.borrow()], + )? + .l()?; + + // ClipData clip = ClipData.newPlainText("link", text) + let label = JValueOwned::from(env.new_string("link")?); + let java_text = JValueOwned::from(env.new_string(text)?); + let clip_class = env.find_class("android/content/ClipData")?; + let clip = env + .call_static_method( + &clip_class, + "newPlainText", + "(Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Landroid/content/ClipData;", + &[label.borrow(), java_text.borrow()], + )? + .l()?; + + // cm.setPrimaryClip(clip) + let clip_val = JValueOwned::Object(clip); + env.call_method( + &cm, + "setPrimaryClip", + "(Landroid/content/ClipData;)V", + &[clip_val.borrow()], + )? + .v() + })() + .map_err(|e| format!("clipboard JNI: {e}")) +} diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index 8398ba9..30b82ea 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -1,5 +1,7 @@ //! Bevy integration layer for Solitaire Quest. +#[cfg(target_os = "android")] +pub mod android_clipboard; pub mod assets; pub mod card_animation; pub mod achievement_plugin; @@ -12,6 +14,7 @@ pub mod feedback_anim_plugin; pub mod challenge_plugin; pub mod cursor_plugin; pub mod daily_challenge_plugin; +pub mod difficulty_plugin; pub mod diagnostics_hud; pub mod events; pub mod game_plugin; @@ -93,11 +96,13 @@ pub use events::{ ForfeitEvent, ForfeitRequestEvent, FoundationCompletedEvent, GameWonEvent, HelpRequestEvent, HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent, - StartDailyChallengeRequestEvent, StartPlayBySeedRequestEvent, StartTimeAttackRequestEvent, - StartZenRequestEvent, StateChangedEvent, SyncCompleteEvent, ToggleAchievementsRequestEvent, - ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent, - ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent, XpAwardedEvent, + StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent, + StartTimeAttackRequestEvent, StartZenRequestEvent, StateChangedEvent, SyncCompleteEvent, + ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, + ToggleSettingsRequestEvent, ToggleStatsRequestEvent, UndoRequestEvent, + WinStreakMilestoneEvent, XpAwardedEvent, }; +pub use difficulty_plugin::{DifficultyIndexResource, DifficultyPlugin}; pub use play_by_seed_plugin::{PlayBySeedPlugin, PlayBySeedScreen}; pub use game_plugin::{ ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath, RecordingReplay, diff --git a/solitaire_engine/src/stats_plugin.rs b/solitaire_engine/src/stats_plugin.rs index 42965d2..fb3f479 100644 --- a/solitaire_engine/src/stats_plugin.rs +++ b/solitaire_engine/src/stats_plugin.rs @@ -361,9 +361,13 @@ fn handle_copy_share_link_button( } #[cfg(target_os = "android")] { - toast.write(InfoToastEvent(format!( - "Share link: {url}" - ))); + match crate::android_clipboard::set_text(&url) { + Ok(()) => toast.write(InfoToastEvent(format!("Copied: {url}"))), + Err(e) => { + warn!("android clipboard failed: {e}"); + toast.write(InfoToastEvent(format!("Share link: {url}"))); + } + } } }