423 lines
13 KiB
Rust
423 lines
13 KiB
Rust
// Copyright 2023 The Grim Developers
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
use lazy_static::lazy_static;
|
|
use parking_lot::RwLock;
|
|
use std::env;
|
|
use std::ffi::OsString;
|
|
use std::fs::File;
|
|
use std::io::Write;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
|
|
use jni::JNIEnv;
|
|
use jni::objects::{JByteArray, JObject, JString, JValue};
|
|
use winit::platform::android::activity::AndroidApp;
|
|
|
|
use crate::gui::platform::PlatformCallbacks;
|
|
|
|
/// How long a revealed secret (e.g. an nsec) may sit in the clipboard before it
|
|
/// is auto-cleared, if it is still there. Long enough to paste into another app,
|
|
/// short enough that it does not linger.
|
|
const CLIPBOARD_SECRET_TTL_SECS: u64 = 45;
|
|
|
|
/// Android platform implementation.
|
|
#[derive(Clone)]
|
|
pub struct Android {
|
|
/// Android related state.
|
|
android_app: AndroidApp,
|
|
|
|
/// Context to repaint content and handle viewport commands.
|
|
ctx: Arc<RwLock<Option<egui::Context>>>,
|
|
}
|
|
|
|
impl Android {
|
|
/// Create new Android platform instance from provided [`AndroidApp`].
|
|
pub fn new(app: AndroidApp) -> Self {
|
|
// Keep a process-wide handle so non-GUI threads (the nostr service)
|
|
// can reach Java too (see `notify_payment_received`).
|
|
{
|
|
let mut w_app = ANDROID_APP.write();
|
|
*w_app = Some(app.clone());
|
|
}
|
|
Self {
|
|
android_app: app,
|
|
ctx: Arc::new(RwLock::new(None)),
|
|
}
|
|
}
|
|
|
|
/// Call Android Activity method with JNI.
|
|
pub fn call_java_method(&self, name: &str, s: &str, a: &[JValue]) -> Option<jni::sys::jvalue> {
|
|
let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap();
|
|
let mut env = vm.attach_current_thread().unwrap();
|
|
let activity =
|
|
unsafe { JObject::from_raw(self.android_app.activity_as_ptr() as jni::sys::jobject) };
|
|
if let Ok(result) = env.call_method(activity, name, s, a) {
|
|
return Some(result.as_jni().clone());
|
|
}
|
|
None
|
|
}
|
|
}
|
|
|
|
impl PlatformCallbacks for Android {
|
|
fn set_context(&mut self, ctx: &egui::Context) {
|
|
let mut w_ctx = self.ctx.write();
|
|
*w_ctx = Some(ctx.clone());
|
|
}
|
|
|
|
fn exit(&self) {
|
|
let _ = self.call_java_method("exit", "()V", &[]);
|
|
}
|
|
|
|
fn copy_string_to_buffer(&self, data: String) {
|
|
let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap();
|
|
let env = vm.attach_current_thread().unwrap();
|
|
let arg_value = env.new_string(data).unwrap();
|
|
let _ = self.call_java_method(
|
|
"copyText",
|
|
"(Ljava/lang/String;)V",
|
|
&[JValue::Object(&JObject::from(arg_value))],
|
|
);
|
|
}
|
|
|
|
fn copy_secret_to_buffer(&self, data: String) {
|
|
// Copy now, then clear after a delay if the clipboard still holds exactly
|
|
// this secret (compare-then-clear), so a revealed nsec does not linger.
|
|
// Runs on a detached thread that reaches Java via the cloned app handle,
|
|
// the same non-GUI JNI path as the payment notifications.
|
|
self.copy_string_to_buffer(data.clone());
|
|
let platform = self.clone();
|
|
std::thread::spawn(move || {
|
|
std::thread::sleep(std::time::Duration::from_secs(CLIPBOARD_SECRET_TTL_SECS));
|
|
if platform.get_string_from_buffer() == data {
|
|
platform.copy_string_to_buffer(String::new());
|
|
}
|
|
});
|
|
}
|
|
|
|
fn get_string_from_buffer(&self) -> String {
|
|
let result = self
|
|
.call_java_method("pasteText", "()Ljava/lang/String;", &[])
|
|
.unwrap();
|
|
let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap();
|
|
let mut env = vm.attach_current_thread().unwrap();
|
|
let j_object: jni::sys::jobject = unsafe { result.l };
|
|
let paste_data: String = unsafe {
|
|
env.get_string(JString::from(JObject::from_raw(j_object)).as_ref())
|
|
.unwrap()
|
|
.into()
|
|
};
|
|
paste_data
|
|
}
|
|
|
|
fn start_camera(&self) {
|
|
// Clear image.
|
|
let mut w_image = LAST_CAMERA_IMAGE.write();
|
|
*w_image = None;
|
|
// Start camera.
|
|
let _ = self.call_java_method("startCamera", "()V", &[]);
|
|
}
|
|
|
|
fn stop_camera(&self) {
|
|
// Stop camera.
|
|
let _ = self.call_java_method("stopCamera", "()V", &[]);
|
|
// Clear image.
|
|
let mut w_image = LAST_CAMERA_IMAGE.write();
|
|
*w_image = None;
|
|
}
|
|
|
|
fn camera_image(&self) -> Option<(Vec<u8>, u32)> {
|
|
let r_image = LAST_CAMERA_IMAGE.read();
|
|
if r_image.is_some() {
|
|
return Some(r_image.clone().unwrap());
|
|
}
|
|
None
|
|
}
|
|
|
|
fn can_switch_camera(&self) -> bool {
|
|
if let Some(res) = self.call_java_method("camerasAmount", "()I", &[]) {
|
|
let amount = unsafe { res.i };
|
|
return amount > 1;
|
|
}
|
|
false
|
|
}
|
|
|
|
fn switch_camera(&self) {
|
|
let _ = self.call_java_method("switchCamera", "()V", &[]);
|
|
}
|
|
|
|
fn share_data(&self, name: String, data: Vec<u8>) -> Result<(), std::io::Error> {
|
|
let default_cache = OsString::from(dirs::cache_dir().unwrap());
|
|
let mut file = PathBuf::from(env::var_os("XDG_CACHE_HOME").unwrap_or(default_cache));
|
|
// File path for Android provider.
|
|
file.push("share");
|
|
if !file.exists() {
|
|
std::fs::create_dir(file.clone())?;
|
|
}
|
|
file.push(name);
|
|
if file.exists() {
|
|
std::fs::remove_file(file.clone())?;
|
|
}
|
|
let mut image = File::create_new(file.clone())?;
|
|
image.write_all(data.as_slice())?;
|
|
image.sync_all()?;
|
|
// Call share modal at system.
|
|
let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap();
|
|
let env = vm.attach_current_thread().unwrap();
|
|
let arg_value = env.new_string(file.to_str().unwrap()).unwrap();
|
|
let _ = self.call_java_method(
|
|
"shareData",
|
|
"(Ljava/lang/String;)V",
|
|
&[JValue::Object(&JObject::from(arg_value))],
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn save_file(&self, name: String, data: Vec<u8>) -> Result<(), std::io::Error> {
|
|
// Stage the bytes in the share cache (same dir the FileProvider exposes),
|
|
// then let Java copy them to the user-chosen Storage Access Framework
|
|
// document. Mirrors `share_data`, but the Java side uses CREATE_DOCUMENT.
|
|
let default_cache = OsString::from(dirs::cache_dir().unwrap());
|
|
let mut file = PathBuf::from(env::var_os("XDG_CACHE_HOME").unwrap_or(default_cache));
|
|
file.push("share");
|
|
if !file.exists() {
|
|
std::fs::create_dir(file.clone())?;
|
|
}
|
|
file.push(&name);
|
|
if file.exists() {
|
|
std::fs::remove_file(file.clone())?;
|
|
}
|
|
let mut f = File::create_new(file.clone())?;
|
|
f.write_all(data.as_slice())?;
|
|
f.sync_all()?;
|
|
let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap();
|
|
let env = vm.attach_current_thread().unwrap();
|
|
let path_arg = env.new_string(file.to_str().unwrap()).unwrap();
|
|
let name_arg = env.new_string(&name).unwrap();
|
|
let _ = self.call_java_method(
|
|
"saveFile",
|
|
"(Ljava/lang/String;Ljava/lang/String;)V",
|
|
&[
|
|
JValue::Object(&JObject::from(path_arg)),
|
|
JValue::Object(&JObject::from(name_arg)),
|
|
],
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn share_text(&self, text: String) {
|
|
let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap();
|
|
let env = vm.attach_current_thread().unwrap();
|
|
let Ok(arg_value) = env.new_string(text) else {
|
|
return;
|
|
};
|
|
let _ = self.call_java_method(
|
|
"shareText",
|
|
"(Ljava/lang/String;)V",
|
|
&[JValue::Object(&JObject::from(arg_value))],
|
|
);
|
|
}
|
|
|
|
fn pick_file(&self) -> Option<String> {
|
|
// Clear previous result.
|
|
let mut w_path = PICKED_FILE_PATH.write();
|
|
*w_path = None;
|
|
// Launch file picker.
|
|
let _ = self.call_java_method("pickFile", "()V", &[]);
|
|
// Return empty string to identify async pick.
|
|
Some("".to_string())
|
|
}
|
|
|
|
fn pick_folder(&self) -> Option<String> {
|
|
// Clear previous result.
|
|
let mut w_path = PICKED_FILE_PATH.write();
|
|
*w_path = None;
|
|
// Launch file picker.
|
|
let _ = self.call_java_method("pickFolder", "()V", &[]);
|
|
// Return empty string to identify async pick.
|
|
Some("".to_string())
|
|
}
|
|
|
|
fn picked_file(&self) -> Option<String> {
|
|
let has_file = {
|
|
let r_path = PICKED_FILE_PATH.read();
|
|
r_path.is_some()
|
|
};
|
|
if has_file {
|
|
let mut w_path = PICKED_FILE_PATH.write();
|
|
let path = Some(w_path.clone().unwrap());
|
|
*w_path = None;
|
|
return path;
|
|
}
|
|
None
|
|
}
|
|
|
|
fn request_user_attention(&self) {}
|
|
|
|
fn user_attention_required(&self) -> bool {
|
|
false
|
|
}
|
|
|
|
fn clear_user_attention(&self) {}
|
|
|
|
fn set_status_bar_white_icons(&self, white: bool) {
|
|
self.call_java_method(
|
|
"setStatusBarWhiteIcons",
|
|
"(Z)V",
|
|
&[JValue::Bool(white as u8)],
|
|
);
|
|
}
|
|
|
|
fn vibrate_error(&self) {
|
|
let _ = self.call_java_method("vibrateError", "()V", &[]);
|
|
}
|
|
|
|
fn vibrate_copy(&self) {
|
|
let _ = self.call_java_method("vibrateCopy", "()V", &[]);
|
|
}
|
|
|
|
fn return_to_caller(&self) {
|
|
// Background our task (Activity.moveTaskToBack(true), wrapped Java-side)
|
|
// so the OS brings the app that deep-linked into us back to the front.
|
|
let _ = self.call_java_method("returnToCaller", "()V", &[]);
|
|
}
|
|
}
|
|
|
|
lazy_static! {
|
|
/// Last image data from camera.
|
|
static ref LAST_CAMERA_IMAGE: Arc<RwLock<Option<(Vec<u8>, u32)>>> = Arc::new(RwLock::new(None));
|
|
/// Picked file path.
|
|
static ref PICKED_FILE_PATH: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
|
|
/// App handle for JNI calls from threads without a platform reference.
|
|
static ref ANDROID_APP: Arc<RwLock<Option<AndroidApp>>> = Arc::new(RwLock::new(None));
|
|
}
|
|
|
|
/// Show the one-shot "payment received" system notification (Java side
|
|
/// `BackgroundService.notifyPaymentReceived`, id=2, separate from the
|
|
/// persistent sync notification id=1). Called by the nostr service on
|
|
/// slatepack receipt from a non-GUI thread, hence the stored [`AndroidApp`]
|
|
/// handle instead of a platform reference. Fail-open: a missing handle or
|
|
/// JNI error just skips the notification, never the payment.
|
|
pub fn notify_payment_received(name: &str, amount: &str) {
|
|
let app = {
|
|
let r_app = ANDROID_APP.read();
|
|
r_app.clone()
|
|
};
|
|
let Some(app) = app else {
|
|
return;
|
|
};
|
|
let platform = Android {
|
|
android_app: app,
|
|
ctx: Arc::new(RwLock::new(None)),
|
|
};
|
|
let Ok(vm) = (unsafe { jni::JavaVM::from_raw(platform.android_app.vm_as_ptr() as _) }) else {
|
|
return;
|
|
};
|
|
let Ok(env) = vm.attach_current_thread() else {
|
|
return;
|
|
};
|
|
let Ok(j_name) = env.new_string(name) else {
|
|
return;
|
|
};
|
|
let Ok(j_amount) = env.new_string(amount) else {
|
|
return;
|
|
};
|
|
let _ = platform.call_java_method(
|
|
"notifyPaymentReceived",
|
|
"(Ljava/lang/String;Ljava/lang/String;)V",
|
|
&[
|
|
JValue::Object(&JObject::from(j_name)),
|
|
JValue::Object(&JObject::from(j_amount)),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Show the one-shot "payment requested" system notification (Java side
|
|
/// `BackgroundService.notifyPaymentRequested`, id=3, separate from both the
|
|
/// persistent sync notification id=1 and the received-payment one id=2). Called
|
|
/// by the nostr service when a payment request (Invoice1) is ingested from a
|
|
/// non-GUI thread, hence the stored [`AndroidApp`] handle instead of a platform
|
|
/// reference. Fail-open: a missing handle or JNI error just skips the
|
|
/// notification, never the request. Mirrors [`notify_payment_received`].
|
|
pub fn notify_payment_requested(name: &str, amount: &str) {
|
|
let app = {
|
|
let r_app = ANDROID_APP.read();
|
|
r_app.clone()
|
|
};
|
|
let Some(app) = app else {
|
|
return;
|
|
};
|
|
let platform = Android {
|
|
android_app: app,
|
|
ctx: Arc::new(RwLock::new(None)),
|
|
};
|
|
let Ok(vm) = (unsafe { jni::JavaVM::from_raw(platform.android_app.vm_as_ptr() as _) }) else {
|
|
return;
|
|
};
|
|
let Ok(env) = vm.attach_current_thread() else {
|
|
return;
|
|
};
|
|
let Ok(j_name) = env.new_string(name) else {
|
|
return;
|
|
};
|
|
let Ok(j_amount) = env.new_string(amount) else {
|
|
return;
|
|
};
|
|
let _ = platform.call_java_method(
|
|
"notifyPaymentRequested",
|
|
"(Ljava/lang/String;Ljava/lang/String;)V",
|
|
&[
|
|
JValue::Object(&JObject::from(j_name)),
|
|
JValue::Object(&JObject::from(j_amount)),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Callback from Java code with last entered character from soft keyboard.
|
|
#[allow(non_snake_case)]
|
|
#[unsafe(no_mangle)]
|
|
pub extern "C" fn Java_mw_gri_android_MainActivity_onCameraImage(
|
|
env: JNIEnv,
|
|
_class: JObject,
|
|
buff: jni::sys::jbyteArray,
|
|
rotation: jni::sys::jint,
|
|
) {
|
|
let arr = unsafe { JByteArray::from_raw(buff) };
|
|
let image: Vec<u8> = env.convert_byte_array(arr).unwrap();
|
|
let mut w_image = LAST_CAMERA_IMAGE.write();
|
|
*w_image = Some((image, rotation as u32));
|
|
}
|
|
|
|
/// Callback from Java code with picked file path.
|
|
#[allow(non_snake_case)]
|
|
#[unsafe(no_mangle)]
|
|
pub extern "C" fn Java_mw_gri_android_MainActivity_onFilePick(
|
|
_env: JNIEnv,
|
|
_class: JObject,
|
|
char: jni::sys::jstring,
|
|
) {
|
|
use std::ops::Add;
|
|
unsafe {
|
|
let j_obj = JString::from_raw(char);
|
|
let j_str = _env.get_string_unchecked(j_obj.as_ref()).unwrap();
|
|
match j_str.to_str() {
|
|
Ok(str) => {
|
|
let mut w_path = PICKED_FILE_PATH.write();
|
|
*w_path = Some(w_path.clone().unwrap_or("".to_string()).add(str));
|
|
}
|
|
Err(_) => {}
|
|
}
|
|
}
|
|
}
|