Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
/** Minimal NativeActivity host for GPUI. */
public final class GpuiActivity extends NativeActivity {
private static final int REQUEST_CAMERA = 6102;
private static final int REQUEST_IMAGES = 6103;
private static final int HOST_OK = 0;
private static final int HOST_CANCELLED = 1;
private static final int HOST_ERROR = 2;
Expand All @@ -53,6 +54,8 @@ public final class GpuiActivity extends NativeActivity {
private boolean keyboardVisible;
private boolean keyboardShowPending;
private long cameraRequest;
private long imageRequest;
private int imageLimit;
private ConnectivityManager.NetworkCallback networkCallback;
private android.net.wifi.WifiManager.MulticastLock multicastLock;

Expand Down Expand Up @@ -144,6 +147,8 @@ private native void nativeInputState(long revision, long serial, String text,
private native void nativeOnInsets(int left, int top, int right, int bottom, int imeBottom);
private native void nativeOnBack(boolean enabled);
private native void nativeQrScanCompleted(long requestId, int status, String value);
private native void nativeImagePicked(long requestId, String name, String mime, byte[] bytes);
private native void nativeImagePickFinished(long requestId, int status, String error);
private native void nativeNetworkChanged();
private native void nativeScrollCaptureSearch(long request);
private native void nativeScrollCaptureStart();
Expand Down Expand Up @@ -418,9 +423,106 @@ public void gpuiStartCameraScan(long requestId) {
}
}

/**
* The system photo picker (Android 13+, no permission) or the document
* picker below it, limited to {@code limit} images. Results arrive through
* {@link #nativeImagePicked} per image and {@link #nativeImagePickFinished}
* once, in that order; a dismissed picker finishes with no images.
*/
public void gpuiPickImages(long requestId, int limit) {
if (imageRequest != 0) {
nativeImagePickFinished(requestId, HOST_ERROR, "图片选择正在进行");
return;
}
imageRequest = requestId;
imageLimit = Math.max(1, limit);
try {
Intent intent;
if (Build.VERSION.SDK_INT >= 33) {
intent = new Intent(android.provider.MediaStore.ACTION_PICK_IMAGES);
if (limit > 1) {
int max = Math.min(limit, android.provider.MediaStore.getPickImagesMaxLimit());
intent.putExtra(android.provider.MediaStore.EXTRA_PICK_IMAGES_MAX, max);
}
} else {
intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, limit > 1);
}
intent.setType("image/*");
startActivityForResult(intent, REQUEST_IMAGES);
} catch (RuntimeException error) {
imageRequest = 0;
nativeImagePickFinished(requestId, HOST_ERROR, errorMessage(error));
}
}

private void deliverPickedImages(long request, int resultCode, Intent data, int limit) {
java.util.ArrayList<Uri> uris = new java.util.ArrayList<>();
if (resultCode == Activity.RESULT_OK && data != null) {
ClipData clip = data.getClipData();
if (clip != null) {
for (int i = 0; i < clip.getItemCount() && uris.size() < limit; i++) {
Uri uri = clip.getItemAt(i).getUri();
if (uri != null) uris.add(uri);
}
} else if (data.getData() != null) {
uris.add(data.getData());
}
}
if (uris.isEmpty()) {
nativeImagePickFinished(request, HOST_CANCELLED, null);
return;
}
// Reading a content URI blocks; the picker's own thread is gone by now.
new Thread(() -> {
try {
for (Uri uri : uris) {
byte[] bytes = readAll(uri);
String mime = getContentResolver().getType(uri);
nativeImagePicked(request, displayName(uri), mime, bytes);
}
nativeImagePickFinished(request, HOST_OK, null);
} catch (Exception error) {
nativeImagePickFinished(request, HOST_ERROR, errorMessage(error));
}
}, "tcode-image-pick").start();
}

private byte[] readAll(Uri uri) throws java.io.IOException {
try (java.io.InputStream input = getContentResolver().openInputStream(uri)) {
if (input == null) throw new java.io.IOException("cannot open " + uri);
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
byte[] buffer = new byte[64 * 1024];
int read;
while ((read = input.read(buffer)) != -1) out.write(buffer, 0, read);
return out.toByteArray();
}
}

private String displayName(Uri uri) {
try (android.database.Cursor cursor = getContentResolver().query(
uri, new String[] {android.provider.OpenableColumns.DISPLAY_NAME}, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
String name = cursor.getString(0);
if (name != null && !name.isEmpty()) return name;
}
} catch (RuntimeException ignored) {
// Some providers refuse metadata queries; the segment below still names the file.
}
String segment = uri.getLastPathSegment();
return segment == null || segment.isEmpty() ? "image" : segment;
}

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == REQUEST_IMAGES) {
long request = imageRequest;
imageRequest = 0;
if (request != 0) deliverPickedImages(request, resultCode, data, imageLimit);
return;
}
if (requestCode != REQUEST_CAMERA) {
return;
}
Expand Down
106 changes: 104 additions & 2 deletions crates/android/src/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ use jni::{
objects::{JObject, JString, JValue},
refs::Global,
};
use tcode_client::host::HostFuture;
use tcode_client::host::{HostFuture, PickedImage};
use tcode_traverse::NativeClientHost;

const RESULT_OK: i32 = 0;
Expand All @@ -36,9 +36,25 @@ enum BridgeEvent {
status: i32,
value: Option<String>,
},
/// One image the picker returned; more may follow before `ImagesPicked`.
ImagePicked {
request_id: u64,
image: PickedImage,
},
ImagesPicked {
request_id: u64,
status: i32,
error: Option<String>,
},
NetworkChanged,
}

/// A picker request in flight: what arrived so far and who awaits it.
struct PickRequest {
images: Vec<PickedImage>,
done: async_channel::Sender<Result<Vec<PickedImage>, String>>,
}

#[derive(Clone)]
struct JniObject {
vm: JavaVM,
Expand Down Expand Up @@ -123,6 +139,27 @@ impl JavaBridge {
}));
}

fn pick_images(&self, request_id: u64, limit: usize) {
let object = self.object.clone();
self.app.run_on_java_main_thread(Box::new(move || {
if let Err(error) = object.with_env(|env, activity| {
env.call_method(
activity,
jni_str!("gpuiPickImages"),
jni_sig!("(JI)V"),
&[
JValue::Long(request_id as i64),
JValue::Int(i32::try_from(limit).unwrap_or(i32::MAX)),
],
)?;
Ok(())
}) {
log::error!("Android image picker JNI call failed: {error}");
deliver_images_picked(request_id, 2, Some(error));
}
}));
}

fn start_camera(&self, request_id: u64) {
let object = self.object.clone();
self.app.run_on_java_main_thread(Box::new(move || {
Expand Down Expand Up @@ -179,8 +216,11 @@ pub(crate) fn native_host(
async_channel::Sender<Result<String, String>>,
>::new()));
let pending = callbacks.clone();
let picks = Rc::new(RefCell::new(HashMap::<u64, PickRequest>::new()));
let pending_picks = picks.clone();
let multicast = bridge.object.clone();
let camera = bridge.clone();
let picker = bridge.clone();
let host = NativeClientHost::new(data_dir, device_name)
.with_platform(platform)
.with_multicast_lock(move |acquire| {
Expand All @@ -207,7 +247,27 @@ pub(crate) fn native_host(
.await
.unwrap_or_else(|error| Err(error.to_string()))
})
});
})
.with_image_picker(
move |limit| -> HostFuture<'static, Result<Vec<PickedImage>, String>> {
let (done, receiver) = async_channel::bounded(1);
let request_id = NEXT_REQUEST_ID.fetch_add(1, Ordering::Relaxed);
picks.borrow_mut().insert(
request_id,
PickRequest {
images: Vec::new(),
done,
},
);
picker.pick_images(request_id, limit);
Box::pin(async move {
receiver
.recv()
.await
.unwrap_or_else(|error| Err(error.to_string()))
})
},
);
let host = Rc::new(host);

let (sender, mut receiver) = mpsc::unbounded();
Expand All @@ -216,9 +276,33 @@ pub(crate) fn native_host(
cx.spawn(async move |cx| {
while let Some(event) = receiver.next().await {
let pending = pending.clone();
let picks = pending_picks.clone();
let host = events_host.clone();
cx.update(move |_cx| match event {
BridgeEvent::NetworkChanged => host.network_changed(),
BridgeEvent::ImagePicked { request_id, image } => {
if let Some(request) = picks.borrow_mut().get_mut(&request_id) {
request.images.push(image);
} else {
log::warn!("image for unknown Android picker request {request_id}");
}
}
BridgeEvent::ImagesPicked {
request_id,
status,
error,
} => {
let request = picks.borrow_mut().remove(&request_id);
let Some(request) = request else {
log::warn!("result for unknown Android picker request {request_id}");
return;
};
let result = match status {
RESULT_OK | RESULT_CANCELLED => Ok(request.images),
_ => Err(error.unwrap_or_else(|| "Android image picker failed".into())),
};
let _ = request.done.try_send(result);
}
BridgeEvent::CameraResult {
request_id,
status,
Expand Down Expand Up @@ -259,6 +343,24 @@ fn send_event(event: BridgeEvent, dropped: &str) {
}
}

pub(crate) fn deliver_image_picked(request_id: u64, image: PickedImage) {
send_event(
BridgeEvent::ImagePicked { request_id, image },
"picked image",
);
}

pub(crate) fn deliver_images_picked(request_id: u64, status: i32, error: Option<String>) {
send_event(
BridgeEvent::ImagesPicked {
request_id,
status,
error,
},
"image picker result",
);
}

pub(crate) fn deliver_result(request_id: u64, status: i32, value: Option<String>) {
send_event(
BridgeEvent::CameraResult {
Expand Down
64 changes: 55 additions & 9 deletions crates/android/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,22 @@ mod jni_exports {
use jni::{
EnvUnowned,
errors::LogErrorAndDefault,
objects::{JObject, JString},
objects::{JByteArray, JObject, JString},
sys::{jboolean, jint, jlong},
};

fn optional_string<'local>(
env: &mut jni::Env<'local>,
value: JObject<'local>,
) -> jni::errors::Result<Option<String>> {
if value.is_null() {
return Ok(None);
}
JString::cast_local(env, value)
.and_then(|value| value.try_to_string(env))
.map(Some)
}

#[unsafe(no_mangle)]
pub extern "system" fn Java_com_tryanks_tcode_GpuiActivity_nativeFirstFrameRendered(
_env: EnvUnowned,
Expand Down Expand Up @@ -296,6 +308,44 @@ mod jni_exports {
crate::host::network_changed();
}

#[unsafe(no_mangle)]
pub extern "system" fn Java_com_tryanks_tcode_GpuiActivity_nativeImagePicked<'local>(
mut env: EnvUnowned<'local>,
_activity: JObject<'local>,
request_id: jlong,
name: JObject<'local>,
mime: JObject<'local>,
bytes: JByteArray<'local>,
) {
env.with_env(|env| -> jni::errors::Result<()> {
let name = optional_string(env, name)?.unwrap_or_else(|| "image".into());
let mime = optional_string(env, mime)?.unwrap_or_else(|| "image/jpeg".into());
let bytes = env.convert_byte_array(&bytes)?;
crate::host::deliver_image_picked(
request_id as u64,
tcode_client::host::PickedImage { name, mime, bytes },
);
Ok(())
})
.resolve::<LogErrorAndDefault>()
}

#[unsafe(no_mangle)]
pub extern "system" fn Java_com_tryanks_tcode_GpuiActivity_nativeImagePickFinished<'local>(
mut env: EnvUnowned<'local>,
_activity: JObject<'local>,
request_id: jlong,
status: jint,
error: JObject<'local>,
) {
env.with_env(|env| -> jni::errors::Result<()> {
let error = optional_string(env, error)?;
crate::host::deliver_images_picked(request_id as u64, status, error);
Ok(())
})
.resolve::<LogErrorAndDefault>()
}

#[unsafe(no_mangle)]
pub extern "system" fn Java_com_tryanks_tcode_GpuiActivity_nativeQrScanCompleted<'local>(
mut env: EnvUnowned<'local>,
Expand All @@ -305,14 +355,10 @@ mod jni_exports {
value: JObject<'local>,
) {
env.with_env(|env| -> jni::errors::Result<()> {
let value = if value.is_null() {
None
} else {
JString::cast_local(env, value)
.and_then(|value| value.try_to_string(env))
.map_err(|error| log::error!("failed reading QR result: {error}"))
.ok()
};
let value = optional_string(env, value)
.map_err(|error| log::error!("failed reading QR result: {error}"))
.ok()
.flatten();
crate::host::deliver_result(request_id as u64, status, value);
Ok(())
})
Expand Down
Loading
Loading