android: pick file

This commit is contained in:
ardocrat 2024-06-06 15:02:32 +03:00
parent 2fb3bffc8f
commit d16624d423
7 changed files with 200 additions and 73 deletions

View file

@ -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<Intent> 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);
}

View file

@ -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);
}
}

View file

@ -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<jni::sys::jvalue> {
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, 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<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", &[]).unwrap();
// 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
}
}
@ -152,13 +172,13 @@ impl PlatformCallbacks for Android {
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));
}
#[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,
@ -170,3 +190,25 @@ pub extern "C" fn Java_mw_gri_android_MainActivity_onCameraImage(
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(_) => {}
}
}
}

View file

@ -153,6 +153,10 @@ impl PlatformCallbacks for Desktop {
}
None
}
fn picked_file(&self) -> Option<String> {
None
}
}
lazy_static! {

View file

@ -33,4 +33,5 @@ pub trait PlatformCallbacks {
fn switch_camera(&self);
fn share_data(&self, name: String, data: Vec<u8>) -> Result<(), std::io::Error>;
fn pick_file(&self) -> Option<String>;
fn picked_file(&self) -> Option<String>;
}

View file

@ -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<AtomicBool>,
/// Flag to check if file is parsing.
pub file_parsing: Arc<AtomicBool>,
/// 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());
}
}
});
}
}

View file

@ -73,50 +73,6 @@ impl QrCodeContent {
}
}
/// Draw QR code image content.
fn qr_image_ui(&mut self, svg: Vec<u8>, 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<u8>, 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();