diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 04e6d7b..39aadbd 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,15 +1,17 @@ - - + - + + + @@ -44,6 +45,22 @@ + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/mw/gri/android/BackgroundService.java b/android/app/src/main/java/mw/gri/android/BackgroundService.java index f34b700..bb668a5 100644 --- a/android/app/src/main/java/mw/gri/android/BackgroundService.java +++ b/android/app/src/main/java/mw/gri/android/BackgroundService.java @@ -152,13 +152,17 @@ public class BackgroundService extends Service { // Show notification with sync status. Intent i = getPackageManager().getLaunchIntentForPackage(this.getPackageName()); PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, i, PendingIntent.FLAG_IMMUTABLE); - mNotificationBuilder = new NotificationCompat.Builder(this, TAG) - .setContentTitle(this.getSyncTitle()) - .setContentText(this.getSyncStatusText()) - .setStyle(new NotificationCompat.BigTextStyle().bigText(this.getSyncStatusText())) - .setSmallIcon(R.drawable.ic_stat_name) - .setPriority(NotificationCompat.PRIORITY_MAX) - .setContentIntent(pendingIntent); + try { + mNotificationBuilder = new NotificationCompat.Builder(this, TAG) + .setContentTitle(this.getSyncTitle()) + .setContentText(this.getSyncStatusText()) + .setStyle(new NotificationCompat.BigTextStyle().bigText(this.getSyncStatusText())) + .setSmallIcon(R.drawable.ic_stat_name) + .setPriority(NotificationCompat.PRIORITY_MAX) + .setContentIntent(pendingIntent); + } catch (UnsatisfiedLinkError e) { + return; + } Notification notification = mNotificationBuilder.build(); // Start service at foreground state to prevent killing by system. diff --git a/android/app/src/main/java/mw/gri/android/MainActivity.java b/android/app/src/main/java/mw/gri/android/MainActivity.java index 409d23c..a589934 100644 --- a/android/app/src/main/java/mw/gri/android/MainActivity.java +++ b/android/app/src/main/java/mw/gri/android/MainActivity.java @@ -7,9 +7,9 @@ import android.content.*; import android.content.pm.PackageManager; import android.content.res.Configuration; import android.net.Uri; -import android.os.Build; -import android.os.Bundle; +import android.os.*; import android.os.Process; +import android.provider.Settings; import android.system.ErrnoException; import android.system.Os; import android.util.Size; @@ -51,8 +51,7 @@ public class MainActivity extends GameActivity { @Override public void onReceive(Context ctx, Intent i) { if (i.getAction().equals(STOP_APP_ACTION)) { - onExit(); - Process.killProcess(Process.myPid()); + exit(); } } }; @@ -67,11 +66,19 @@ public class MainActivity extends GameActivity { private ExecutorService mCameraExecutor = null; private boolean mUseBackCamera = true; - private ActivityResultLauncher mFilePickResultLauncher = null; + private ActivityResultLauncher mFilePickResult = null; + private ActivityResultLauncher mOpenFilePermissionsResult = null; @SuppressLint("UnspecifiedRegisterReceiverFlag") @Override protected void onCreate(Bundle savedInstanceState) { + // Check if activity was launched to exclude from recent apps on exit. + if ((getIntent().getFlags() & Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) != 0) { + super.onCreate(null); + finish(); + return; + } + // Clear cache on start. if (savedInstanceState == null) { Utils.deleteDirectoryContent(new File(getExternalCacheDir().getPath()), false); @@ -91,8 +98,21 @@ public class MainActivity extends GameActivity { // Register receiver to finish activity from the BackgroundService. registerReceiver(mBroadcastReceiver, new IntentFilter(STOP_APP_ACTION)); - // Register file pick result launcher. - mFilePickResultLauncher = registerForActivityResult( + // Register associated file opening result. + mOpenFilePermissionsResult = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + if (Build.VERSION.SDK_INT >= 30) { + if (Environment.isExternalStorageManager()) { + onFile(); + } + } else if (result.getResultCode() == RESULT_OK) { + onFile(); + } + } + ); + // Register file pick result. + mFilePickResult = registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), result -> { int resultCode = result.getResultCode(); @@ -105,11 +125,11 @@ public class MainActivity extends GameActivity { File file = new File(getExternalCacheDir(), name); try (InputStream is = getContentResolver().openInputStream(uri); OutputStream os = new FileOutputStream(file)) { - byte[] buffer = new byte[1024]; - int length; - while ((length = is.read(buffer)) > 0) { - os.write(buffer, 0, length); - } + byte[] buffer = new byte[1024]; + int length; + while ((length = is.read(buffer)) > 0) { + os.write(buffer, 0, length); + } } catch (Exception e) { e.printStackTrace(); } @@ -124,7 +144,7 @@ public class MainActivity extends GameActivity { // Listener for display insets (cutouts) to pass values into native code. View content = getWindow().getDecorView().findViewById(android.R.id.content); ViewCompat.setOnApplyWindowInsetsListener(content, (v, insets) -> { - // Setup cutouts values. + // Get display cutouts. DisplayCutoutCompat dc = insets.getDisplayCutout(); int cutoutTop = 0; int cutoutRight = 0; @@ -140,7 +160,7 @@ public class MainActivity extends GameActivity { // Get display insets. Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); - // Setup values to pass into native code. + // Pass values into native code. int[] values = new int[]{0, 0, 0, 0}; values[0] = Utils.pxToDp(Integer.max(cutoutTop, systemBars.top), this); values[1] = Utils.pxToDp(Integer.max(cutoutRight, systemBars.right), this); @@ -166,8 +186,61 @@ public class MainActivity extends GameActivity { BackgroundService.start(this); } }); + + // Check if intent has data on launch. + if (savedInstanceState == null) { + onNewIntent(getIntent()); + } } + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + String action = intent.getAction(); + // Check if file was open with the application. + if (action != null && action.equals(Intent.ACTION_VIEW)) { + Intent i = getIntent(); + i.setData(intent.getData()); + setIntent(i); + onFile(); + } + } + + // Callback when associated file was open. + private void onFile() { + Uri data = getIntent().getData(); + if (data == null) { + return; + } + if (Build.VERSION.SDK_INT >= 30) { + if (!Environment.isExternalStorageManager()) { + Intent i = new Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION); + mOpenFilePermissionsResult.launch(i); + return; + } + } + try { + ParcelFileDescriptor parcelFile = getContentResolver().openFileDescriptor(data, "r"); + FileReader fileReader = new FileReader(parcelFile.getFileDescriptor()); + BufferedReader reader = new BufferedReader(fileReader); + String line; + StringBuilder buff = new StringBuilder(); + while ((line = reader.readLine()) != null) { + buff.append(line); + } + reader.close(); + fileReader.close(); + + // Provide file content into native code. + onData(buff.toString()); + } catch (Exception e) { + e.printStackTrace(); + } + } + + // Pass data into native code. + public native void onData(String data); + @Override public void onConfigurationChanged(Configuration newConfig) { super.onConfigurationChanged(newConfig); @@ -232,17 +305,17 @@ public class MainActivity extends GameActivity { // Implemented into native code to handle key code BACK event. public native void onBack(); - // Actions on app exit. - private void onExit() { - unregisterReceiver(mBroadcastReceiver); - BackgroundService.stop(this); + // Called from native code to exit app. + public void exit() { + finishAndRemoveTask(); } @Override protected void onDestroy() { - onExit(); + unregisterReceiver(mBroadcastReceiver); + BackgroundService.stop(this); - // Kill process after 3 seconds if app was terminated from recent apps to prevent app hanging. + // Kill process after 3 secs if app was terminated from recent apps to prevent app hang. new Thread(() -> { try { onTermination(); @@ -253,9 +326,7 @@ public class MainActivity extends GameActivity { } }).start(); - // Destroy an app and kill process. super.onDestroy(); - Process.killProcess(Process.myPid()); } // Notify native code to stop activity (e.g. node) if app was terminated from recent apps. @@ -298,18 +369,16 @@ public class MainActivity extends GameActivity { // Called from native code to start camera. public void startCamera() { - // Check permissions. String notificationsPermission = Manifest.permission.CAMERA; if (checkSelfPermission(notificationsPermission) != PackageManager.PERMISSION_GRANTED) { requestPermissions(new String[] { notificationsPermission }, CAMERA_PERMISSION_CODE); } else { - // Start . if (mCameraProviderFuture == null) { mCameraProviderFuture = ProcessCameraProvider.getInstance(this); mCameraProviderFuture.addListener(() -> { try { mCameraProvider = mCameraProviderFuture.get(); - // Launch camera. + // Start camera. openCamera(); } catch (Exception e) { View content = findViewById(android.R.id.content); @@ -402,7 +471,7 @@ public class MainActivity extends GameActivity { Intent intent = new Intent(Intent.ACTION_GET_CONTENT); intent.setType("*/*"); try { - mFilePickResultLauncher.launch(Intent.createChooser(intent, "Pick file")); + mFilePickResult.launch(Intent.createChooser(intent, "Pick file")); } catch (android.content.ActivityNotFoundException ex) { onFilePick(""); } diff --git a/src/gui/app.rs b/src/gui/app.rs index 4e1c07a..f95176a 100644 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -110,7 +110,7 @@ impl App { } // Provide incoming data to wallets. - if let Some(data) = crate::consume_passed_data() { + if let Some(data) = crate::consume_incoming_data() { if !data.is_empty() { self.content.wallets.on_data(ui, Some(data), &self.platform); } diff --git a/src/gui/platform/android/mod.rs b/src/gui/platform/android/mod.rs index fce8afc..b93739c 100644 --- a/src/gui/platform/android/mod.rs +++ b/src/gui/platform/android/mod.rs @@ -66,6 +66,10 @@ impl PlatformCallbacks for Android { *w_ctx = Some(ctx.clone()); } + fn exit(&self) { + self.call_java_method("exit", "()V", &[]).unwrap(); + } + fn show_keyboard(&self) { // Disable NDK soft input show call before fix for egui. // self.android_app.show_soft_input(false); @@ -137,20 +141,18 @@ impl PlatformCallbacks for Android { fn share_data(&self, name: String, data: Vec) -> Result<(), std::io::Error> { // Create file at cache dir. let default_cache = OsString::from(dirs::cache_dir().unwrap()); - let mut cache = PathBuf::from(env::var_os("XDG_CACHE_HOME").unwrap_or(default_cache)); - cache.push("images"); - std::fs::create_dir_all(cache.to_str().unwrap())?; - cache.push(name); - if cache.exists() { - std::fs::remove_file(cache.clone())?; + let mut file = PathBuf::from(env::var_os("XDG_CACHE_HOME").unwrap_or(default_cache)); + file.push(name); + if file.exists() { + std::fs::remove_file(file.clone())?; } - let mut image = File::create_new(cache.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(cache.to_str().unwrap()).unwrap(); + let arg_value = env.new_string(file.to_str().unwrap()).unwrap(); self.call_java_method("shareImage", "(Ljava/lang/String;)V", &[JValue::Object(&JObject::from(arg_value))]).unwrap(); diff --git a/src/gui/platform/desktop/mod.rs b/src/gui/platform/desktop/mod.rs index bc18707..85c0f0b 100644 --- a/src/gui/platform/desktop/mod.rs +++ b/src/gui/platform/desktop/mod.rs @@ -43,6 +43,14 @@ impl PlatformCallbacks for Desktop { *w_ctx = Some(ctx.clone()); } + fn exit(&self) { + let r_ctx = self.ctx.read(); + if r_ctx.is_some() { + let ctx = r_ctx.as_ref().unwrap(); + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } + fn show_keyboard(&self) {} fn hide_keyboard(&self) {} diff --git a/src/gui/platform/mod.rs b/src/gui/platform/mod.rs index 605924e..cd4ed15 100644 --- a/src/gui/platform/mod.rs +++ b/src/gui/platform/mod.rs @@ -23,6 +23,7 @@ pub mod platform; pub trait PlatformCallbacks { fn set_context(&mut self, ctx: &egui::Context); + fn exit(&self); fn show_keyboard(&self); fn hide_keyboard(&self); fn copy_string_to_buffer(&self, data: String); diff --git a/src/gui/views/content.rs b/src/gui/views/content.rs index 8780813..bc36690 100644 --- a/src/gui/views/content.rs +++ b/src/gui/views/content.rs @@ -40,8 +40,8 @@ pub struct Content { /// Central panel [`WalletsContent`] content. pub wallets: WalletsContent, - /// Check if app exit is allowed on close event of [`eframe::App`] implementation. - pub(crate) exit_allowed: bool, + /// Check if app exit is allowed on Desktop close event. + pub exit_allowed: bool, /// Flag to show exit progress at [`Modal`]. show_exit_progress: bool, @@ -83,7 +83,7 @@ impl ModalContainer for Content { modal: &Modal, cb: &dyn PlatformCallbacks) { match modal.id { - Self::EXIT_CONFIRMATION_MODAL => self.exit_modal_content(ui, modal), + Self::EXIT_CONFIRMATION_MODAL => self.exit_modal_content(ui, modal, cb), Self::SETTINGS_MODAL => self.settings_modal_ui(ui, modal), Self::ANDROID_INTEGRATED_NODE_WARNING_MODAL => self.android_warning_modal_ui(ui, modal), Self::CRASH_REPORT_MODAL => self.crash_report_modal_ui(ui, modal, cb), @@ -206,11 +206,11 @@ impl Content { } /// Draw exit confirmation modal content. - fn exit_modal_content(&mut self, ui: &mut egui::Ui, modal: &Modal) { + fn exit_modal_content(&mut self, ui: &mut egui::Ui, modal: &Modal, cb: &dyn PlatformCallbacks) { if self.show_exit_progress { if !Node::is_running() { self.exit_allowed = true; - ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); + cb.exit(); modal.close(); } ui.add_space(16.0); @@ -244,7 +244,7 @@ impl Content { View::button_ui(ui, t!("modal_exit.exit"), Colors::white_or_black(false), |ui| { if !Node::is_running() { self.exit_allowed = true; - ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); + cb.exit(); modal.close(); } else { Node::stop(true); diff --git a/src/gui/views/wallets/content.rs b/src/gui/views/wallets/content.rs index 45ca51d..df5495b 100644 --- a/src/gui/views/wallets/content.rs +++ b/src/gui/views/wallets/content.rs @@ -36,12 +36,10 @@ pub struct WalletsContent { /// Wallet selection [`Modal`] content. wallet_selection_content: Option, - /// Wallet opening [`Modal`] content. open_wallet_content: Option, - /// Wallet connection selection content. - conn_modal_content: Option, + conn_selection_content: Option, /// Selected [`Wallet`] content. wallet_content: WalletContent, @@ -70,7 +68,7 @@ impl Default for WalletsContent { wallets: WalletList::default(), wallet_selection_content: None, open_wallet_content: None, - conn_modal_content: None, + conn_selection_content: None, wallet_content: WalletContent::new(None), creation_content: WalletCreation::default(), show_wallets_at_dual_panel: AppConfig::show_wallets_at_dual_panel(), @@ -106,7 +104,7 @@ impl ModalContainer for WalletsContent { self.creation_content.name_pass_modal_ui(ui, modal, cb) }, CONNECTION_SELECTION_MODAL => { - if let Some(content) = self.conn_modal_content.as_mut() { + if let Some(content) = self.conn_selection_content.as_mut() { content.ui(ui, modal, cb, |id| { // Update wallet connection on select. let list = self.wallets.list(); @@ -303,6 +301,7 @@ impl WalletsContent { } } + /// Show wallet selection with provided optional data. fn show_wallet_selection_modal(&mut self, data: Option) { self.wallet_selection_content = Some(WalletsModal::new(None, data, true)); // Show wallet selection modal. @@ -557,7 +556,7 @@ impl WalletsContent { /// Show [`Modal`] to select connection for the wallet. fn show_connection_selector_modal(&mut self, wallet: &Wallet) { let ext_conn = wallet.get_current_ext_conn(); - self.conn_modal_content = Some(WalletConnectionModal::new(ext_conn)); + self.conn_selection_content = Some(WalletConnectionModal::new(ext_conn)); // Show modal. Modal::new(CONNECTION_SELECTION_MODAL) .position(ModalPosition::CenterTop) diff --git a/src/lib.rs b/src/lib.rs index aba2528..a6d8840 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -260,15 +260,15 @@ fn setup_i18n() { } } -/// Get data provided from deeplink or opened file. -pub fn consume_passed_data() -> Option { +/// Get data from deeplink or opened file. +pub fn consume_incoming_data() -> Option { let has_data = { - let r_data = PASSED_DATA.read(); + let r_data = INCOMING_DATA.read(); r_data.is_some() }; if has_data { // Clear data. - let mut w_data = PASSED_DATA.write(); + let mut w_data = INCOMING_DATA.write(); let data = w_data.clone(); *w_data = None; return data; @@ -278,11 +278,35 @@ pub fn consume_passed_data() -> Option { /// Provide data from deeplink or opened file. pub fn on_data(data: String) { - let mut w_data = PASSED_DATA.write(); + let mut w_data = INCOMING_DATA.write(); *w_data = Some(data); } lazy_static! { /// Data provided from deeplink or opened file. - pub static ref PASSED_DATA: Arc>> = Arc::new(RwLock::new(None)); + pub static ref INCOMING_DATA: Arc>> = Arc::new(RwLock::new(None)); +} + +/// Callback from Java code with with passed data. +#[allow(dead_code)] +#[allow(non_snake_case)] +#[cfg(target_os = "android")] +#[no_mangle] +pub extern "C" fn Java_mw_gri_android_MainActivity_onData( + _env: jni::JNIEnv, + _class: jni::objects::JObject, + char: jni::sys::jstring +) { + unsafe { + let j_obj = jni::objects::JString::from_raw(char); + if let Ok(j_str) = _env.get_string_unchecked(j_obj.as_ref()) { + match j_str.to_str() { + Ok(str) => { + let mut w_path = INCOMING_DATA.write(); + *w_path = Some(str.to_string()); + } + Err(_) => {} + } + }; + } } \ No newline at end of file diff --git a/src/settings/settings.rs b/src/settings/settings.rs index 5c5164c..c065f59 100644 --- a/src/settings/settings.rs +++ b/src/settings/settings.rs @@ -48,9 +48,10 @@ pub struct Settings { impl Settings { /// Main application directory name. pub const MAIN_DIR_NAME: &'static str = ".grim"; - /// Crash report file name. pub const CRASH_REPORT_FILE_NAME: &'static str = "crash.log"; + /// Application socket name. + pub const SOCKET_NAME: &'static str = "grim.sock"; /// Initialize settings with app and node configs. fn init() -> Self { @@ -142,10 +143,10 @@ impl Settings { } /// Get desktop application socket path. - pub fn socket_path() -> String { + pub fn socket_path() -> PathBuf { let mut socket_path = Self::base_path(None); - socket_path.push("grim.socket"); - socket_path.to_str().unwrap().to_string() + socket_path.push(Self::SOCKET_NAME); + socket_path } /// Get configuration file path from provided name and sub-directory if needed.