From 6ee2b28e702a992aa4c4ce65b41fc17be781423d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20Lo=CC=81pez=20Guevara?= Date: Sun, 27 Jul 2025 16:10:47 -0300 Subject: [PATCH] media: handle upload on android --- Cargo.lock | 4 + crates/notedeck/Cargo.toml | 2 + crates/notedeck/src/platform/android.rs | 88 +++++- crates/notedeck/src/platform/file.rs | 99 +++++++ crates/notedeck/src/platform/mod.rs | 7 + crates/notedeck/src/urls.rs | 4 +- .../damus/notedeck/KeyboardHeightHelper.java | 48 ---- .../notedeck/KeyboardHeightObserver.java | 35 --- .../notedeck/KeyboardHeightProvider.java | 174 ------------ .../java/com/damus/notedeck/MainActivity.java | 256 +++++++++++++----- crates/notedeck_chrome/src/android.rs | 14 +- crates/notedeck_columns/Cargo.toml | 4 + crates/notedeck_columns/src/media_upload.rs | 102 +++---- crates/notedeck_columns/src/ui/note/post.rs | 51 ++-- 14 files changed, 469 insertions(+), 419 deletions(-) create mode 100644 crates/notedeck/src/platform/file.rs delete mode 100644 crates/notedeck_chrome/android/app/src/main/java/com/damus/notedeck/KeyboardHeightHelper.java delete mode 100644 crates/notedeck_chrome/android/app/src/main/java/com/damus/notedeck/KeyboardHeightObserver.java delete mode 100644 crates/notedeck_chrome/android/app/src/main/java/com/damus/notedeck/KeyboardHeightProvider.java diff --git a/Cargo.lock b/Cargo.lock index 7e0ffb2..8bdcd66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3514,6 +3514,7 @@ dependencies = [ "bitflags 2.9.1", "blurhash", "chrono", + "crossbeam-channel", "dirs", "eframe", "egui", @@ -3532,6 +3533,7 @@ dependencies = [ "lightning-invoice", "md5", "mime_guess", + "ndk-context", "nostr 0.37.0", "nostrdb", "nwc", @@ -3630,6 +3632,8 @@ dependencies = [ "human_format", "image", "indexmap 2.9.0", + "jni 0.21.1 (registry+https://github.com/rust-lang/crates.io-index)", + "ndk-context", "nostrdb", "notedeck", "notedeck_ui", diff --git a/crates/notedeck/Cargo.toml b/crates/notedeck/Cargo.toml index 40b6dbe..cc53e08 100644 --- a/crates/notedeck/Cargo.toml +++ b/crates/notedeck/Cargo.toml @@ -51,6 +51,7 @@ bitflags = { workspace = true } regex = "1" chrono = { workspace = true } indexmap = {workspace = true} +crossbeam-channel = "0.5" [dev-dependencies] tempfile = { workspace = true } @@ -59,6 +60,7 @@ tokio = { workspace = true } [target.'cfg(target_os = "android")'.dependencies] jni = { workspace = true } android-activity = { workspace = true } +ndk-context = "0.1" [features] puffin = ["puffin_egui", "dep:puffin"] diff --git a/crates/notedeck/src/platform/android.rs b/crates/notedeck/src/platform/android.rs index 1417e05..b81da30 100644 --- a/crates/notedeck/src/platform/android.rs +++ b/crates/notedeck/src/platform/android.rs @@ -1,5 +1,14 @@ +use crate::platform::{file::emit_selected_file, SelectedMedia}; +use jni::{ + objects::{JByteArray, JClass, JObject, JObjectArray, JString}, + JNIEnv, +}; use std::sync::atomic::{AtomicI32, Ordering}; -use tracing::debug; +use tracing::{debug, error, info}; + +pub fn get_jvm() -> jni::JavaVM { + unsafe { jni::JavaVM::from_raw(ndk_context::android_context().vm().cast()) }.unwrap() +} // Thread-safe static global static KEYBOARD_HEIGHT: AtomicI32 = AtomicI32::new(0); @@ -24,3 +33,80 @@ pub extern "C" fn Java_com_damus_notedeck_KeyboardHeightHelper_nativeKeyboardHei pub fn virtual_keyboard_height() -> i32 { KEYBOARD_HEIGHT.load(Ordering::SeqCst) } + +#[no_mangle] +pub extern "C" fn Java_com_damus_notedeck_MainActivity_nativeOnFilePickedFailed( + mut env: JNIEnv, + _class: JClass, + juri: JString, + je: JString, +) { + let _uri: String = env.get_string(&juri).unwrap().into(); + let _error: String = env.get_string(&je).unwrap().into(); +} + +#[no_mangle] +pub extern "C" fn Java_com_damus_notedeck_MainActivity_nativeOnFilePickedWithContent( + mut env: JNIEnv, + _class: JClass, + // [display_name, size, mime_type] + juri_info: JObjectArray, + jcontent: JByteArray, +) { + debug!("File picked with content"); + + let display_name: Option = { + let obj = env.get_object_array_element(&juri_info, 0).unwrap(); + if obj.is_null() { + None + } else { + Some(env.get_string(&JString::from(obj)).unwrap().into()) + } + }; + + if let Some(display_name) = display_name { + let length = env.get_array_length(&jcontent).unwrap() as usize; + let mut content: Vec = vec![0; length]; + env.get_byte_array_region(&jcontent, 0, &mut content) + .unwrap(); + + debug!("selected file: {display_name:?} ({length:?} bytes)",); + + emit_selected_file(SelectedMedia::from_bytes( + display_name, + content.into_iter().map(|b| b as u8).collect(), + )); + } else { + error!("Received null file name"); + } +} + +pub fn try_open_file_picker() { + match open_file_picker() { + Ok(()) => { + info!("File picker opened successfully"); + } + Err(e) => { + error!("Failed to open file picker: {}", e); + } + } +} + +pub fn open_file_picker() -> std::result::Result<(), Box> { + // Get the Java VM from AndroidApp + let vm = get_jvm(); + + // Attach current thread to get JNI environment + let mut env = vm.attach_current_thread()?; + + let context = unsafe { JObject::from_raw(ndk_context::android_context().context().cast()) }; + // Call the openFilePicker method on the MainActivity + env.call_method( + context, + "openFilePicker", + "()V", // Method signature: no parameters, void return + &[], // No arguments + )?; + + Ok(()) +} diff --git a/crates/notedeck/src/platform/file.rs b/crates/notedeck/src/platform/file.rs new file mode 100644 index 0000000..f6df242 --- /dev/null +++ b/crates/notedeck/src/platform/file.rs @@ -0,0 +1,99 @@ +use std::{path::PathBuf, str::FromStr}; + +use crossbeam_channel::{unbounded, Receiver, Sender}; +use once_cell::sync::Lazy; + +use crate::{Error, SupportedMimeType}; + +#[derive(Debug)] +pub enum MediaFrom { + PathBuf(PathBuf), + Memory(Vec), +} + +#[derive(Debug)] +pub struct SelectedMedia { + pub from: MediaFrom, + pub file_name: String, + pub media_type: SupportedMimeType, +} + +impl SelectedMedia { + pub fn from_path(path: PathBuf) -> Result { + if let Some(ex) = path.extension().and_then(|f| f.to_str()) { + let media_type = SupportedMimeType::from_extension(ex)?; + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(&format!("file.{ex}")) + .to_owned(); + + Ok(SelectedMedia { + from: MediaFrom::PathBuf(path), + file_name, + media_type, + }) + } else { + Err(Error::Generic(format!( + "{path:?} does not have an extension" + ))) + } + } + + pub fn from_bytes(file_name: String, content: Vec) -> Result { + if let Some(ex) = PathBuf::from_str(&file_name) + .unwrap() + .extension() + .and_then(|f| f.to_str()) + { + let media_type = SupportedMimeType::from_extension(ex)?; + + Ok(SelectedMedia { + from: MediaFrom::Memory(content), + file_name, + media_type, + }) + } else { + Err(Error::Generic(format!( + "{file_name:?} does not have an extension" + ))) + } + } +} + +pub struct SelectedMediaChannel { + sender: Sender>, + receiver: Receiver>, +} + +impl Default for SelectedMediaChannel { + fn default() -> Self { + let (sender, receiver) = unbounded(); + Self { sender, receiver } + } +} + +impl SelectedMediaChannel { + pub fn new_selected_file(&self, media: Result) { + let _ = self.sender.send(media); + } + + pub fn try_receive(&self) -> Option> { + self.receiver.try_recv().ok() + } + + pub fn receive(&self) -> Option> { + self.receiver.recv().ok() + } +} + +pub static SELECTED_MEDIA_CHANNEL: Lazy = + Lazy::new(SelectedMediaChannel::default); + +pub fn emit_selected_file(media: Result) { + SELECTED_MEDIA_CHANNEL.new_selected_file(media); +} + +pub fn get_next_selected_file() -> Option> { + SELECTED_MEDIA_CHANNEL.try_receive() +} diff --git a/crates/notedeck/src/platform/mod.rs b/crates/notedeck/src/platform/mod.rs index dac0b36..34978e8 100644 --- a/crates/notedeck/src/platform/mod.rs +++ b/crates/notedeck/src/platform/mod.rs @@ -1,5 +1,12 @@ +use crate::{platform::file::SelectedMedia, Error}; + #[cfg(target_os = "android")] pub mod android; +pub mod file; + +pub fn get_next_selected_file() -> Option> { + file::get_next_selected_file() +} const VIRT_HEIGHT: i32 = 400; diff --git a/crates/notedeck/src/urls.rs b/crates/notedeck/src/urls.rs index 91c52a6..9024900 100644 --- a/crates/notedeck/src/urls.rs +++ b/crates/notedeck/src/urls.rs @@ -238,7 +238,9 @@ impl SupportedMimeType { { Ok(Self { mime }) } else { - Err(Error::Generic("Unsupported mime type".to_owned())) + Err(Error::Generic( + format!("{extension} Unsupported mime type",), + )) } } diff --git a/crates/notedeck_chrome/android/app/src/main/java/com/damus/notedeck/KeyboardHeightHelper.java b/crates/notedeck_chrome/android/app/src/main/java/com/damus/notedeck/KeyboardHeightHelper.java deleted file mode 100644 index 9fd4443..0000000 --- a/crates/notedeck_chrome/android/app/src/main/java/com/damus/notedeck/KeyboardHeightHelper.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.damus.notedeck; - -import android.app.Activity; -import android.content.res.Configuration; -import android.util.Log; -import android.view.View; - -public class KeyboardHeightHelper { - private static final String TAG = "KeyboardHeightHelper"; - private KeyboardHeightProvider keyboardHeightProvider; - private Activity activity; - - // Static JNI method not tied to any specific activity - private static native void nativeKeyboardHeightChanged(int height); - - public KeyboardHeightHelper(Activity activity) { - this.activity = activity; - keyboardHeightProvider = new KeyboardHeightProvider(activity); - - // Create observer implementation - KeyboardHeightObserver observer = (height, orientation) -> { - Log.d(TAG, "Keyboard height: " + height + "px, orientation: " + - (orientation == Configuration.ORIENTATION_PORTRAIT ? "portrait" : "landscape")); - - // Call the generic native method - nativeKeyboardHeightChanged(height); - }; - - // Set up the provider - keyboardHeightProvider.setKeyboardHeightObserver(observer); - } - - public void start() { - // Start the keyboard height provider after the view is ready - final View contentView = activity.findViewById(android.R.id.content); - contentView.post(() -> { - keyboardHeightProvider.start(); - }); - } - - public void stop() { - keyboardHeightProvider.setKeyboardHeightObserver(null); - } - - public void close() { - keyboardHeightProvider.close(); - } -} diff --git a/crates/notedeck_chrome/android/app/src/main/java/com/damus/notedeck/KeyboardHeightObserver.java b/crates/notedeck_chrome/android/app/src/main/java/com/damus/notedeck/KeyboardHeightObserver.java deleted file mode 100644 index 58cca6a..0000000 --- a/crates/notedeck_chrome/android/app/src/main/java/com/damus/notedeck/KeyboardHeightObserver.java +++ /dev/null @@ -1,35 +0,0 @@ -/* - * This file is part of Siebe Projects samples. - * - * Siebe Projects samples is free software: you can redistribute it and/or modify - * it under the terms of the Lesser GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Siebe Projects samples is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * Lesser GNU General Public License for more details. - * - * You should have received a copy of the Lesser GNU General Public License - * along with Siebe Projects samples. If not, see . - */ - -package com.damus.notedeck; - -/** - * The observer that will be notified when the height of - * the keyboard has changed - */ -public interface KeyboardHeightObserver { - - /** - * Called when the keyboard height has changed, 0 means keyboard is closed, - * >= 1 means keyboard is opened. - * - * @param height The height of the keyboard in pixels - * @param orientation The orientation either: Configuration.ORIENTATION_PORTRAIT or - * Configuration.ORIENTATION_LANDSCAPE - */ - void onKeyboardHeightChanged(int height, int orientation); -} diff --git a/crates/notedeck_chrome/android/app/src/main/java/com/damus/notedeck/KeyboardHeightProvider.java b/crates/notedeck_chrome/android/app/src/main/java/com/damus/notedeck/KeyboardHeightProvider.java deleted file mode 100644 index 9c85657..0000000 --- a/crates/notedeck_chrome/android/app/src/main/java/com/damus/notedeck/KeyboardHeightProvider.java +++ /dev/null @@ -1,174 +0,0 @@ -/* - * This file is part of Siebe Projects samples. - * - * Siebe Projects samples is free software: you can redistribute it and/or modify - * it under the terms of the Lesser GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Siebe Projects samples is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * Lesser GNU General Public License for more details. - * - * You should have received a copy of the Lesser GNU General Public License - * along with Siebe Projects samples. If not, see . - */ - -package com.damus.notedeck; - -import android.app.Activity; - -import android.content.res.Configuration; -import android.content.res.Resources; -import android.util.Log; -import android.graphics.Point; -import android.graphics.Rect; -import android.graphics.drawable.ColorDrawable; -import android.util.DisplayMetrics; - -import android.view.Gravity; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewTreeObserver.OnGlobalLayoutListener; - -import android.view.WindowManager.LayoutParams; - -import android.widget.PopupWindow; - - -/** - * The keyboard height provider, this class uses a PopupWindow - * to calculate the window height when the floating keyboard is opened and closed. - */ -public class KeyboardHeightProvider extends PopupWindow { - - /** The tag for logging purposes */ - private final static String TAG = "sample_KeyboardHeightProvider"; - - /** The keyboard height observer */ - private KeyboardHeightObserver observer; - - /** The cached landscape height of the keyboard */ - private int keyboardLandscapeHeight; - - /** The cached portrait height of the keyboard */ - private int keyboardPortraitHeight; - - /** The view that is used to calculate the keyboard height */ - private View popupView; - - /** The parent view */ - private View parentView; - - /** The root activity that uses this KeyboardHeightProvider */ - private Activity activity; - - /** - * Construct a new KeyboardHeightProvider - * - * @param activity The parent activity - */ - public KeyboardHeightProvider(Activity activity) { - super(activity); - this.activity = activity; - - //LayoutInflater inflator = (LayoutInflater) activity.getSystemService(Activity.LAYOUT_INFLATER_SERVICE); - //this.popupView = inflator.inflate(android.R.layout.popupwindow, null, false); - this.popupView = new View(activity); - setContentView(popupView); - - setSoftInputMode(LayoutParams.SOFT_INPUT_ADJUST_RESIZE | LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE); - setInputMethodMode(PopupWindow.INPUT_METHOD_NEEDED); - - parentView = activity.findViewById(android.R.id.content); - - setWidth(0); - setHeight(LayoutParams.MATCH_PARENT); - - popupView.getViewTreeObserver().addOnGlobalLayoutListener(new OnGlobalLayoutListener() { - - @Override - public void onGlobalLayout() { - if (popupView != null) { - handleOnGlobalLayout(); - } - } - }); - } - - /** - * Start the KeyboardHeightProvider, this must be called after the onResume of the Activity. - * PopupWindows are not allowed to be registered before the onResume has finished - * of the Activity. - */ - public void start() { - - if (!isShowing() && parentView.getWindowToken() != null) { - setBackgroundDrawable(new ColorDrawable(0)); - showAtLocation(parentView, Gravity.NO_GRAVITY, 0, 0); - } - } - - /** - * Close the keyboard height provider, - * this provider will not be used anymore. - */ - public void close() { - this.observer = null; - dismiss(); - } - - /** - * Set the keyboard height observer to this provider. The - * observer will be notified when the keyboard height has changed. - * For example when the keyboard is opened or closed. - * - * @param observer The observer to be added to this provider. - */ - public void setKeyboardHeightObserver(KeyboardHeightObserver observer) { - this.observer = observer; - } - - /** - * Popup window itself is as big as the window of the Activity. - * The keyboard can then be calculated by extracting the popup view bottom - * from the activity window height. - */ - private void handleOnGlobalLayout() { - - Point screenSize = new Point(); - activity.getWindowManager().getDefaultDisplay().getSize(screenSize); - - Rect rect = new Rect(); - popupView.getWindowVisibleDisplayFrame(rect); - - // REMIND, you may like to change this using the fullscreen size of the phone - // and also using the status bar and navigation bar heights of the phone to calculate - // the keyboard height. But this worked fine on a Nexus. - int orientation = getScreenOrientation(); - int keyboardHeight = screenSize.y - rect.bottom; - - if (keyboardHeight == 0) { - notifyKeyboardHeightChanged(0, orientation); - } - else if (orientation == Configuration.ORIENTATION_PORTRAIT) { - this.keyboardPortraitHeight = keyboardHeight; - notifyKeyboardHeightChanged(keyboardPortraitHeight, orientation); - } - else { - this.keyboardLandscapeHeight = keyboardHeight; - notifyKeyboardHeightChanged(keyboardLandscapeHeight, orientation); - } - } - - private int getScreenOrientation() { - return activity.getResources().getConfiguration().orientation; - } - - private void notifyKeyboardHeightChanged(int height, int orientation) { - if (observer != null) { - observer.onKeyboardHeightChanged(height, orientation); - } - } -} diff --git a/crates/notedeck_chrome/android/app/src/main/java/com/damus/notedeck/MainActivity.java b/crates/notedeck_chrome/android/app/src/main/java/com/damus/notedeck/MainActivity.java index 61a2068..c806613 100644 --- a/crates/notedeck_chrome/android/app/src/main/java/com/damus/notedeck/MainActivity.java +++ b/crates/notedeck_chrome/android/app/src/main/java/com/damus/notedeck/MainActivity.java @@ -1,13 +1,18 @@ package com.damus.notedeck; +import android.content.ClipData; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; import android.os.Bundle; +import android.os.ParcelFileDescriptor; +import android.provider.OpenableColumns; import android.util.Log; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import androidx.core.graphics.Insets; -import androidx.core.view.DisplayCutoutCompat; import androidx.core.view.ViewCompat; import androidx.core.view.WindowCompat; import androidx.core.view.WindowInsetsCompat; @@ -15,52 +20,23 @@ import androidx.core.view.WindowInsetsControllerCompat; import com.google.androidgamesdk.GameActivity; +import java.io.ByteArrayOutputStream; +import java.io.FileDescriptor; +import java.io.IOException; +import java.io.InputStream; + public class MainActivity extends GameActivity { - static { - System.loadLibrary("notedeck_chrome"); - } + static final int REQUEST_CODE_PICK_FILE = 420; - private native void nativeOnKeyboardHeightChanged(int height); - private KeyboardHeightHelper keyboardHelper; - - @Override - protected void onCreate(Bundle savedInstanceState) { - // Shrink view so it does not get covered by insets. - super.onCreate(savedInstanceState); + private native void nativeOnFilePickedFailed(String uri, String e); + private native void nativeOnFilePickedWithContent(Object[] uri_info, byte[] content); - setupInsets(); - - //setupFullscreen() - - //keyboardHelper = new KeyboardHeightHelper(this); - - - } - - private void setupFullscreen() { - WindowCompat.setDecorFitsSystemWindows(getWindow(), false); - - WindowInsetsControllerCompat controller = - WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView()); - if (controller != null) { - controller.setSystemBarsBehavior( - WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - ); - controller.hide(WindowInsetsCompat.Type.systemBars()); - } - - //focus(getContent()) - } - - // not sure if this does anything - private void focus(View content) { - content.setFocusable(true); - content.setFocusableInTouchMode(true); - content.requestFocus(); - } - - private View getContent() { - return getWindow().getDecorView().findViewById(android.R.id.content); + public void openFilePicker() { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + intent.setType("*/*"); + intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); + intent.addCategory(Intent.CATEGORY_OPENABLE); + startActivityForResult(intent, REQUEST_CODE_PICK_FILE); } private void setupInsets() { @@ -92,35 +68,171 @@ public class MainActivity extends GameActivity { WindowCompat.setDecorFitsSystemWindows(getWindow(), false); } - - /* - @Override - public void onResume() { - super.onResume(); - keyboardHelper.start(); - } - - @Override - public void onPause() { - super.onPause(); - keyboardHelper.stop(); - } - - @Override - public void onDestroy() { - super.onDestroy(); - keyboardHelper.close(); - } - */ - @Override - public boolean onTouchEvent(MotionEvent event) { - // Offset the location so it fits the view with margins caused by insets. + private void processSelectedFile(Uri uri) { + try { + nativeOnFilePickedWithContent(this.getUriInfo(uri), readUriContent(uri)); + } catch (Exception e) { + Log.e("MainActivity", "Error processing file: " + uri.toString(), e); - int[] location = new int[2]; - findViewById(android.R.id.content).getLocationOnScreen(location); - event.offsetLocation(-location[0], -location[1]); - - return super.onTouchEvent(event); + nativeOnFilePickedFailed(uri.toString(), e.toString()); + } } + + private Object[] getUriInfo(Uri uri) throws Exception { + if (!uri.getScheme().equals("content")) { + throw new Exception("uri should start with content://"); + } + + Cursor cursor = getContentResolver().query(uri, null, null, null, null); + + while (cursor.moveToNext()) { + Object[] info = new Object[3]; + + int col_idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); + info[0] = cursor.getString(col_idx); + + col_idx = cursor.getColumnIndex(OpenableColumns.SIZE); + info[1] = cursor.getLong(col_idx); + + col_idx = cursor.getColumnIndex("mime_type"); + info[2] = cursor.getString(col_idx); + + return info; + } + + return null; + } + + private byte[] readUriContent(Uri uri) { + InputStream inputStream = null; + ByteArrayOutputStream buffer = null; + + try { + inputStream = getContentResolver().openInputStream(uri); + if (inputStream == null) { + Log.e("MainActivity", "Could not open input stream for URI: " + uri); + return null; + } + + buffer = new ByteArrayOutputStream(); + byte[] data = new byte[8192]; // 8KB buffer + int bytesRead; + + while ((bytesRead = inputStream.read(data)) != -1) { + buffer.write(data, 0, bytesRead); + } + + byte[] result = buffer.toByteArray(); + Log.d("MainActivity", "Successfully read " + result.length + " bytes"); + return result; + + } catch (IOException e) { + Log.e("MainActivity", "IOException while reading URI: " + uri, e); + return null; + } catch (SecurityException e) { + Log.e("MainActivity", "SecurityException while reading URI: " + uri, e); + return null; + } finally { + // Close streams + if (inputStream != null) { + try { + inputStream.close(); + } catch (IOException e) { + Log.e("MainActivity", "Error closing input stream", e); + } + } + if (buffer != null) { + try { + buffer.close(); + } catch (IOException e) { + Log.e("MainActivity", "Error closing buffer", e); + } + } + } + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + // Shrink view so it does not get covered by insets. + + setupInsets(); + //setupFullscreen() + + super.onCreate(savedInstanceState); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (requestCode == REQUEST_CODE_PICK_FILE && resultCode == RESULT_OK) { + if (data == null) return; + + if (data.getClipData() != null) { + // Multiple files selected + ClipData clipData = data.getClipData(); + for (int i = 0; i < clipData.getItemCount(); i++) { + Uri uri = clipData.getItemAt(i).getUri(); + processSelectedFile(uri); + } + } else if (data.getData() != null) { + // Single file selected + Uri uri = data.getData(); + processSelectedFile(uri); + } + } + } + + private void setupFullscreen() { + WindowCompat.setDecorFitsSystemWindows(getWindow(), false); + + WindowInsetsControllerCompat controller = + WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView()); + if (controller != null) { + controller.setSystemBarsBehavior( + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + ); + controller.hide(WindowInsetsCompat.Type.systemBars()); + } + + //focus(getContent()) + } + + // not sure if this does anything + private void focus(View content) { + content.setFocusable(true); + content.setFocusableInTouchMode(true); + content.requestFocus(); + } + + private View getContent() { + return getWindow().getDecorView().findViewById(android.R.id.content); + } + + @Override + public void onResume() { + super.onResume(); + } + + @Override + public void onPause() { + super.onPause(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + // Offset the location so it fits the view with margins caused by insets. + + int[] location = new int[2]; + findViewById(android.R.id.content).getLocationOnScreen(location); + event.offsetLocation(-location[0], -location[1]); + + return super.onTouchEvent(event); + } } diff --git a/crates/notedeck_chrome/src/android.rs b/crates/notedeck_chrome/src/android.rs index f36bf87..829ce37 100644 --- a/crates/notedeck_chrome/src/android.rs +++ b/crates/notedeck_chrome/src/android.rs @@ -8,12 +8,11 @@ use notedeck::Notedeck; #[no_mangle] #[tokio::main] -pub async fn android_main(app: AndroidApp) { +pub async fn android_main(android_app: AndroidApp) { //use tracing_logcat::{LogcatMakeWriter, LogcatTag}; use tracing_subscriber::{prelude::*, EnvFilter}; std::env::set_var("RUST_BACKTRACE", "full"); - //std::env::set_var("DAVE_ENDPOINT", "http://ollama.jb55.com/v1"); //std::env::set_var("DAVE_MODEL", "hhao/qwen2.5-coder-tools:latest"); std::env::set_var( "RUST_LOG", @@ -42,7 +41,7 @@ pub async fn android_main(app: AndroidApp) { .with(fmt_layer) .init(); - let path = app.internal_data_path().expect("data path"); + let path = android_app.internal_data_path().expect("data path"); let mut options = eframe::NativeOptions { depth_buffer: 24, ..eframe::NativeOptions::default() @@ -55,17 +54,18 @@ pub async fn android_main(app: AndroidApp) { // builder.with_android_app(app_clone_for_event_loop); //})); - options.android_app = Some(app.clone()); + options.android_app = Some(android_app.clone()); - let app_args = get_app_args(app.clone()); + let app_args = get_app_args(); let _res = eframe::run_native( "Damus Notedeck", options, Box::new(move |cc| { let ctx = &cc.egui_ctx; + let mut notedeck = Notedeck::new(ctx, path, &app_args); - notedeck.set_android_context(app.clone()); + notedeck.set_android_context(android_app); notedeck.setup(ctx); let chrome = Chrome::new_with_apps(cc, &app_args, &mut notedeck)?; notedeck.set_app(chrome); @@ -104,7 +104,7 @@ Using internal storage would be better but it seems hard to get the config file the device ... */ -fn get_app_args(_app: AndroidApp) -> Vec { +fn get_app_args() -> Vec { vec!["argv0-placeholder".to_string()] /* use serde_json::value; diff --git a/crates/notedeck_columns/Cargo.toml b/crates/notedeck_columns/Cargo.toml index a7ec688..655bc43 100644 --- a/crates/notedeck_columns/Cargo.toml +++ b/crates/notedeck_columns/Cargo.toml @@ -10,6 +10,10 @@ description = "A tweetdeck-style notedeck app" [lib] crate-type = ["lib", "cdylib"] +[target.'cfg(target_os = "android")'.dependencies] +jni = { workspace = true } +ndk-context = "0.1" + [dependencies] opener = { workspace = true } rmpv = { workspace = true } diff --git a/crates/notedeck_columns/src/media_upload.rs b/crates/notedeck_columns/src/media_upload.rs index b7653e2..91edf2b 100644 --- a/crates/notedeck_columns/src/media_upload.rs +++ b/crates/notedeck_columns/src/media_upload.rs @@ -1,18 +1,17 @@ #![cfg_attr(target_os = "android", allow(dead_code, unused_variables))] -use std::path::PathBuf; - +use crate::Error; use base64::{prelude::BASE64_URL_SAFE, Engine}; use ehttp::Request; use nostrdb::{Note, NoteBuilder}; -use notedeck::SupportedMimeType; +use notedeck::{ + media::images::fetch_binary_from_disk, + platform::file::{MediaFrom, SelectedMedia}, +}; use poll_promise::Promise; use sha2::{Digest, Sha256}; use url::Url; -use crate::Error; -use notedeck::media::images::fetch_binary_from_disk; - pub const NOSTR_BUILD_URL: fn() -> Url = || Url::parse("http://nostr.build").unwrap(); const NIP96_WELL_KNOWN: &str = ".well-known/nostr/nip96.json"; @@ -94,15 +93,15 @@ fn create_nip98_note(seckey: &[u8; 32], upload_url: String, payload_hash: String fn create_nip96_request( upload_url: &str, - media_path: MediaPath, + file_name: &str, + media_type: &str, file_contents: Vec, nip98_base64: &str, ) -> ehttp::Request { let boundary = "----boundary"; let mut body = format!( - "--{}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\nContent-Type: {}\r\n\r\n", - boundary, media_path.file_name, media_path.media_type.to_mime() + "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{file_name}\"\r\nContent-Type: {media_type}\r\n\r\n", ) .into_bytes(); body.extend(file_contents); @@ -134,25 +133,14 @@ fn sha256_hex(contents: &Vec) -> String { pub fn nip96_upload( seckey: [u8; 32], upload_url: String, - media_path: MediaPath, + selected_media: SelectedMedia, ) -> Promise> { - let bytes_res = fetch_binary_from_disk(media_path.full_path.clone()); - - let file_bytes = match bytes_res { - Ok(bytes) => bytes, - Err(e) => { - return Promise::from_ready(Err(Error::Generic(format!( - "could not read contents of file to upload: {e}" - )))); - } - }; - - internal_nip96_upload(seckey, upload_url, media_path, file_bytes) + internal_nip96_upload(seckey, upload_url, selected_media) } pub fn nostrbuild_nip96_upload( seckey: [u8; 32], - media_path: MediaPath, + selected_media: SelectedMedia, ) -> Promise> { let (sender, promise) = Promise::new(); std::thread::spawn(move || { @@ -166,7 +154,7 @@ pub fn nostrbuild_nip96_upload( } }; - let res = nip96_upload(seckey, upload_url, media_path).block_and_take(); + let res = nip96_upload(seckey, upload_url, selected_media).block_and_take(); sender.send(res); }); promise @@ -175,9 +163,21 @@ pub fn nostrbuild_nip96_upload( fn internal_nip96_upload( seckey: [u8; 32], upload_url: String, - media_path: MediaPath, - file_contents: Vec, + selected_media: SelectedMedia, ) -> Promise> { + let file_name = selected_media.file_name; + let mime_type = selected_media.media_type.to_mime(); + let bytes_res = bytes_from_media(selected_media.from); + + let file_contents = match bytes_res { + Ok(bytes) => bytes, + Err(e) => { + return Promise::from_ready(Err(Error::Generic(format!( + "could not read contents of file to upload: {e}" + )))); + } + }; + let file_hash = sha256_hex(&file_contents); let nip98_note = create_nip98_note(&seckey, upload_url.to_owned(), file_hash); @@ -186,7 +186,13 @@ fn internal_nip96_upload( Err(e) => return Promise::from_ready(Err(Error::Generic(e.to_string()))), }; - let request = create_nip96_request(&upload_url, media_path, file_contents, &nip98_base64); + let request = create_nip96_request( + &upload_url, + &file_name, + mime_type, + file_contents, + &nip98_base64, + ); let (sender, promise) = Promise::new(); @@ -232,33 +238,10 @@ fn find_nip94_ev_in_json(json: String) -> Result { } } -#[derive(Debug)] -pub struct MediaPath { - full_path: PathBuf, - file_name: String, - media_type: SupportedMimeType, -} - -impl MediaPath { - pub fn new(path: PathBuf) -> Result { - if let Some(ex) = path.extension().and_then(|f| f.to_str()) { - let media_type = SupportedMimeType::from_extension(ex)?; - let file_name = path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(&format!("file.{ex}")) - .to_owned(); - - Ok(MediaPath { - full_path: path, - file_name, - media_type, - }) - } else { - Err(Error::Generic(format!( - "{path:?} does not have an extension" - ))) - } +pub fn bytes_from_media(media: MediaFrom) -> Result, notedeck::Error> { + match media { + MediaFrom::PathBuf(full_path) => fetch_binary_from_disk(full_path.clone()), + MediaFrom::Memory(bytes) => Ok(bytes), } } @@ -349,7 +332,7 @@ mod tests { use enostr::FullKeypair; use crate::media_upload::{ - get_upload_url_from_provider, nostrbuild_nip96_upload, MediaPath, NOSTR_BUILD_URL, + get_upload_url_from_provider, nostrbuild_nip96_upload, SelectedMedia, NOSTR_BUILD_URL, }; use super::internal_nip96_upload; @@ -368,7 +351,7 @@ mod tests { fn test_internal_nip96() { // just a random image to test image upload let file_path = PathBuf::from_str("../../../assets/damus_rounded_80.png").unwrap(); - let media_path = MediaPath::new(file_path).unwrap(); + let selected_media = SelectedMedia::from_path(file_path).unwrap(); let img_bytes = include_bytes!("../../../assets/damus_rounded_80.png"); let promise = get_upload_url_from_provider(NOSTR_BUILD_URL()); let kp = FullKeypair::generate(); @@ -378,8 +361,7 @@ mod tests { let promise = internal_nip96_upload( kp.secret_key.secret_bytes(), upload_url.to_string(), - media_path, - img_bytes.to_vec(), + selected_media, ); let res = promise.block_until_ready(); assert!(res.is_ok()) @@ -395,11 +377,11 @@ mod tests { let file_path = fs::canonicalize(PathBuf::from_str("../../assets/damus_rounded_80.png").unwrap()) .unwrap(); - let media_path = MediaPath::new(file_path).unwrap(); + let selected_media = SelectedMedia::from_path(file_path).unwrap(); let kp = FullKeypair::generate(); println!("Using pubkey: {:?}", kp.pubkey); - let promise = nostrbuild_nip96_upload(kp.secret_key.secret_bytes(), media_path); + let promise = nostrbuild_nip96_upload(kp.secret_key.secret_bytes(), selected_media); let out = promise.block_and_take(); assert!(out.is_ok()); diff --git a/crates/notedeck_columns/src/ui/note/post.rs b/crates/notedeck_columns/src/ui/note/post.rs index 4844380..8e15ea9 100644 --- a/crates/notedeck_columns/src/ui/note/post.rs +++ b/crates/notedeck_columns/src/ui/note/post.rs @@ -1,11 +1,9 @@ use crate::draft::{Draft, Drafts, MentionHint}; -#[cfg(not(target_os = "android"))] -use crate::media_upload::{nostrbuild_nip96_upload, MediaPath}; +use crate::media_upload::nostrbuild_nip96_upload; use crate::post::{downcast_post_buffer, MentionType, NewPost}; use crate::ui::mentions_picker::MentionPickerView; use crate::ui::{self, Preview, PreviewConfig}; use crate::Result; - use egui::{ text::{CCursorRange, LayoutJob}, text_edit::TextEditOutput, @@ -16,19 +14,22 @@ use enostr::{FilledKeypair, FullKeypair, NoteId, Pubkey, RelayPool}; use nostrdb::{Ndb, Transaction}; use notedeck::media::gif::ensure_latest_texture; use notedeck::media::AnimationMode; +#[cfg(target_os = "android")] +use notedeck::platform::android::try_open_file_picker; +use notedeck::platform::get_next_selected_file; use notedeck::{get_render_state, JobsCache, PixelDimensions, RenderState}; - +use notedeck::{ + name::get_display_name, supported_mime_hosted_at_url, tr, Localization, NoteAction, NoteContext, +}; use notedeck_ui::{ app_images, context_menu::{input_context, PasteBehavior}, note::render_note_preview, NoteOptions, ProfilePic, }; - -use notedeck::{ - name::get_display_name, supported_mime_hosted_at_url, tr, Localization, NoteAction, NoteContext, -}; use tracing::error; +#[cfg(not(target_os = "android"))] +use {notedeck::platform::file::emit_selected_file, notedeck::platform::file::SelectedMedia}; pub struct PostView<'a, 'd> { note_context: &'a mut NoteContext<'d>, @@ -341,6 +342,22 @@ impl<'a, 'd> PostView<'a, 'd> { } pub fn ui(&mut self, txn: &Transaction, ui: &mut egui::Ui) -> PostResponse { + while let Some(selected_file) = get_next_selected_file() { + match selected_file { + Ok(selected_media) => { + let promise = nostrbuild_nip96_upload( + self.poster.secret_key.secret_bytes(), + selected_media, + ); + self.draft.uploading_media.push(promise); + } + Err(e) => { + error!("{e}"); + self.draft.upload_errors.push(e.to_string()); + } + } + } + ScrollArea::vertical() .id_salt(PostView::scroll_id()) .show(ui, |ui| self.ui_no_scroll(txn, ui)) @@ -521,22 +538,14 @@ impl<'a, 'd> PostView<'a, 'd> { { if let Some(files) = rfd::FileDialog::new().pick_files() { for file in files { - match MediaPath::new(file) { - Ok(media_path) => { - let promise = nostrbuild_nip96_upload( - self.poster.secret_key.secret_bytes(), - media_path, - ); - self.draft.uploading_media.push(promise); - } - Err(e) => { - error!("{e}"); - self.draft.upload_errors.push(e.to_string()); - } - } + emit_selected_file(SelectedMedia::from_path(file)); } } } + #[cfg(target_os = "android")] + { + try_open_file_picker(); + } } }