media: handle upload on android
This commit is contained in:
committed by
William Casarin
parent
31ee64827a
commit
6ee2b28e70
@@ -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"]
|
||||
|
||||
@@ -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<String> = {
|
||||
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<i8> = 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<dyn std::error::Error>> {
|
||||
// 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(())
|
||||
}
|
||||
|
||||
99
crates/notedeck/src/platform/file.rs
Normal file
99
crates/notedeck/src/platform/file.rs
Normal file
@@ -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<u8>),
|
||||
}
|
||||
|
||||
#[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<Self, Error> {
|
||||
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<u8>) -> Result<Self, Error> {
|
||||
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<Result<SelectedMedia, Error>>,
|
||||
receiver: Receiver<Result<SelectedMedia, Error>>,
|
||||
}
|
||||
|
||||
impl Default for SelectedMediaChannel {
|
||||
fn default() -> Self {
|
||||
let (sender, receiver) = unbounded();
|
||||
Self { sender, receiver }
|
||||
}
|
||||
}
|
||||
|
||||
impl SelectedMediaChannel {
|
||||
pub fn new_selected_file(&self, media: Result<SelectedMedia, Error>) {
|
||||
let _ = self.sender.send(media);
|
||||
}
|
||||
|
||||
pub fn try_receive(&self) -> Option<Result<SelectedMedia, Error>> {
|
||||
self.receiver.try_recv().ok()
|
||||
}
|
||||
|
||||
pub fn receive(&self) -> Option<Result<SelectedMedia, Error>> {
|
||||
self.receiver.recv().ok()
|
||||
}
|
||||
}
|
||||
|
||||
pub static SELECTED_MEDIA_CHANNEL: Lazy<SelectedMediaChannel> =
|
||||
Lazy::new(SelectedMediaChannel::default);
|
||||
|
||||
pub fn emit_selected_file(media: Result<SelectedMedia, Error>) {
|
||||
SELECTED_MEDIA_CHANNEL.new_selected_file(media);
|
||||
}
|
||||
|
||||
pub fn get_next_selected_file() -> Option<Result<SelectedMedia, Error>> {
|
||||
SELECTED_MEDIA_CHANNEL.try_receive()
|
||||
}
|
||||
@@ -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<Result<SelectedMedia, Error>> {
|
||||
file::get_next_selected_file()
|
||||
}
|
||||
|
||||
const VIRT_HEIGHT: i32 = 400;
|
||||
|
||||
|
||||
@@ -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",),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
fn get_app_args() -> Vec<String> {
|
||||
vec!["argv0-placeholder".to_string()]
|
||||
/*
|
||||
use serde_json::value;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<u8>,
|
||||
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<u8>) -> String {
|
||||
pub fn nip96_upload(
|
||||
seckey: [u8; 32],
|
||||
upload_url: String,
|
||||
media_path: MediaPath,
|
||||
selected_media: SelectedMedia,
|
||||
) -> Promise<Result<Nip94Event, Error>> {
|
||||
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<Result<Nip94Event, Error>> {
|
||||
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<u8>,
|
||||
selected_media: SelectedMedia,
|
||||
) -> Promise<Result<Nip94Event, Error>> {
|
||||
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<Nip94Event, Error> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MediaPath {
|
||||
full_path: PathBuf,
|
||||
file_name: String,
|
||||
media_type: SupportedMimeType,
|
||||
}
|
||||
|
||||
impl MediaPath {
|
||||
pub fn new(path: PathBuf) -> Result<Self, Error> {
|
||||
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<Vec<u8>, 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());
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user