feat(android): JNI clipboard bridge for Stats share-link button
Replaces the informational "Share link: {url}" toast on Android with a
real clipboard write via ClipboardManager JNI. Falls back to the old
toast on JNI error so the user can still copy the URL manually.
Adds `jni = "0.21"` (default-features = false) as a workspace dep;
`jni 0.21.1` was already in Cargo.lock as a transitive dep so no new
packages are fetched.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,7 @@ keyring = "4"
|
|||||||
keyring-core = "1"
|
keyring-core = "1"
|
||||||
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
|
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
|
||||||
arboard = { version = "3", default-features = false }
|
arboard = { version = "3", default-features = false }
|
||||||
|
jni = { version = "0.21", default-features = false }
|
||||||
|
|
||||||
solitaire_core = { path = "solitaire_core" }
|
solitaire_core = { path = "solitaire_core" }
|
||||||
solitaire_sync = { path = "solitaire_sync" }
|
solitaire_sync = { path = "solitaire_sync" }
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ zip = { workspace = true }
|
|||||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||||
arboard = { workspace = true }
|
arboard = { workspace = true }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
|
jni = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
|
|||||||
@@ -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}"))
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
//! Bevy integration layer for Solitaire Quest.
|
//! Bevy integration layer for Solitaire Quest.
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub mod android_clipboard;
|
||||||
pub mod assets;
|
pub mod assets;
|
||||||
pub mod card_animation;
|
pub mod card_animation;
|
||||||
pub mod achievement_plugin;
|
pub mod achievement_plugin;
|
||||||
@@ -12,6 +14,7 @@ pub mod feedback_anim_plugin;
|
|||||||
pub mod challenge_plugin;
|
pub mod challenge_plugin;
|
||||||
pub mod cursor_plugin;
|
pub mod cursor_plugin;
|
||||||
pub mod daily_challenge_plugin;
|
pub mod daily_challenge_plugin;
|
||||||
|
pub mod difficulty_plugin;
|
||||||
pub mod diagnostics_hud;
|
pub mod diagnostics_hud;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod game_plugin;
|
pub mod game_plugin;
|
||||||
@@ -93,11 +96,13 @@ pub use events::{
|
|||||||
ForfeitEvent, ForfeitRequestEvent, FoundationCompletedEvent, GameWonEvent, HelpRequestEvent,
|
ForfeitEvent, ForfeitRequestEvent, FoundationCompletedEvent, GameWonEvent, HelpRequestEvent,
|
||||||
HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||||
NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
|
NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
|
||||||
StartDailyChallengeRequestEvent, StartPlayBySeedRequestEvent, StartTimeAttackRequestEvent,
|
StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent,
|
||||||
StartZenRequestEvent, StateChangedEvent, SyncCompleteEvent, ToggleAchievementsRequestEvent,
|
StartTimeAttackRequestEvent, StartZenRequestEvent, StateChangedEvent, SyncCompleteEvent,
|
||||||
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
|
ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent,
|
||||||
ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent, XpAwardedEvent,
|
ToggleSettingsRequestEvent, ToggleStatsRequestEvent, UndoRequestEvent,
|
||||||
|
WinStreakMilestoneEvent, XpAwardedEvent,
|
||||||
};
|
};
|
||||||
|
pub use difficulty_plugin::{DifficultyIndexResource, DifficultyPlugin};
|
||||||
pub use play_by_seed_plugin::{PlayBySeedPlugin, PlayBySeedScreen};
|
pub use play_by_seed_plugin::{PlayBySeedPlugin, PlayBySeedScreen};
|
||||||
pub use game_plugin::{
|
pub use game_plugin::{
|
||||||
ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath, RecordingReplay,
|
ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath, RecordingReplay,
|
||||||
|
|||||||
@@ -361,9 +361,13 @@ fn handle_copy_share_link_button(
|
|||||||
}
|
}
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
{
|
{
|
||||||
toast.write(InfoToastEvent(format!(
|
match crate::android_clipboard::set_text(&url) {
|
||||||
"Share link: {url}"
|
Ok(()) => toast.write(InfoToastEvent(format!("Copied: {url}"))),
|
||||||
)));
|
Err(e) => {
|
||||||
|
warn!("android clipboard failed: {e}");
|
||||||
|
toast.write(InfoToastEvent(format!("Share link: {url}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user