From d16624d423bf2008def0e677f64546d283e3e421 Mon Sep 17 00:00:00 2001 From: ardocrat Date: Thu, 6 Jun 2024 15:02:32 +0300 Subject: [PATCH] android: pick file --- .../java/mw/gri/android/MainActivity.java | 55 ++++++++++- .../src/main/java/mw/gri/android/Utils.java | 7 ++ src/gui/platform/android/mod.rs | 52 ++++++++++- src/gui/platform/desktop/mod.rs | 4 + src/gui/platform/mod.rs | 1 + src/gui/views/file.rs | 63 ++++++++----- src/gui/views/qr.rs | 91 ++++++++++--------- 7 files changed, 200 insertions(+), 73 deletions(-) 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 980e74f..409d23c 100644 --- a/android/app/src/main/java/mw/gri/android/MainActivity.java +++ b/android/app/src/main/java/mw/gri/android/MainActivity.java @@ -1,6 +1,8 @@ package mw.gri.android; import android.Manifest; +import android.annotation.SuppressLint; +import android.app.Activity; import android.content.*; import android.content.pm.PackageManager; import android.content.res.Configuration; @@ -14,6 +16,8 @@ import android.util.Size; import android.view.KeyEvent; import android.view.View; import android.view.inputmethod.InputMethodManager; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.camera.core.*; import androidx.camera.lifecycle.ProcessCameraProvider; @@ -26,7 +30,7 @@ import androidx.core.view.WindowInsetsCompat; import com.google.androidgamesdk.GameActivity; import com.google.common.util.concurrent.ListenableFuture; -import java.io.File; +import java.io.*; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -63,6 +67,9 @@ public class MainActivity extends GameActivity { private ExecutorService mCameraExecutor = null; private boolean mUseBackCamera = true; + private ActivityResultLauncher mFilePickResultLauncher = null; + + @SuppressLint("UnspecifiedRegisterReceiverFlag") @Override protected void onCreate(Bundle savedInstanceState) { // Clear cache on start. @@ -84,6 +91,36 @@ 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( + new ActivityResultContracts.StartActivityForResult(), + result -> { + int resultCode = result.getResultCode(); + Intent data = result.getData(); + if (resultCode == Activity.RESULT_OK) { + String path = ""; + if (data != null) { + Uri uri = data.getData(); + String name = "pick" + Utils.getFileExtension(uri, this); + 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); + } + } catch (Exception e) { + e.printStackTrace(); + } + path = file.getPath(); + } + onFilePick(path); + } else { + onFilePick(""); + } + }); + // 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) -> { @@ -354,9 +391,23 @@ public class MainActivity extends GameActivity { startActivity(Intent.createChooser(intent, "Share image")); } - // Check if device is using dark theme. + // Called from native code to check if device is using dark theme. public boolean useDarkTheme() { int currentNightMode = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; return currentNightMode == Configuration.UI_MODE_NIGHT_YES; } + + // Called from native code to pick the file. + public void pickFile() { + Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.setType("*/*"); + try { + mFilePickResultLauncher.launch(Intent.createChooser(intent, "Pick file")); + } catch (android.content.ActivityNotFoundException ex) { + onFilePick(""); + } + } + + // Pass picked file into native code. + public native void onFilePick(String path); } \ No newline at end of file diff --git a/android/app/src/main/java/mw/gri/android/Utils.java b/android/app/src/main/java/mw/gri/android/Utils.java index 47d1d56..1a610bb 100644 --- a/android/app/src/main/java/mw/gri/android/Utils.java +++ b/android/app/src/main/java/mw/gri/android/Utils.java @@ -5,6 +5,8 @@ import android.graphics.ImageFormat; import android.graphics.Rect; import android.graphics.YuvImage; import android.media.Image; +import android.net.Uri; +import android.webkit.MimeTypeMap; import androidx.camera.core.ImageProxy; import java.io.ByteArrayOutputStream; @@ -146,4 +148,9 @@ public class Utils { } return directoryToBeDeleted.delete(); } + + public static String getFileExtension(Uri uri, Context context) { + String fileType = context.getContentResolver().getType(uri); + return MimeTypeMap.getSingleton().getExtensionFromMimeType(fileType); + } } diff --git a/src/gui/platform/android/mod.rs b/src/gui/platform/android/mod.rs index 6e2aa01..bb077f7 100644 --- a/src/gui/platform/android/mod.rs +++ b/src/gui/platform/android/mod.rs @@ -42,13 +42,13 @@ impl Android { } /// Call Android Activity method with JNI. - pub fn call_java_method(&self, name: &str, sig: &str, args: &[JValue]) -> Option { + pub fn call_java_method(&self, name: &str, s: &str, a: &[JValue]) -> Option { 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, sig, args) { + if let Ok(result) = env.call_method(activity, name, s, a) { return Some(result.as_jni().clone()); } None @@ -145,6 +145,26 @@ impl PlatformCallbacks for Android { } fn pick_file(&self) -> Option { + // Clear previous result. + let mut w_path = PICKED_FILE_PATH.write(); + *w_path = None; + // Launch file picker. + let _ = self.call_java_method("pickFile", "()V", &[]).unwrap(); + // Return empty string to identify async pick. + Some("".to_string()) + } + + fn picked_file(&self) -> Option { + 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 } } @@ -152,13 +172,13 @@ impl PlatformCallbacks for Android { lazy_static! { /// Last image data from camera. static ref LAST_CAMERA_IMAGE: Arc, u32)>>> = Arc::new(RwLock::new(None)); + /// Picked file path. + static ref PICKED_FILE_PATH: Arc>> = Arc::new(RwLock::new(None)); } -#[allow(dead_code)] -#[cfg(target_os = "android")] +/// Callback from Java code with last entered character from soft keyboard. #[allow(non_snake_case)] #[no_mangle] -/// Callback from Java code with last entered character from soft keyboard. pub extern "C" fn Java_mw_gri_android_MainActivity_onCameraImage( env: JNIEnv, _class: JObject, @@ -169,4 +189,26 @@ pub extern "C" fn Java_mw_gri_android_MainActivity_onCameraImage( let image : Vec = 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)] +#[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(_) => {} + } + } } \ No newline at end of file diff --git a/src/gui/platform/desktop/mod.rs b/src/gui/platform/desktop/mod.rs index 1890002..bdb5d95 100644 --- a/src/gui/platform/desktop/mod.rs +++ b/src/gui/platform/desktop/mod.rs @@ -153,6 +153,10 @@ impl PlatformCallbacks for Desktop { } None } + + fn picked_file(&self) -> Option { + None + } } lazy_static! { diff --git a/src/gui/platform/mod.rs b/src/gui/platform/mod.rs index c925973..e5bc3be 100644 --- a/src/gui/platform/mod.rs +++ b/src/gui/platform/mod.rs @@ -33,4 +33,5 @@ pub trait PlatformCallbacks { fn switch_camera(&self); fn share_data(&self, name: String, data: Vec) -> Result<(), std::io::Error>; fn pick_file(&self) -> Option; + fn picked_file(&self) -> Option; } \ No newline at end of file diff --git a/src/gui/views/file.rs b/src/gui/views/file.rs index 05d22fb..4f99203 100644 --- a/src/gui/views/file.rs +++ b/src/gui/views/file.rs @@ -18,12 +18,14 @@ use std::{fs, thread}; use parking_lot::RwLock; use crate::gui::Colors; -use crate::gui::icons::FILE_ARROW_UP; +use crate::gui::icons::ARCHIVE_BOX; use crate::gui::platform::PlatformCallbacks; use crate::gui::views::View; /// Button to pick file and parse its data into text. pub struct FilePickButton { + /// Flag to check if file is picking. + pub file_picking: Arc, /// Flag to check if file is parsing. pub file_parsing: Arc, /// File parsing result. @@ -33,6 +35,7 @@ pub struct FilePickButton { impl Default for FilePickButton { fn default() -> Self { Self { + file_picking: Arc::new(AtomicBool::new(false)), file_parsing: Arc::new(AtomicBool::new(false)), file_parsing_result: Arc::new(RwLock::new(None)) } @@ -45,8 +48,16 @@ impl FilePickButton { ui: &mut egui::Ui, cb: &dyn PlatformCallbacks, on_result: impl FnOnce(String)) { - if self.file_parsing.load(Ordering::Relaxed) { - // Draw loading spinner on file parsing. + if self.file_picking.load(Ordering::Relaxed) { + View::small_loading_spinner(ui); + // Check file pick result. + if let Some(path) = cb.picked_file() { + self.file_picking.store(false, Ordering::Relaxed); + if !path.is_empty() { + self.on_file_pick(path); + } + } + } else if self.file_parsing.load(Ordering::Relaxed) { View::small_loading_spinner(ui); // Check file parsing result. let has_result = { @@ -67,29 +78,39 @@ impl FilePickButton { } } else { // Draw button to pick file. - let file_text = format!("{} {}", FILE_ARROW_UP, t!("choose_file")); + let file_text = format!("{} {}", ARCHIVE_BOX, t!("choose_file")); View::colored_text_button(ui, file_text, Colors::blue(), Colors::button(), || { if let Some(path) = cb.pick_file() { - // Parse file at new thread. - self.file_parsing.store(true, Ordering::Relaxed); - let result = self.file_parsing_result.clone(); - thread::spawn(move || { - if path.ends_with(".gif") { - //TODO: Detect QR codes on GIF file. - } else if path.ends_with(".jpeg") || path.ends_with(".jpg") || - path.ends_with(".png") { - //TODO: Detect QR codes on image files. - } else { - // Parse file as plain text. - if let Ok(text) = fs::read_to_string(path) { - let mut w_res = result.write(); - *w_res = Some(text); - } - } - }); + self.on_file_pick(path); } }); } } + /// Handle picked file path. + fn on_file_pick(&self, path: String) { + // Wait for asynchronous file pick result if path is empty. + if path.is_empty() { + self.file_picking.store(true, Ordering::Relaxed); + return; + } + self.file_parsing.store(true, Ordering::Relaxed); + let result = self.file_parsing_result.clone(); + thread::spawn(move || { + if path.ends_with(".gif") { + //TODO: Detect QR codes on GIF file. + } else if path.ends_with(".jpeg") || path.ends_with(".jpg") || + path.ends_with(".png") { + //TODO: Detect QR codes on image files. + } else { + // Parse file as plain text. + let mut w_res = result.write(); + if let Ok(text) = fs::read_to_string(path) { + *w_res = Some(text); + } else { + *w_res = Some("".to_string()); + } + } + }); + } } \ No newline at end of file diff --git a/src/gui/views/qr.rs b/src/gui/views/qr.rs index 041e407..bfaa9fd 100644 --- a/src/gui/views/qr.rs +++ b/src/gui/views/qr.rs @@ -73,50 +73,6 @@ impl QrCodeContent { } } - /// Draw QR code image content. - fn qr_image_ui(&mut self, svg: Vec, ui: &mut egui::Ui) { - let mut rect = ui.available_rect_before_wrap(); - rect.min += egui::emath::vec2(10.0, 0.0); - rect.max -= egui::emath::vec2(10.0, 0.0); - - // Create background shape. - let mut bg_shape = egui::epaint::RectShape { - rect, - rounding: egui::Rounding::default(), - fill: egui::Color32::WHITE, - stroke: egui::Stroke::NONE, - fill_texture_id: Default::default(), - uv: egui::Rect::ZERO - }; - let bg_idx = ui.painter().add(bg_shape); - - // Draw QR code image content. - let mut content_rect = ui.allocate_ui_at_rect(rect, |ui| { - ui.add_space(10.0); - let size = SizeHint::Size(ui.available_width() as u32, ui.available_width() as u32); - let color_img = load_svg_bytes_with_size(svg.as_slice(), Some(size)).unwrap(); - // Create image texture. - let texture_handle = ui.ctx().load_texture("qr_code", - color_img.clone(), - TextureOptions::default()); - self.texture_handle = Some(texture_handle.clone()); - let img_size = egui::emath::vec2(color_img.width() as f32, - color_img.height() as f32); - let sized_img = SizedTexture::new(texture_handle.id(), img_size); - // Add image to content. - ui.add(egui::Image::from_texture(sized_img) - .max_height(ui.available_width()) - .fit_to_original_size(1.0)); - ui.add_space(10.0); - }).response.rect; - - // Setup background shape to be painted behind content. - content_rect.min -= egui::emath::vec2(10.0, 0.0); - content_rect.max += egui::emath::vec2(10.0, 0.0); - bg_shape.rect = content_rect; - ui.painter().set(bg_idx, bg_shape); - } - /// Draw animated QR code content. fn animated_ui(&mut self, ui: &mut egui::Ui, text: String, cb: &dyn PlatformCallbacks) { if !self.has_image() { @@ -180,8 +136,9 @@ impl QrCodeContent { }); } else { ui.vertical_centered(|ui| { - ui.add_space(8.0); + ui.add_space(2.0); View::small_loading_spinner(ui); + ui.add_space(1.0); }); } @@ -271,6 +228,50 @@ impl QrCodeContent { } } + /// Draw QR code image content. + fn qr_image_ui(&mut self, svg: Vec, ui: &mut egui::Ui) { + let mut rect = ui.available_rect_before_wrap(); + rect.min += egui::emath::vec2(10.0, 0.0); + rect.max -= egui::emath::vec2(10.0, 0.0); + + // Create background shape. + let mut bg_shape = egui::epaint::RectShape { + rect, + rounding: egui::Rounding::default(), + fill: egui::Color32::WHITE, + stroke: egui::Stroke::NONE, + fill_texture_id: Default::default(), + uv: egui::Rect::ZERO + }; + let bg_idx = ui.painter().add(bg_shape); + + // Draw QR code image content. + let mut content_rect = ui.allocate_ui_at_rect(rect, |ui| { + ui.add_space(10.0); + let size = SizeHint::Size(ui.available_width() as u32, ui.available_width() as u32); + let color_img = load_svg_bytes_with_size(svg.as_slice(), Some(size)).unwrap(); + // Create image texture. + let texture_handle = ui.ctx().load_texture("qr_code", + color_img.clone(), + TextureOptions::default()); + self.texture_handle = Some(texture_handle.clone()); + let img_size = egui::emath::vec2(color_img.width() as f32, + color_img.height() as f32); + let sized_img = SizedTexture::new(texture_handle.id(), img_size); + // Add image to content. + ui.add(egui::Image::from_texture(sized_img) + .max_height(ui.available_width()) + .fit_to_original_size(1.0)); + ui.add_space(10.0); + }).response.rect; + + // Setup background shape to be painted behind content. + content_rect.min -= egui::emath::vec2(10.0, 0.0); + content_rect.max += egui::emath::vec2(10.0, 0.0); + bg_shape.rect = content_rect; + ui.painter().set(bg_idx, bg_shape); + } + /// Check if QR code is loading. fn loading(&self) -> bool { let r_state = self.qr_image_state.read();