diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 63a431a..0d5871c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -17,6 +17,7 @@ dependencies = [ "fern", "futures-util", "humantime", + "libc", "log", "ndarray", "prost", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8a19afb..e7ddc3c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -53,6 +53,8 @@ uuid = { version = "1", features = ["v4", "serde"] } rand = "0.8" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } ndarray = { version = "0.15", optional = true } +[target.'cfg(target_os = "android")'.dependencies] +libc = "0.2" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-updater = "2" diff --git a/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src-tauri/gen/android/app/src/main/AndroidManifest.xml index 99cfdae..951bf4c 100644 --- a/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,9 @@ + + + @@ -22,6 +25,13 @@ + + + + + = Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(UsbManager.EXTRA_DEVICE) + } + + val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false) + val invoke = pendingConnectInvoke + val targetDevice = pendingConnectDevice + + pendingConnectInvoke = null + pendingConnectDevice = null + + if (invoke == null || device == null) return + + if (!granted) { + invoke.reject("USB permission denied") + return + } + + if (targetDevice != null && device.deviceName == targetDevice.deviceName) { + openAndReturn(invoke, device) + } else { + invoke.reject("USB device mismatch") + } + } + } + } + + override fun load(webView: android.webkit.WebView) { + super.load(webView) + val filter = IntentFilter(ACTION_USB_PERMISSION) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + activity.applicationContext.registerReceiver( + usbPermissionReceiver, filter, Context.RECEIVER_NOT_EXPORTED + ) + } else { + activity.applicationContext.registerReceiver(usbPermissionReceiver, filter) + } + } + + override fun onDestroy() { + super.onDestroy() + try { + activity.applicationContext.unregisterReceiver(usbPermissionReceiver) + } catch (_: Exception) {} + } + + @Command + fun usb_serial_list(invoke: Invoke) { + val usbManager = activity.getSystemService(Context.USB_SERVICE) as? UsbManager + if (usbManager == null) { + invoke.reject("USB service not available") + return + } + + val devices = usbManager.deviceList + val result = JSObject() + + val serialDevices = mutableListOf() + for ((_, device) in devices) { + if (isUsbSerialDevice(device)) { + val obj = JSObject() + obj.put("name", device.deviceName) + obj.put("vendorId", device.vendorId) + obj.put("productId", device.productId) + obj.put("manufacturer", device.manufacturerName ?: "") + obj.put("product", device.productName ?: "") + obj.put("serial", device.serialNumber ?: "") + obj.put("hasPermission", usbManager.hasPermission(device)) + serialDevices.add(obj) + } + } + + result.put("devices", serialDevices.toTypedArray()) + invoke.resolve(result) + } + + @Command + fun usb_serial_open(invoke: Invoke) { + val args = invoke.parseArgs(JSObject::class.java) + val deviceName = args.optString("name", "") + + val usbManager = activity.getSystemService(Context.USB_SERVICE) as? UsbManager + if (usbManager == null) { + invoke.reject("USB service not available") + return + } + + val device = usbManager.deviceList[deviceName] + if (device == null) { + invoke.reject("USB device not found: $deviceName") + return + } + + if (!usbManager.hasPermission(device)) { + synchronized(this) { + pendingConnectInvoke = invoke + pendingConnectDevice = device + } + + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_MUTABLE + } else { + 0 + } + val permissionIntent = PendingIntent.getBroadcast( + activity, 0, Intent(ACTION_USB_PERMISSION), flags + ) + usbManager.requestPermission(device, permissionIntent) + return + } + + openAndReturn(invoke, device) + } + + @Command + fun usb_serial_close(invoke: Invoke) { + invoke.resolve(JSObject()) + } + + private fun openAndReturn(invoke: Invoke, device: UsbDevice) { + val usbManager = activity.getSystemService(Context.USB_SERVICE) as? UsbManager + if (usbManager == null) { + invoke.reject("USB service not available") + return + } + + val connection: UsbDeviceConnection = usbManager.openDevice(device) + ?: run { + invoke.reject("Failed to open USB device") + return + } + + var claimedInterface = false + for (i in 0 until device.interfaceCount) { + val iface = device.getInterface(i) + if (iface.endpointCount >= 2) { + connection.claimInterface(iface, true) + claimedInterface = true + break + } + } + + if (!claimedInterface) { + invoke.reject("No usable USB interface found") + return + } + + val fd = connection.fileDescriptor + val result = JSObject() + result.put("fd", fd) + result.put("name", device.deviceName) + result.put("vendorId", device.vendorId) + result.put("productId", device.productId) + invoke.resolve(result) + } + + private fun isUsbSerialDevice(device: UsbDevice): Boolean { + for (i in 0 until device.interfaceCount) { + val iface = device.getInterface(i) + val classId = iface.interfaceClass + if (classId == 0x02 || classId == 0xFF) { + if (iface.endpointCount >= 2) { + return true + } + } + } + + val knownVendors = setOf( + 0x1A86, // CH340/CH341 + 0x10C4, // CP210x + 0x0403, // FTDI + 0x067B, // PL2303 + 0x2341, // Arduino + 0x239A, // Adafruit + ) + if (device.vendorId in knownVendors) { + return true + } + + return false + } +} \ No newline at end of file diff --git a/src-tauri/gen/android/app/src/main/res/xml/usb_device_filter.xml b/src-tauri/gen/android/app/src/main/res/xml/usb_device_filter.xml new file mode 100644 index 0000000..b1ca837 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/xml/usb_device_filter.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src-tauri/plugins/usb-serial-plugin.json b/src-tauri/plugins/usb-serial-plugin.json new file mode 100644 index 0000000..ef8d4e2 --- /dev/null +++ b/src-tauri/plugins/usb-serial-plugin.json @@ -0,0 +1,9 @@ +{ + "plugins": { + "usb-serial": { + "android": { + "package": "com.lenn.tauri_serial.UsbSerialPlugin" + } + } + } +} \ No newline at end of file diff --git a/src-tauri/src/commands/serial.rs b/src-tauri/src/commands/serial.rs index 61980b5..736e0cb 100644 --- a/src-tauri/src/commands/serial.rs +++ b/src-tauri/src/commands/serial.rs @@ -246,6 +246,104 @@ pub async fn serial_disconnect( }) } +#[cfg(target_os = "android")] +#[tauri::command] +pub async fn serial_connect_fd( + app: AppHandle, + fd: i32, + device_name: String, + state: State<'_, SerialConnectionState>, +) -> Result { + if fd < 0 { + return Err(SerialError::InvalidConfig); + } + + { + let session = state.session.lock().map_err(|_| SerialError::StateError)?; + if session.is_some() { + return Err(SerialError::AlreadyConnected); + } + } + + let cancel = CancellationToken::new(); + let current_record = Arc::new(Mutex::new(TactileARecording::new())); + let task_record = current_record.clone(); + let task_cancel = cancel.clone(); + let task_app = app.clone(); + let task_port_name = device_name.clone(); + + let port = crate::serial_core::raw_fd_stream::RawFdStream::new(fd) + .map_err(|_| SerialError::OpenError)?; + let session_started_at = Instant::now(); + + let task = tauri::async_runtime::spawn(async move { + let codec = TactileACodec::new(DEFAULT_TACTILE_COLS, DEFAULT_TACTILE_ROWS); + let handler = TactileAHandler; + let poll_mode = PollMode::Enabled(Box::new(TactileAPollRequester::new( + Duration::from_millis(DEFAULT_TACTILE_POLL_INTERVAL_MS), + DEFAULT_TACTILE_COLS, + DEFAULT_TACTILE_ROWS, + Duration::from_millis(DEFAULT_TACTILE_REPLY_TIMEOUT_MS), + ))); + + if let Err(error) = serial::run_serial_with_poll( + task_app.clone(), + port, + codec, + handler, + session_started_at, + task_record.clone(), + task_cancel, + poll_mode, + ) + .await + { + eprintln!("serial task exited with error: {error}"); + } + + let manager = task_app.state::(); + if let Ok(mut last_record) = manager.last_record.lock() { + *last_record = Some(task_record); + } + + let mut session = match manager.session.lock() { + Ok(session) => session, + Err(_) => return, + }; + + { + let should_clear = session + .as_ref() + .map(|current| current.port.as_str() == task_port_name.as_str()) + .unwrap_or(false); + + if should_clear { + session.take(); + } + } + }); + + let mut session = state.session.lock().map_err(|_| SerialError::StateError)?; + if session.is_some() { + cancel.cancel(); + task.abort(); + return Err(SerialError::AlreadyConnected); + } + + *session = Some(SerialSession { + port: device_name.clone(), + cancel, + task, + current_record, + }); + + Ok(SerialConnectResponse { + port: device_name, + connected: true, + message: "connected".to_string(), + }) +} + #[tauri::command] pub fn serial_export_csv( app: AppHandle, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 848645a..386d766 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -135,7 +135,7 @@ pub fn run() { Ok(()) }); - #[cfg(feature = "devkit")] + #[cfg(all(feature = "devkit", not(target_os = "android")))] let builder = builder.invoke_handler(tauri::generate_handler![ commands::file_explorer::file_explorer_list, commands::serial::serial_enum, @@ -157,7 +157,7 @@ pub fn run() { commands::devkit::devkit_process_export ]); - #[cfg(not(feature = "devkit"))] + #[cfg(all(not(feature = "devkit"), not(target_os = "android")))] let builder = builder.invoke_handler(tauri::generate_handler![ commands::file_explorer::file_explorer_list, commands::serial::serial_enum, @@ -173,6 +173,46 @@ pub fn run() { commands::window::win_close ]); + #[cfg(all(feature = "devkit", target_os = "android"))] + let builder = builder.invoke_handler(tauri::generate_handler![ + commands::file_explorer::file_explorer_list, + commands::serial::serial_enum, + commands::serial::serial_connect, + commands::serial::serial_connect_fd, + commands::serial::serial_disconnect, + commands::serial::serial_export_csv, + commands::serial::serial_has_record_data, + commands::serial::serial_export_csv_to_path, + commands::serial::serial_import_csv, + commands::serial::serial_import_csv_from_path, + commands::window::win_minimize, + commands::window::win_toggle_maximize, + commands::window::win_close, + commands::devkit::devkit_status, + commands::devkit::devkit_start, + commands::devkit::devkit_stop, + commands::devkit::devkit_get_config, + commands::devkit::devkit_set_config, + commands::devkit::devkit_process_export + ]); + + #[cfg(all(not(feature = "devkit"), target_os = "android"))] + let builder = builder.invoke_handler(tauri::generate_handler![ + commands::file_explorer::file_explorer_list, + commands::serial::serial_enum, + commands::serial::serial_connect, + commands::serial::serial_connect_fd, + commands::serial::serial_disconnect, + commands::serial::serial_export_csv, + commands::serial::serial_has_record_data, + commands::serial::serial_export_csv_to_path, + commands::serial::serial_import_csv, + commands::serial::serial_import_csv_from_path, + commands::window::win_minimize, + commands::window::win_toggle_maximize, + commands::window::win_close + ]); + builder .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/serial_core/mod.rs b/src-tauri/src/serial_core/mod.rs index 3a3e73c..374f00b 100644 --- a/src-tauri/src/serial_core/mod.rs +++ b/src-tauri/src/serial_core/mod.rs @@ -11,6 +11,8 @@ pub mod model; pub mod serial; pub mod record; pub mod utils; +#[cfg(target_os = "android")] +pub mod raw_fd_stream; #[cfg(feature = "multi-dim")] pub mod multi_dim_force; diff --git a/src-tauri/src/serial_core/raw_fd_stream.rs b/src-tauri/src/serial_core/raw_fd_stream.rs new file mode 100644 index 0000000..ff902d9 --- /dev/null +++ b/src-tauri/src/serial_core/raw_fd_stream.rs @@ -0,0 +1,126 @@ +use std::io; +use std::os::unix::io::RawFd; +use tokio::io::unix::AsyncFd; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; + +/// A wrapper around a raw file descriptor that implements AsyncRead + AsyncWrite. +/// Uses tokio's AsyncFd to properly integrate with the async reactor. +/// Used on Android to wrap USB device file descriptors obtained from the USB Host API. +pub struct RawFdStream { + inner: AsyncFd, +} + +impl RawFdStream { + pub fn new(fd: RawFd) -> io::Result { + if fd < 0 { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid fd")); + } + + // Set non-blocking + unsafe { + let flags = libc::fcntl(fd, libc::F_GETFL); + if flags < 0 { + return Err(io::Error::last_os_error()); + } + if libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) < 0 { + return Err(io::Error::last_os_error()); + } + } + + let inner = AsyncFd::new(fd)?; + Ok(Self { inner }) + } +} + +impl Drop for RawFdStream { + fn drop(&mut self) { + // We don't close the fd here - it's managed by the UsbDeviceConnection in Kotlin. + // The Kotlin side is responsible for closing. + } +} + +impl AsyncRead for RawFdStream { + fn poll_read( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> std::task::Poll> { + loop { + let mut guard = match self.inner.poll_read_ready(cx) { + std::task::Poll::Ready(Ok(guard)) => guard, + std::task::Poll::Ready(Err(e)) => return std::task::Poll::Ready(Err(e)), + std::task::Poll::Pending => return std::task::Poll::Pending, + }; + + let unfilled = buf.initialize_unfilled(); + let result = unsafe { + libc::read( + *self.inner.get_ref(), + unfilled.as_mut_ptr() as *mut libc::c_void, + unfilled.len(), + ) + }; + + if result < 0 { + let err = io::Error::last_os_error(); + if err.kind() == io::ErrorKind::WouldBlock { + guard.clear_ready(); + continue; + } + return std::task::Poll::Ready(Err(err)); + } + + buf.advance(result as usize); + return std::task::Poll::Ready(Ok(())); + } + } +} + +impl AsyncWrite for RawFdStream { + fn poll_write( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> std::task::Poll> { + loop { + let mut guard = match self.inner.poll_write_ready(cx) { + std::task::Poll::Ready(Ok(guard)) => guard, + std::task::Poll::Ready(Err(e)) => return std::task::Poll::Ready(Err(e)), + std::task::Poll::Pending => return std::task::Poll::Pending, + }; + + let result = unsafe { + libc::write( + *self.inner.get_ref(), + buf.as_ptr() as *const libc::c_void, + buf.len(), + ) + }; + + if result < 0 { + let err = io::Error::last_os_error(); + if err.kind() == io::ErrorKind::WouldBlock { + guard.clear_ready(); + continue; + } + return std::task::Poll::Ready(Err(err)); + } + + return std::task::Poll::Ready(Ok(result as usize)); + } + } + + fn poll_flush( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } + + fn poll_shutdown( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } +} \ No newline at end of file diff --git a/src-tauri/src/serial_core/serial.rs b/src-tauri/src/serial_core/serial.rs index fca733d..6a24686 100644 --- a/src-tauri/src/serial_core/serial.rs +++ b/src-tauri/src/serial_core/serial.rs @@ -18,8 +18,9 @@ use std::time::Instant; use tauri::{AppHandle, Emitter}; #[cfg(feature = "devkit")] use tauri::Manager; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tokio::time::{self, Duration, MissedTickBehavior}; +#[cfg(not(target_os = "android"))] use tokio_serial::SerialStream; use tokio_util::sync::CancellationToken; @@ -171,6 +172,7 @@ impl PollRequester for TactileAPollRequester { } } +#[cfg(not(target_os = "android"))] pub async fn run_serial( app: AppHandle, port: SerialStream, @@ -201,7 +203,7 @@ where pub async fn run_serial_with_poll( app: AppHandle, - mut port: SerialStream, + mut port: impl AsyncRead + AsyncWrite + Unpin, mut codec: C, mut handler: H, session_started_at: Instant, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index fb0bbfe..56c1eaa 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -45,15 +45,5 @@ "resources/je-skin-devkit-server.exe" ] }, - "plugins": { - "updater": { - "windows": { - "installMode": "passive" - }, - "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDkwODM1QkFEODI2NkZENgpSV1RXYnliWXVqVUlDUVRxbjlseDNDNjhQTGpDYis4TEZMeUk2WVhiMEhTRWJhN3hGRnQ3TTJtcwo=", - "endpoints": [ - "https://je-skin.cn-nb1.rains3.com/latest.json" - ] - } - } + "plugins": {} }