diff --git a/Cargo.lock b/Cargo.lock index 301834f3..71d8ffe5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2348,7 +2348,7 @@ dependencies = [ [[package]] name = "localdesktop" -version = "2.1.0" +version = "2.1.1" dependencies = [ "android-sdkmanager-rs", "android_logger", diff --git a/Cargo.toml b/Cargo.toml index de1ff397..4cfa4779 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "localdesktop" -version = "2.1.0" +version = "2.1.1" edition = "2021" build = "build.rs" default-run = "build_apk" diff --git a/docs/architecture.md b/docs/architecture.md index d008bf36..9d614e2f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -80,7 +80,7 @@ The backend struct is the compositor's live state — the smithay `Compositor`, -`configure_output` reconciles the compositor with the physical Android window every time the surface changes: it sets the compositor size, creates or updates the smithay `Output` (mode, transform, fractional scale), writes the host geometry to a file the in-chroot `wlr-randr` script watches, and resizes any existing toplevel windows to fill the screen. +`configure_output` reconciles the compositor with the physical Android window every time the surface changes: it sets the compositor size, creates or updates the smithay `Output` (mode and transform, with the parent output scale fixed at 1 because rendering uses physical pixels), writes the host geometry and guest UI scale to a file the in-chroot `wlr-randr` script watches, and resizes any existing toplevel windows to fill the screen. diff --git a/gh-pages/docs/developer/9-offline-documentation.md b/gh-pages/docs/developer/9-offline-documentation.md index dc2a66ff..e23ce2ce 100644 --- a/gh-pages/docs/developer/9-offline-documentation.md +++ b/gh-pages/docs/developer/9-offline-documentation.md @@ -43,7 +43,8 @@ cargo run --bin build_docs -- user phone dark # user manual, dark, phone pag The User Manual ships as release artifacts in **6 variants** — every size (desktop/fold/phone) × theme (light/dark) — attached alongside the APK/AAB as `Local-Desktop-v-User-Manual[-Fold|-Phone][-Dark].pdf`. The app also -pre-downloads the matching desktop/light manual onto the Linux desktop during setup. +pre-downloads the matching desktop/light manual onto the Linux desktop as +`Local Desktop - User Manual.pdf`. The Developer Manual's architecture part comes in two modes: diff --git a/gh-pages/docs/user/app-compatibility/visual-studio-code.md b/gh-pages/docs/user/app-compatibility/visual-studio-code.md index b0539351..2fe22941 100644 --- a/gh-pages/docs/user/app-compatibility/visual-studio-code.md +++ b/gh-pages/docs/user/app-compatibility/visual-studio-code.md @@ -2,23 +2,13 @@ title: Visual Studio Code --- -:::info - -Visual Studio Code must be launched from the command line as follows: - -```bash -code --no-sandbox -``` - -::: - ![Visual Studio Code on Local Desktop](/img/vscode.webp) You can install it from the [AUR](https://aur.archlinux.org/). See [How to install applications?](/docs/user/getting-started#how-to-install-applications) for instructions. ## Compatibility note -- VS Code **won't** launch from the XFCE Application launcher, you have to launch it from the terminal with an additional flag `--no-sandbox`. **It is an issue that persists with Termux + proot-distro.** +- Chromium's sandbox needs Linux user namespaces, which Android does not allow, so VS Code has to run with `--no-sandbox`. Local Desktop applies that for you: `ELECTRON_DISABLE_SANDBOX` is exported for the whole desktop session, and application entries for Chromium-based apps are shadowed with `--no-sandbox` copies in `~/.local/share/applications`. VS Code launches from the XFCE Application launcher and from the terminal with no extra flags. **Termux + proot-distro still requires the flag by hand.** - Launching as a root user requires the `--user-data-dir` flag. [Why?](https://stackoverflow.com/a/70453798) diff --git a/src/android/app/run.rs b/src/android/app/run.rs index ad1e3f47..49005264 100644 --- a/src/android/app/run.rs +++ b/src/android/app/run.rs @@ -12,7 +12,10 @@ use crate::android::{ webview::ErrorVariant, }, proot::launch::launch, - utils::{ndk::run_in_jvm, webview::show_webview_popup}, + utils::{ + ndk::{self, run_in_jvm}, + webview::show_webview_popup, + }, }; use crate::core::config; use smithay::output::{Mode, Output, PhysicalProperties, Scale, Subpixel}; @@ -30,8 +33,11 @@ fn configure_output(backend: &mut crate::android::backend::wayland::WaylandBacke }; let window_size = winit.window_size(); - let scale_factor = winit.scale_factor(); let size = (window_size.w, window_size.h); + // Not `winit.scale_factor()`: that reads `AConfiguration`, which still reports the 160 dpi + // default on the first launch and only becomes accurate after a configuration change. + let guest_scale_factor = ndk::scale_factor(&backend.android_app); + backend.guest_scale_factor = guest_scale_factor; backend.compositor.state.size = size.into(); let output = backend @@ -61,11 +67,11 @@ fn configure_output(backend: &mut crate::android::backend::wayland::WaylandBacke refresh: 60000, }), Some(Transform::Normal), - Some(Scale::Fractional(scale_factor)), + Some(Scale::Integer(1)), Some((0, 0).into()), ); - let guest_scale = scale_factor.round().max(1.0) as i32; + let guest_scale = guest_scale_factor.round().max(1.0) as i32; write_guest_output_state(window_size.w, window_size.h, guest_scale); for surface in backend.compositor.state.xdg_shell_state.toplevel_surfaces() { @@ -180,10 +186,7 @@ impl ApplicationHandler for PolarBearApp { if let PolarBearBackend::Wayland(backend) = &mut self.backend { backend.graphic_renderer = None; backend.key_counter = 0; - backend.touch_points.clear(); - backend.scroll_centroid = None; - backend.touch_gesture_was_multi_touch = false; - backend.touch_down_position = None; + backend.reset_touch_state(); backend.pointer_pressed = false; // Kill the standalone-client PipeWire/AAudio backend if it was started. pipewire_standalone_aaudio::shutdown(); diff --git a/src/android/backend/wayland/event_centralizer.rs b/src/android/backend/wayland/event_centralizer.rs index c11b5ba3..44411472 100644 --- a/src/android/backend/wayland/event_centralizer.rs +++ b/src/android/backend/wayland/event_centralizer.rs @@ -5,8 +5,9 @@ use crate::android::backend::wayland::{ WinitTouchMovedEvent, WinitTouchStartedEvent, }, keymap::physicalkey_to_scancode, - WaylandBackend, + TouchMode, WaylandBackend, }; +use crate::android::utils::ndk; use smithay::backend::input::InputEvent; use smithay::utils::{Physical, Size}; use winit::dpi::PhysicalPosition; @@ -19,8 +20,7 @@ pub enum CentralizedEvent { Resized { /// The new physical size (in pixels) size: Size, - /// The new scale factor - scale_factor: f64, + guest_scale_factor: f64, }, /// The focus state of the window changed @@ -77,17 +77,15 @@ pub fn centralize(event: WindowEvent, backend: &mut WaylandBackend) -> Centraliz return match event { WindowEvent::Resized(size) => { let (w, h): (i32, i32) = size.into(); + backend.guest_scale_factor = ndk::scale_factor(&backend.android_app); CentralizedEvent::Resized { size: (w, h).into(), - scale_factor: backend.scale_factor, + guest_scale_factor: backend.guest_scale_factor, } } - WindowEvent::ScaleFactorChanged { - scale_factor: new_scale_factor, - .. - } => { - backend.scale_factor = new_scale_factor; + WindowEvent::ScaleFactorChanged { .. } => { + backend.guest_scale_factor = ndk::scale_factor(&backend.android_app); let (w, h): (i32, i32) = backend .graphic_renderer .as_ref() @@ -97,7 +95,7 @@ pub fn centralize(event: WindowEvent, backend: &mut WaylandBackend) -> Centraliz .into(); CentralizedEvent::Resized { size: (w, h).into(), - scale_factor: backend.scale_factor, + guest_scale_factor: backend.guest_scale_factor, } } WindowEvent::RedrawRequested => CentralizedEvent::Redraw, @@ -112,18 +110,10 @@ pub fn centralize(event: WindowEvent, backend: &mut WaylandBackend) -> Centraliz centralize_keyboard(scancode, event.state, time, backend) } WindowEvent::CursorMoved { position, .. } => { - let size = backend - .graphic_renderer - .as_ref() - .unwrap() - .window() - .inner_size(); - let x = position.x / size.width as f64; - let y = position.y / size.height as f64; let event = InputEvent::PointerMotionAbsolute { event: WinitMouseMovedEvent { time, - position: RelativePosition::new(x, y), + position: relative_position(backend, position), global_position: position, }, }; @@ -153,35 +143,25 @@ pub fn centralize(event: WindowEvent, backend: &mut WaylandBackend) -> Centraliz .. }) => { backend.touch_points.insert(id, location); + backend.scroll_centroid = Some(centroid(&backend.touch_points)); + if backend.touch_points.len() >= 2 { - // Second finger down: transition to two-finger scroll mode. - // Initialize the centroid so the first move has a reference point. - backend.touch_gesture_was_multi_touch = true; - backend.scroll_centroid = Some(centroid(&backend.touch_points)); - CentralizedEvent::Unsupported - } else if backend.touch_gesture_was_multi_touch { - // A finger landed again during the tail of a two-finger gesture; keep ignoring - // single-finger handling until every finger has lifted. - CentralizedEvent::Unsupported - } else { - backend.touch_down_position = Some(location); - let size = backend - .graphic_renderer - .as_ref() - .unwrap() - .window() - .inner_size(); - let x = location.x / size.width as f64; - let y = location.y / size.height as f64; - CentralizedEvent::Input(InputEvent::TouchDown { - event: WinitTouchStartedEvent { - time, - global_position: location, - position: RelativePosition::new(x, y), - id, - }, - }) + // A second finger is unambiguously a scroll, whatever the first one was doing. + backend.touch_mode = TouchMode::Scroll; + return CentralizedEvent::Unsupported; } + + backend.touch_mode = TouchMode::Undecided; + backend.touch_down_position = Some(location); + backend.touch_down_time = Some(time); + CentralizedEvent::Input(InputEvent::TouchDown { + event: WinitTouchStartedEvent { + time, + global_position: location, + position: relative_position(backend, location), + id, + }, + }) } WindowEvent::Touch(Touch { phase: TouchPhase::Moved, @@ -190,50 +170,53 @@ pub fn centralize(event: WindowEvent, backend: &mut WaylandBackend) -> Centraliz .. }) => { backend.touch_points.insert(id, location); - if backend.touch_points.len() >= 2 { - // Two-finger scroll: emit a PointerAxis event based on centroid delta. - backend.touch_gesture_was_multi_touch = true; - let new_centroid = centroid(&backend.touch_points); - if let Some(last) = backend.scroll_centroid { + + if backend.touch_mode == TouchMode::Undecided && travelled_past_slop(backend, location) + { + backend.touch_mode = TouchMode::Scroll; + // Start scrolling from here, so the slop the finger just used up doesn't + // arrive as one jump. + backend.scroll_centroid = Some(centroid(&backend.touch_points)); + } + if backend.touch_mode == TouchMode::LongPress && travelled_past_slop(backend, location) + { + backend.touch_mode = TouchMode::Drag; + } + + match backend.touch_mode { + // Still deciding between a tap, a scroll and a long press: don't move the + // cursor yet, or a scroll would drag it along. + TouchMode::Undecided | TouchMode::LongPress => CentralizedEvent::Unsupported, + TouchMode::Scroll => { + // Scroll by the centroid delta, which is the finger itself when only one + // is down. Positive axis values scroll the view down, so the raw delta + // (negated once more in `WinitMouseWheelEvent::amount`) makes the content + // follow the finger the way Android does. + let new_centroid = centroid(&backend.touch_points); + let last = backend.scroll_centroid.replace(new_centroid); + let Some(last) = last else { + return CentralizedEvent::Unsupported; + }; let dx = new_centroid.x - last.x; let dy = new_centroid.y - last.y; - backend.scroll_centroid = Some(new_centroid); - if dx != 0.0 || dy != 0.0 { - return CentralizedEvent::Input(InputEvent::PointerAxis { - event: WinitMouseWheelEvent { - time, - delta: MouseScrollDelta::PixelDelta(PhysicalPosition { - x: -dx, - y: -dy, - }), - }, - }); + if dx == 0.0 && dy == 0.0 { + return CentralizedEvent::Unsupported; } - } else { - backend.scroll_centroid = Some(new_centroid); + CentralizedEvent::Input(InputEvent::PointerAxis { + event: WinitMouseWheelEvent { + time, + delta: MouseScrollDelta::PixelDelta(PhysicalPosition { x: dx, y: dy }), + }, + }) } - CentralizedEvent::Unsupported - } else if backend.touch_gesture_was_multi_touch { - // Leftover finger drifting after a two-finger scroll: ignore it so it neither - // moves the cursor nor starts a drag that would select text. - CentralizedEvent::Unsupported - } else { - let size = backend - .graphic_renderer - .as_ref() - .unwrap() - .window() - .inner_size(); - let x = location.x / size.width as f64; - let y = location.y / size.height as f64; - CentralizedEvent::Input(InputEvent::TouchMotion { + TouchMode::Drag => CentralizedEvent::Input(InputEvent::TouchMotion { event: WinitTouchMovedEvent { time, - position: RelativePosition::new(x, y), + position: relative_position(backend, location), global_position: location, id, }, - }) + }), } } @@ -243,56 +226,41 @@ pub fn centralize(event: WindowEvent, backend: &mut WaylandBackend) -> Centraliz id, .. }) => { - let was_multi_touch = backend.touch_points.len() >= 2; backend.touch_points.remove(&id); - backend.scroll_centroid = if backend.touch_points.len() >= 2 { - Some(centroid(&backend.touch_points)) - } else { - None - }; - if was_multi_touch { - backend.touch_gesture_was_multi_touch = true; - } - if backend.touch_points.is_empty() { - let emit_click = !backend.touch_gesture_was_multi_touch; - backend.touch_gesture_was_multi_touch = false; - backend.touch_down_position = None; - CentralizedEvent::Input(InputEvent::TouchUp { - event: WinitTouchEndedEvent { - time, - id, - emit_click, - x: location.x, - y: location.y, - }, - }) - } else { - // Don't forward a stray TouchUp while other fingers are still down. - CentralizedEvent::Unsupported + if !backend.touch_points.is_empty() { + // Don't forward a stray TouchUp while other fingers are still down; re-anchor + // the centroid so the remaining fingers don't jump the scroll. + backend.scroll_centroid = Some(centroid(&backend.touch_points)); + return CentralizedEvent::Unsupported; } + + let mode = backend.touch_mode; + backend.reset_touch_state(); + CentralizedEvent::Input(InputEvent::TouchUp { + event: WinitTouchEndedEvent { + time, + id, + mode, + x: location.x, + y: location.y, + }, + }) } WindowEvent::Touch(Touch { phase: TouchPhase::Cancelled, id, .. }) => { - let was_multi_touch = backend.touch_points.len() >= 2; backend.touch_points.remove(&id); - backend.scroll_centroid = None; - if was_multi_touch { - backend.touch_gesture_was_multi_touch = true; - } - if backend.touch_points.is_empty() { - backend.touch_gesture_was_multi_touch = false; - backend.touch_down_position = None; - } - if was_multi_touch { - CentralizedEvent::Unsupported - } else { - CentralizedEvent::Input(InputEvent::TouchCancel { - event: WinitTouchCancelledEvent { time, id }, - }) + if !backend.touch_points.is_empty() { + backend.scroll_centroid = Some(centroid(&backend.touch_points)); + return CentralizedEvent::Unsupported; } + + backend.reset_touch_state(); + CentralizedEvent::Input(InputEvent::TouchCancel { + event: WinitTouchCancelledEvent { time, id }, + }) } _ => { @@ -302,6 +270,35 @@ pub fn centralize(event: WindowEvent, backend: &mut WaylandBackend) -> Centraliz }; } +/// Normalize a window-relative pixel position into the 0..1 range the input backend expects. +fn relative_position( + backend: &WaylandBackend, + location: PhysicalPosition, +) -> RelativePosition { + let size = backend + .graphic_renderer + .as_ref() + .unwrap() + .window() + .inner_size(); + RelativePosition::new( + location.x / size.width as f64, + location.y / size.height as f64, + ) +} + +/// Whether the finger has moved far enough from where it landed to stop being a tap. +fn travelled_past_slop(backend: &WaylandBackend, location: PhysicalPosition) -> bool { + backend + .touch_down_position + .map(|start| { + let dx = location.x - start.x; + let dy = location.y - start.y; + dx * dx + dy * dy > backend.touch_slop_px * backend.touch_slop_px + }) + .unwrap_or(false) +} + fn centroid( points: &std::collections::HashMap>, ) -> PhysicalPosition { diff --git a/src/android/backend/wayland/event_handler.rs b/src/android/backend/wayland/event_handler.rs index 718ef73a..38290178 100644 --- a/src/android/backend/wayland/event_handler.rs +++ b/src/android/backend/wayland/event_handler.rs @@ -2,7 +2,7 @@ use crate::android::{ accessibility, backend::wayland::{ compositor::{send_frames_surface_tree, ClientState, State}, - write_guest_output_state, CentralizedEvent, WaylandBackend, + write_guest_output_state, CentralizedEvent, TouchMode, WaylandBackend, }, }; use smithay::backend::input::ButtonState; @@ -30,8 +30,8 @@ use winit::event_loop::{ActiveEventLoop, ControlFlow}; /// Linux input event code for the left mouse button (`BTN_LEFT`). const BTN_LEFT: u32 = 0x110; -/// How far a finger must travel before a touch becomes a drag (press-and-hold) rather than a tap. -const TAP_DRAG_THRESHOLD_PX: f64 = 25.0; +/// Linux input event code for the right mouse button (`BTN_RIGHT`). +const BTN_RIGHT: u32 = 0x111; /** * As we currently use Xwayland, there is only 1 surface @@ -77,8 +77,12 @@ fn emit_pointer_motion( } } -/// Press the left button. Also moves keyboard focus to the surface under the pointer. -fn emit_pointer_press(compositor: &mut crate::android::backend::wayland::Compositor, time: u32) { +/// Press a button. Also moves keyboard focus to the surface under the pointer. +fn emit_pointer_press( + compositor: &mut crate::android::backend::wayland::Compositor, + button: u32, + time: u32, +) { let pointer = compositor.pointer.clone(); let state = &mut compositor.state; if let Some(surface) = get_surface(state) { @@ -93,7 +97,7 @@ fn emit_pointer_press(compositor: &mut crate::android::backend::wayland::Composi pointer.button( state, &pointer::ButtonEvent { - button: BTN_LEFT, + button, state: ButtonState::Pressed, serial, time, @@ -102,15 +106,19 @@ fn emit_pointer_press(compositor: &mut crate::android::backend::wayland::Composi pointer.frame(state); } -/// Release the left button. -fn emit_pointer_release(compositor: &mut crate::android::backend::wayland::Compositor, time: u32) { +/// Release a button. +fn emit_pointer_release( + compositor: &mut crate::android::backend::wayland::Compositor, + button: u32, + time: u32, +) { let pointer = compositor.pointer.clone(); let state = &mut compositor.state; let serial = SERIAL_COUNTER.next_serial(); pointer.button( state, &pointer::ButtonEvent { - button: BTN_LEFT, + button, state: ButtonState::Released, serial, time, @@ -122,13 +130,42 @@ fn emit_pointer_release(compositor: &mut crate::android::backend::wayland::Compo /// A full tap: move to the location, then a press immediately followed by a release. fn emit_pointer_click( compositor: &mut crate::android::backend::wayland::Compositor, + button: u32, x: f64, y: f64, time: u32, ) { emit_pointer_motion(compositor, x, y, time); - emit_pointer_press(compositor, time); - emit_pointer_release(compositor, time); + emit_pointer_press(compositor, button, time); + emit_pointer_release(compositor, button, time); +} + +/// Arm the long press once the finger has stayed put for `ViewConfiguration`'s timeout. +/// +/// No button is sent here: moving afterwards starts a drag with the left button held, lifting +/// instead fires a right click. Called from the redraw loop, which already ticks every frame. +fn poll_long_press(backend: &mut WaylandBackend) { + if backend.touch_mode != TouchMode::Undecided || backend.touch_points.len() != 1 { + return; + } + let (Some(down_time), Some(down_position)) = + (backend.touch_down_time, backend.touch_down_position) + else { + return; + }; + let now = backend.clock.now().as_millis() as u64; + if now.saturating_sub(down_time) < backend.long_press_timeout_ms { + return; + } + + backend.touch_mode = TouchMode::LongPress; + // Anchor the pointer where the finger landed, so a drag selects from there. + emit_pointer_motion( + &mut backend.compositor, + down_position.x, + down_position.y, + now as u32, + ); } pub fn handle(event: CentralizedEvent, backend: &mut WaylandBackend, event_loop: &ActiveEventLoop) { @@ -137,6 +174,8 @@ pub fn handle(event: CentralizedEvent, backend: &mut WaylandBackend, event_loop: event_loop.exit(); } CentralizedEvent::Redraw => { + poll_long_press(backend); + if let Err(error) = redraw(backend) { log::error!("Redraw failed; dropping renderer until next resume: {error}"); backend.graphic_renderer = None; @@ -181,9 +220,8 @@ pub fn handle(event: CentralizedEvent, backend: &mut WaylandBackend, event_loop: ); } InputEvent::TouchDown { event } => { - // Just move the cursor. Defer the button press until the finger moves - // (a drag) or lifts (a tap), so a second finger landing for a scroll - // doesn't leave a stray press held down. + // Just move the cursor. Which button (if any) this gesture sends is only known + // once the finger moves, lifts, or sits still long enough to be a long press. emit_pointer_motion( &mut backend.compositor, event.x(), @@ -193,50 +231,50 @@ pub fn handle(event: CentralizedEvent, backend: &mut WaylandBackend, event_loop: } InputEvent::TouchMotion { event } => { let time = event.time_msec(); - let (x, y) = (event.x(), event.y()); - // Once the finger travels past the threshold, press the button so the - // motion that follows reads as a drag. The centralizer only emits this - // event for a genuine single-finger gesture (never the leftover finger of - // a two-finger scroll), so this can't be mistaken for a scroll. + // The centralizer only emits motion in Drag mode, and flips into it on the + // first move after a long press — that transition is where the grab starts. if !backend.pointer_pressed { - let start = backend.touch_down_position; - let far_enough = start - .map(|s| { - let dx = s.x - x; - let dy = s.y - y; - dx * dx + dy * dy > TAP_DRAG_THRESHOLD_PX * TAP_DRAG_THRESHOLD_PX - }) - .unwrap_or(false); - if far_enough { - // Anchor the drag at where the finger first landed so the grab / - // selection starts there, not where we crossed the threshold. - if let Some(s) = start { - emit_pointer_motion(&mut backend.compositor, s.x, s.y, time); - } - emit_pointer_press(&mut backend.compositor, time); - backend.pointer_pressed = true; - } + emit_pointer_press(&mut backend.compositor, BTN_LEFT, time); + backend.pointer_pressed = true; } - emit_pointer_motion(&mut backend.compositor, x, y, time); + emit_pointer_motion(&mut backend.compositor, event.x(), event.y(), time); } InputEvent::TouchUp { event } => { let time = event.time_msec(); - emit_pointer_motion(&mut backend.compositor, event.x, event.y, time); if backend.pointer_pressed { // End of a drag. - emit_pointer_release(&mut backend.compositor, time); + emit_pointer_motion(&mut backend.compositor, event.x, event.y, time); + emit_pointer_release(&mut backend.compositor, BTN_LEFT, time); backend.pointer_pressed = false; - } else if event.emit_click { - // A tap that never became a drag → synthesize a click. - emit_pointer_click(&mut backend.compositor, event.x, event.y, time); + } else { + match event.mode { + // A tap: left click where the finger lifted. + TouchMode::Undecided => emit_pointer_click( + &mut backend.compositor, + BTN_LEFT, + event.x, + event.y, + time, + ), + // Held still, then lifted without moving: a context menu, as on Android. + TouchMode::LongPress => emit_pointer_click( + &mut backend.compositor, + BTN_RIGHT, + event.x, + event.y, + time, + ), + // A scroll consumed the gesture; nothing to click. + TouchMode::Scroll | TouchMode::Drag => {} + } } } InputEvent::TouchCancel { event } => { if backend.pointer_pressed { - emit_pointer_release(&mut backend.compositor, event.time() as u32); + emit_pointer_release(&mut backend.compositor, BTN_LEFT, event.time() as u32); backend.pointer_pressed = false; } } @@ -286,10 +324,10 @@ pub fn handle(event: CentralizedEvent, backend: &mut WaylandBackend, event_loop: pointer.frame(&mut compositor.state); } InputEvent::PointerAxis { event } => { - // A scroll means a second finger landed; drop any button the first - // finger may have pressed so we don't scroll with it held. + // A second finger can turn an in-progress drag into a scroll; drop the button + // the drag was holding rather than scrolling with it down. if backend.pointer_pressed { - emit_pointer_release(&mut backend.compositor, event.time_msec()); + emit_pointer_release(&mut backend.compositor, BTN_LEFT, event.time_msec()); backend.pointer_pressed = false; } let horizontal_amount = event @@ -338,7 +376,10 @@ pub fn handle(event: CentralizedEvent, backend: &mut WaylandBackend, event_loop: } _ => {} }, - CentralizedEvent::Resized { size, scale_factor } => { + CentralizedEvent::Resized { + size, + guest_scale_factor, + } => { backend.compositor.state.size = (size.w, size.h).into(); if let Some(output) = &backend.compositor.output { @@ -348,12 +389,12 @@ pub fn handle(event: CentralizedEvent, backend: &mut WaylandBackend, event_loop: refresh: 60000, }), Some(Transform::Normal), - Some(Scale::Fractional(scale_factor)), + Some(Scale::Integer(1)), Some((0, 0).into()), ); } - let guest_scale = scale_factor.round().max(1.0) as i32; + let guest_scale = guest_scale_factor.round().max(1.0) as i32; write_guest_output_state(size.w, size.h, guest_scale); if let Some(surface) = get_surface(&backend.compositor.state) { diff --git a/src/android/backend/wayland/input.rs b/src/android/backend/wayland/input.rs index 8afb4d1a..dc8eb03c 100644 --- a/src/android/backend/wayland/input.rs +++ b/src/android/backend/wayland/input.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; +use super::TouchMode; use winit::{ dpi::PhysicalPosition, event::{ElementState, MouseButton as WinitMouseButton, MouseScrollDelta}, @@ -308,8 +309,8 @@ impl AbsolutePositionEvent for WinitTouchMovedEvent { pub struct WinitTouchEndedEvent { pub(crate) time: u64, pub(crate) id: u64, - /// When false, the touch ended as part of a multi-touch gesture (e.g. scroll). - pub(crate) emit_click: bool, + /// What the gesture had been resolved to when the last finger lifted. + pub(crate) mode: TouchMode, pub(crate) x: f64, pub(crate) y: f64, } diff --git a/src/android/backend/wayland/mod.rs b/src/android/backend/wayland/mod.rs index 9e3b592f..c3564058 100644 --- a/src/android/backend/wayland/mod.rs +++ b/src/android/backend/wayland/mod.rs @@ -20,21 +20,53 @@ use smithay::{ }; use std::collections::HashMap; use winit::dpi::PhysicalPosition; +use winit::platform::android::activity::AndroidApp; + +/// What the fingers currently on screen are doing, following Android's gesture conventions. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TouchMode { + /// Still within touch slop and the long-press timeout: could become anything. + Undecided, + /// Moved past touch slop before the long press fired. + Scroll, + /// Long-press timeout elapsed without moving; no button sent yet. + LongPress, + /// Moved after a long press: left button held down. + Drag, +} pub struct WaylandBackend { pub compositor: Compositor, pub graphic_renderer: Option>, + pub android_app: AndroidApp, pub clock: Clock, pub key_counter: u32, - pub scale_factor: f64, - /// Active touch points keyed by pointer id, used for two-finger scroll detection. + pub guest_scale_factor: f64, + /// Active touch points keyed by pointer id. pub touch_points: HashMap>, - /// Centroid of the two active touch points at the last scroll update. + /// Centroid of the active touch points at the last scroll update. pub scroll_centroid: Option>, - /// Set when a two-finger gesture occurred; cleared after the last finger lifts. - pub touch_gesture_was_multi_touch: bool, - /// Location where the active single-finger touch first landed, used to tell a tap from a drag. + /// What the current gesture has been resolved to. + pub touch_mode: TouchMode, + /// Location where the gesture's first finger landed. pub touch_down_position: Option>, - /// Whether a synthesized left-button press is currently held (an in-progress drag). + /// When that finger landed, in `clock` milliseconds. + pub touch_down_time: Option, + /// `ViewConfiguration.getScaledTouchSlop()`. + pub touch_slop_px: f64, + /// `ViewConfiguration.getLongPressTimeout()`. + pub long_press_timeout_ms: u64, + /// Whether a synthesized button press is currently held (an in-progress drag). pub pointer_pressed: bool, } + +impl WaylandBackend { + /// Forget the in-flight gesture. Callers holding a pressed button must release it first. + pub fn reset_touch_state(&mut self) { + self.touch_points.clear(); + self.scroll_centroid = None; + self.touch_mode = TouchMode::Undecided; + self.touch_down_position = None; + self.touch_down_time = None; + } +} diff --git a/src/android/proot/setup.rs b/src/android/proot/setup.rs index da54d403..6fe6893a 100644 --- a/src/android/proot/setup.rs +++ b/src/android/proot/setup.rs @@ -3,19 +3,17 @@ use crate::{ android::{ app::build::PolarBearBackend, backend::{ - wayland::{Compositor, WaylandBackend}, + wayland::{Compositor, TouchMode, WaylandBackend}, webview::{ErrorVariant, WebviewBackend}, }, utils::application_context::get_application_context, - utils::ndk::run_in_jvm, + utils::ndk::{density_dpi, long_press_timeout_ms, scale_factor, touch_slop_px}, }, core::config::{ CommandConfig, ARCH_FS_ARCHIVE, ARCH_FS_ROOT, DOCS_HOME_URL, PIPEWIRE_GUEST_RUNTIME_DIR, PULSE_GUEST_SERVER, }, }; -use jni::objects::JObject; -use jni::sys::_jobject; use pathdiff::diff_paths; use smithay::utils::Clock; use std::{ @@ -344,6 +342,7 @@ fn install_dependencies(options: &SetupOptions) -> StageOutput { .run(); if installed() { + download_user_manual(); return; } mpsc_sender @@ -367,6 +366,25 @@ fn install_dependencies(options: &SetupOptions) -> StageOutput { })); } +/// Drop the offline User Manual for this app version onto the guest desktop. +/// +/// The filename carries no version so an update overwrites the previous copy instead of landing +/// beside it. Called once a fresh install or update has just succeeded — the only moment the +/// manual on disk can be out of date — and best-effort: a failed download is not worth a retry. +fn download_user_manual() { + let username = get_application_context().local_config.user.username; + let desktop_dir = chroot_home_dir(Path::new(ARCH_FS_ROOT), &username).join("Desktop"); + if fs::create_dir_all(&desktop_dir).is_err() { + return; + } + + let url = crate::core::config::user_manual_url(); + let response = reqwest::blocking::get(&url).and_then(|it| it.error_for_status()); + if let Ok(bytes) = response.and_then(|it| it.bytes()) { + let _ = fs::write(desktop_dir.join("Local Desktop - User Manual.pdf"), &bytes); + } +} + fn clear_pipewire_package_lock_for_install() { let pacman_conf = Path::new(ARCH_FS_ROOT).join("etc/pacman.conf"); let content = match fs::read_to_string(&pacman_conf) { @@ -716,6 +734,65 @@ exec "$@" None } +fn setup_chromium_no_sandbox(_: &SetupOptions) -> StageOutput { + let fs_root = Path::new(ARCH_FS_ROOT); + + // Chromium's sandbox needs CLONE_NEWUSER, which Android SELinux blocks, so every + // Chromium/Electron app has to be started with --no-sandbox. Electron apps pick that up + // from ELECTRON_DISABLE_SANDBOX (exported by startxfce4-localdesktop), but Chromium itself + // only takes the flag, and its desktop entry hardcodes an absolute path that a + // /usr/local/bin wrapper cannot intercept. So shadow the affected application entries in + // the user's own XDG directory, re-running every session to catch newly installed apps. + write_executable( + &fs_root.join("usr/local/bin/localdesktop-no-sandbox-entries"), + r#"#!/bin/sh +target_dir="${XDG_DATA_HOME:-$HOME/.local/share}/applications" +mkdir -p "$target_dir" || exit 0 + +for src in /usr/share/applications/*.desktop /usr/local/share/applications/*.desktop; do + [ -f "$src" ] || continue + + prog=$(sed -n 's/^Exec=//p' "$src" | head -n1 | awk '{print $1}') + [ -n "$prog" ] || continue + case "$prog" in + /*) bin="$prog" ;; + *) bin=$(command -v "$prog" 2>/dev/null) || continue ;; + esac + bin=$(readlink -f "$bin" 2>/dev/null) + [ -n "$bin" ] || continue + + # Every Chromium/Electron build ships the setuid sandbox helper next to its binary, + # or one level up when the launcher lives in a bin/ subdirectory. + dir=$(dirname "$bin") + [ -e "$dir/chrome-sandbox" ] || [ -e "$dir/../chrome-sandbox" ] || continue + + dst="$target_dir/$(basename "$src")" + # Leave alone anything the user wrote themselves. + if [ -e "$dst" ] && ! grep -q '^X-LocalDesktop-NoSandbox=' "$dst"; then + continue + fi + + awk ' + /^\[Desktop Entry\]/ && !seen { print; print "X-LocalDesktop-NoSandbox=true"; seen = 1; next } + /^Exec=/ && !/--no-sandbox/ { sub(/^Exec=[^ ]+/, "& --no-sandbox") } + { print } + ' "$src" > "$dst" +done +"#, + ); + + // Same flag for terminal launches, following the /usr/local/bin PATH-priority pattern. + write_executable( + &fs_root.join("usr/local/bin/chromium"), + r#"#!/bin/sh +[ -x /usr/bin/chromium ] || { echo "chromium is not installed" >&2; exit 127; } +exec /usr/bin/chromium --no-sandbox "$@" +"#, + ); + + None +} + fn setup_onboard_signal_fix(_: &SetupOptions) -> StageOutput { let fs_root = Path::new(ARCH_FS_ROOT); let wrapper_path = fs_root.join("usr/local/bin/onboard"); @@ -772,42 +849,6 @@ fn write_executable(path: &Path, contents: &str) { .expect("Failed to mark executable script"); } -fn read_android_density_dpi(android_app: AndroidApp) -> i32 { - let mut density_dpi: i32 = 160; - run_in_jvm( - |env, app| { - let activity = unsafe { JObject::from_raw(app.activity_as_ptr() as *mut _jobject) }; - let resources = env - .call_method( - activity, - "getResources", - "()Landroid/content/res/Resources;", - &[], - ) - .expect("Failed to call getResources") - .l() - .expect("Failed to read getResources result"); - let metrics = env - .call_method( - resources, - "getDisplayMetrics", - "()Landroid/util/DisplayMetrics;", - &[], - ) - .expect("Failed to call getDisplayMetrics") - .l() - .expect("Failed to read getDisplayMetrics result"); - density_dpi = env - .get_field(&metrics, "densityDpi", "I") - .expect("Failed to read densityDpi") - .i() - .expect("Failed to convert densityDpi"); - }, - android_app, - ); - density_dpi -} - /// Map Android density to a whole-number UI scale factor (same baseline as the old LXQt setup). fn android_ui_scale(density_dpi: i32) -> i32 { ((density_dpi as f32) / 160.0 * 1.1).max(1.0).round() as i32 @@ -819,8 +860,7 @@ fn setup_xfce_wayland(options: &SetupOptions) -> StageOutput { let home_dir = chroot_home_dir(fs_root, &username); let labwc_dir = home_dir.join(".config/xfce4/labwc"); - let density_dpi = read_android_density_dpi(options.android_app.clone()); - let ui_scale = android_ui_scale(density_dpi); + let ui_scale = android_ui_scale(density_dpi(&options.android_app)); // Xft uses 96 as the default logical DPI; multiply by scale for HiDPI fonts. let xft_dpi = ui_scale * 96; @@ -879,12 +919,15 @@ export PIPEWIRE_RUNTIME_DIR={PIPEWIRE_GUEST_RUNTIME_DIR} export PULSE_SERVER={PULSE_GUEST_SERVER} : "${{XDG_RUNTIME_DIR:={PIPEWIRE_GUEST_RUNTIME_DIR}}}" export XDG_RUNTIME_DIR +# Electron adds --no-sandbox when this is set; Android has no user namespaces for it to use. +export ELECTRON_DISABLE_SANDBOX=1 exec startxfce4 --wayland "$@" "# ), ); - // Runs from ~/.config/autostart once xfsettingsd is up; reinforces pre-seeded /Xft/DPI. + // Runs from ~/.config/autostart once xfsettingsd is up; reinforces pre-seeded /Xft/DPI and + // refreshes the --no-sandbox application entries for anything installed since last session. write_executable( &fs_root.join("usr/local/bin/localdesktop-xfce-session-init"), &format!( @@ -896,6 +939,8 @@ done xfconf-query -c xsettings -p /Xft/DPI -n -t int -s {xft_dpi} 2>/dev/null || \ xfconf-query -c xsettings -p /Xft/DPI -t int -s {xft_dpi} + +/usr/local/bin/localdesktop-no-sandbox-entries "# ), ); @@ -938,24 +983,6 @@ StartupNotify=true ); } - // Pre-download the matching offline User Manual (light, desktop size) onto the - // Desktop. Best-effort and off-thread so it never blocks setup; create-if-missing - // via the version in the filename. The on-disk name is human-friendly. - let version = crate::core::config::VERSION; - let manual_path = desktop_dir.join(format!("Local Desktop v{version} - User Manual.pdf")); - if !manual_path.exists() { - let url = crate::core::config::user_manual_url(); - thread::spawn(move || { - if let Ok(response) = reqwest::blocking::get(&url) { - if let Ok(response) = response.error_for_status() { - if let Ok(bytes) = response.bytes() { - let _ = fs::write(&manual_path, &bytes); - } - } - } - }); - } - let autostart_dir = home_dir.join(".config/autostart"); let _ = fs::create_dir_all(&autostart_dir); @@ -965,7 +992,7 @@ StartupNotify=true Version=1.0 Type=Application Name=Local Desktop Xfce Session Init -Comment=Apply HiDPI font scaling via xfsettings +Comment=Apply HiDPI font scaling and refresh sandbox-free application entries Exec=/usr/local/bin/localdesktop-xfce-session-init Terminal=false OnlyShowIn=XFCE; @@ -1158,7 +1185,7 @@ pub fn setup(android_app: AndroidApp) -> PolarBearBackend { } let options = SetupOptions { - android_app, + android_app: android_app.clone(), mpsc_sender: sender.clone(), }; @@ -1170,9 +1197,10 @@ pub fn setup(android_app: AndroidApp) -> PolarBearBackend { Box::new(setup_pipewire_package_lock), // Step 5. Hold guest PipeWire packages for the Android-side PipeWire POC Box::new(setup_firefox_config), // Step 6. Setup Firefox config Box::new(setup_fake_bwrap), // Step 7. Replace bwrap with a no-sandbox shim (Android has no user namespaces) - Box::new(setup_onboard_signal_fix), // Step 8. Wrap Onboard to survive proot fstat/signal.set_wakeup_fd failure - Box::new(setup_xfce_wayland), // Step 9. Setup Xfce Wayland launch and HiDPI scaling - Box::new(fix_xkb_symlink), // Step 10. Fix xkb symlink + Box::new(setup_chromium_no_sandbox), // Step 8. Make Chromium/Electron apps launchable without a terminal + Box::new(setup_onboard_signal_fix), // Step 9. Wrap Onboard to survive proot fstat/signal.set_wakeup_fd failure + Box::new(setup_xfce_wayland), // Step 10. Setup Xfce Wayland launch and HiDPI scaling + Box::new(fix_xkb_symlink), // Step 11. Fix xkb symlink ]; let handle_stage_error = |e: Box, sender: &Sender| { @@ -1247,12 +1275,16 @@ pub fn setup(android_app: AndroidApp) -> PolarBearBackend { graphic_renderer: None, clock: Clock::new(), key_counter: 0, - scale_factor: 1.0, + guest_scale_factor: scale_factor(&android_app), touch_points: std::collections::HashMap::new(), scroll_centroid: None, - touch_gesture_was_multi_touch: false, + touch_mode: TouchMode::Undecided, touch_down_position: None, + touch_down_time: None, + touch_slop_px: touch_slop_px(&android_app), + long_press_timeout_ms: long_press_timeout_ms(&android_app), pointer_pressed: false, + android_app, }) } else { PolarBearBackend::WebView(WebviewBackend::build(receiver, progress)) diff --git a/src/android/utils/ndk.rs b/src/android/utils/ndk.rs index bcf6a176..3840ea63 100644 --- a/src/android/utils/ndk.rs +++ b/src/android/utils/ndk.rs @@ -1,7 +1,11 @@ -use jni::sys::JNIInvokeInterface_; +use jni::objects::{JObject, JValue}; +use jni::sys::{JNIInvokeInterface_, _jobject}; use jni::{JNIEnv, JavaVM}; use winit::platform::android::activity::AndroidApp; +/// Logical density baseline: 160 dpi is Android's 1x bucket. +const BASELINE_DPI: f64 = 160.0; + /// A higher-order function to run a provided JNI function within the JVM context. pub fn run_in_jvm(jni_function: F, android_app: AndroidApp) -> T where @@ -22,3 +26,90 @@ where res } + +/// Screen density in dpi, read from `Resources.getDisplayMetrics()`. +/// +/// Prefer this over winit's `scale_factor()`: that one comes from `AConfiguration`, which the +/// native-activity glue builds from the asset manager at `onCreate` while density is still unset, +/// so it reports the 160 dpi default until the first configuration change. +pub fn density_dpi(android_app: &AndroidApp) -> i32 { + run_in_jvm( + |env, app| { + let activity = unsafe { JObject::from_raw(app.activity_as_ptr() as *mut _jobject) }; + let resources = env + .call_method( + activity, + "getResources", + "()Landroid/content/res/Resources;", + &[], + ) + .and_then(|it| it.l()) + .ok()?; + let metrics = env + .call_method( + resources, + "getDisplayMetrics", + "()Landroid/util/DisplayMetrics;", + &[], + ) + .and_then(|it| it.l()) + .ok()?; + env.get_field(&metrics, "densityDpi", "I") + .and_then(|it| it.i()) + .ok() + }, + android_app.clone(), + ) + .unwrap_or(BASELINE_DPI as i32) +} + +/// Guest UI scale factor derived from the device density, never below 1x. +pub fn scale_factor(android_app: &AndroidApp) -> f64 { + (density_dpi(android_app) as f64 / BASELINE_DPI).max(1.0) +} + +/// How far a finger may travel before the gesture counts as a scroll rather than a tap +/// (`ViewConfiguration.getScaledTouchSlop()`, already in physical pixels). +pub fn touch_slop_px(android_app: &AndroidApp) -> f64 { + run_in_jvm( + |env, app| { + let activity = unsafe { JObject::from_raw(app.activity_as_ptr() as *mut _jobject) }; + let config = env + .call_static_method( + "android/view/ViewConfiguration", + "get", + "(Landroid/content/Context;)Landroid/view/ViewConfiguration;", + &[JValue::Object(&activity)], + ) + .and_then(|it| it.l()) + .ok()?; + env.call_method(config, "getScaledTouchSlop", "()I", &[]) + .and_then(|it| it.i()) + .ok() + }, + android_app.clone(), + ) + .map(|slop| slop as f64) + .unwrap_or(24.0) +} + +/// How long a finger must stay put to count as a long press +/// (`ViewConfiguration.getLongPressTimeout()`, 500 ms by default, tunable in accessibility +/// settings). +pub fn long_press_timeout_ms(android_app: &AndroidApp) -> u64 { + run_in_jvm( + |env, _| { + env.call_static_method( + "android/view/ViewConfiguration", + "getLongPressTimeout", + "()I", + &[], + ) + .and_then(|it| it.i()) + .ok() + }, + android_app.clone(), + ) + .map(|timeout| timeout.max(0) as u64) + .unwrap_or(500) +}