diff --git a/Cargo.lock b/Cargo.lock index 6f984387..4eaa36d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2348,7 +2348,7 @@ dependencies = [ [[package]] name = "localdesktop" -version = "3.2.2" +version = "3.3.0" dependencies = [ "android-sdkmanager-rs", "android_logger", diff --git a/Cargo.toml b/Cargo.toml index 510be904..a927572f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "localdesktop" -version = "3.2.2" +version = "3.3.0" edition = "2021" build = "build.rs" default-run = "build_apk" diff --git a/gh-pages/docs/user/1-getting-started.md b/gh-pages/docs/user/1-getting-started.md index aeee4761..0a349a91 100644 --- a/gh-pages/docs/user/1-getting-started.md +++ b/gh-pages/docs/user/1-getting-started.md @@ -51,9 +51,9 @@ The hardest part is installing `yay` or `paru`. You have to follow the `README.m ## I don't have a physical keyboard. -An on-screen keyboard slides up when you tap a text field, and hides again when you tap elsewhere. For apps where it doesn't appear on its own, open _"Applications"_ > _"Accessories"_ > _"On-screen keyboard"_ to show or hide it. A physical keyboard is still more comfortable for long sessions. +Your phone's own keyboard comes up when you tap a text field, and the desktop fits above it. Swipe back to put it away. To use Squeekboard, the desktop's own on-screen keyboard, instead, turn off **Use the Android keyboard** in [Local Desktop settings](/docs/user/configurations). For apps where it doesn't come up on its own, open _"Applications"_ > _"Accessories"_ > _"On-screen keyboard"_. A physical keyboard is still more comfortable for long sessions. -![On-screen keyboard](/img/squeekboard.webp#boxed) +![The Android keyboard typing into the desktop](/img/android-keyboard.webp#boxed) ## Text is too small diff --git a/gh-pages/docs/user/2-creating-a-non-root-user.md b/gh-pages/docs/user/2-creating-a-non-root-user.md index ab4df27b..aea6e943 100644 --- a/gh-pages/docs/user/2-creating-a-non-root-user.md +++ b/gh-pages/docs/user/2-creating-a-non-root-user.md @@ -70,19 +70,6 @@ sudo ls /root # Make sure it does not output something like: "teddy is not in th ## [Important] Tell Local Desktop -You must tell Local Desktop who to log in as, or it will log in as root. To (create and) edit the config file: +You must tell Local Desktop who to log in as, or it will log in as root. In Android, open **App info** for Local Desktop > **Additional settings in the app**, and set **User** to `teddy` _(the username you created [previously](#create-your-user))_. Then tap **Restart Local Desktop**. -``` -nano /etc/localdesktop/localdesktop.toml -``` - -Add the following content: - -```toml title="/etc/localdesktop/localdesktop.toml" -[user] -username = "teddy" -``` - -_(Replace `teddy` with the username you created [previously](#create-your-user))_ - -The changes will take effect the next time you launch Local Desktop. If something goes wrong, you can always delete this config file and restart as root. +If something goes wrong, set **User** back to `root` there. See [Settings](/docs/user/configurations). diff --git a/gh-pages/docs/user/3-custom-de.md b/gh-pages/docs/user/3-custom-de.md index 5da634be..b38ed9a1 100644 --- a/gh-pages/docs/user/3-custom-de.md +++ b/gh-pages/docs/user/3-custom-de.md @@ -6,21 +6,14 @@ title: Using other Desktop Environments This is an advanced topic. Proceed with your own risk. ::: -## The `[command]` configs +## The 3 commands -Local Desktop uses 3 commands to set up your desktop environment: - -```toml title="/etc/localdesktop/localdesktop.toml" -[command] -check="pacman -Q noto-fonts && pacman -Q xfce4-session && pacman -Q xfce4-panel && pacman -Q xfce4-settings && pacman -Q xfce4-terminal && pacman -Q thunar && pacman -Q xfdesktop && pacman -Q xfconf && pacman -Q labwc && pacman -Q wlr-randr && pacman -Q xorg-xwayland && pacman -Q xdg-desktop-portal && pacman -Q xdg-desktop-portal-gtk && pacman -Q squeekboard" -install="stdbuf -oL pacman -Syu --needed --noconfirm --noprogressbar noto-fonts xfce4 labwc wlr-randr xorg-xwayland xdg-desktop-portal xdg-desktop-portal-gtk squeekboard" -launch="XDG_RUNTIME_DIR=/tmp WAYLAND_DISPLAY=wayland-0 XDG_SESSION_TYPE=wayland XDG_CURRENT_DESKTOP=XFCE /usr/local/bin/startxfce4-localdesktop 2>&1" -``` +Local Desktop uses 3 commands to set up your desktop environment: **Check**, **Install** and **Launch**. Change them in [Local Desktop settings](/docs/user/configurations), under **Additional settings in the app** in Android's App info for Local Desktop, which shows the current ones. You can change these 3 commands to install and launch your custom desktop environment. Please share your successful setups with us and we can put them here to help others. :::success Tips -The `try_check`, `try_install`, `try_launch` configs are very handy to try different config values **without breaking anything**. Check out the [Configurations](/docs/user/configurations#special-try_-configs) documentation for more details about `try_*`. +The settings open from Android even when the desktop doesn't start, so a command that doesn't work is easy to undo: change it back, or tap **Restore defaults**. ::: ### check @@ -65,14 +58,28 @@ For a legacy **X11 session via Xwayland**, start Xwayland first and point the de ### KDE Plasma -```toml title="/etc/localdesktop/localdesktop.toml" -[command] -try_check = "pacman -Qg plasma" -try_install = "stdbuf -oL pacman -Syu plasma --noconfirm --noprogressbar" -# X11 session via Xwayland -try_launch = "XDG_RUNTIME_DIR=/tmp Xwayland -hidpi :1 2>&1 & while [ ! -e /tmp/.X11-unix/X1 ]; do sleep 0.1; done; XDG_SESSION_TYPE=x11 DISPLAY=:1 dbus-launch startplasma-x11 2>&1" -# Wayland session -try_launch = "XDG_RUNTIME_DIR=/tmp WAYLAND_DISPLAY=wayland-0 /usr/lib/plasma-dbus-run-session-if-needed startplasma-wayland 2>&1" +**Check:** + +```bash +pacman -Qg plasma +``` + +**Install:** + +```bash +stdbuf -oL pacman -Syu plasma --noconfirm --noprogressbar +``` + +**Launch**, a Wayland session: + +```bash +XDG_RUNTIME_DIR=/tmp WAYLAND_DISPLAY=wayland-0 /usr/lib/plasma-dbus-run-session-if-needed startplasma-wayland 2>&1 +``` + +Or an X11 session via Xwayland: + +```bash +XDG_RUNTIME_DIR=/tmp Xwayland -hidpi :1 2>&1 & while [ ! -e /tmp/.X11-unix/X1 ]; do sleep 0.1; done; XDG_SESSION_TYPE=x11 DISPLAY=:1 dbus-launch startplasma-x11 2>&1 ``` ![KDE Plasma on Local Desktop](/img/kde.webp) @@ -85,6 +92,4 @@ Feedback: ### Others -```toml title="/etc/localdesktop/localdesktop.toml" -Feel free to contribute your configs by using the "Edit this page" link below -``` +Feel free to contribute your commands by using the "Edit this page" link below. diff --git a/gh-pages/docs/user/4-configurations.md b/gh-pages/docs/user/4-configurations.md index 58f7265d..7aad7afc 100644 --- a/gh-pages/docs/user/4-configurations.md +++ b/gh-pages/docs/user/4-configurations.md @@ -1,53 +1,28 @@ --- -title: Configurations +title: Settings --- -## Config file +## Open the settings -On launch, Local Desktop reads the config file located at: +Local Desktop's settings are in Android, so you can change them even when the desktop doesn't start: -``` -/etc/localdesktop/localdesktop.toml -``` +1. Touch and hold the Local Desktop icon, then tap **App info**. Or open Android's **Settings** > **Apps** > **Local Desktop**. +2. Tap **Additional settings in the app** (on some phones, a gear icon). -If the content of the config file is invalid (for example, invalid TOML format), it will be **replaced** with the default config. You can still view its original content in: +Touch and hold the Local Desktop icon, then tap App info Tap Additional settings in the app Local Desktop settings -``` -/etc/localdesktop/localdesktop.bak -``` +Changes apply the next time Local Desktop starts. **Restart Local Desktop** at the bottom applies them right away. -Some important notes: -- Although TOML does support multi-line strings, Local Desktop requires each config to fit in a **single line**. You can use `\n` for multi-line config values if needed. -- We use **all lowercase** for config **keys**. For config **values**, the content is **case-sensitive**. +## What you can change -## Config schema +| Setting | What it does | Default | +|---|---|---| +| **Use the Android keyboard** | Your phone's keyboard comes up when a text field takes focus, and the desktop fits above it. Turn it off to use Squeekboard, the desktop's own on-screen keyboard. With an external display, the Android keyboard always types. | On | +| **User** | The Linux user the desktop runs as. Create the user first, see [Creating a non-root user](/docs/user/creating-a-non-root-user). | `root` | +| **Check** | A command that succeeds when the desktop environment is installed. | Checks for Xfce | +| **Install** | Installs the desktop environment when **Check** fails. | Installs Xfce | +| **Launch** | Starts the desktop environment. | Starts Xfce | -We might draw a table or have a mechanism to generate the config schema automatically here. But for now, please check the code for the schema: [localdesktop/src/core/config.rs#L28-L87](https://github.com/localdesktop/localdesktop.github.io/blob/main/src/core/config.rs#L28-L87). +See [Using other Desktop Environments](/docs/user/custom-de) for other **Check**, **Install** and **Launch** commands. -## Special `try_*` configs - -Some configs are so important that a misconfiguration can leave you stuck on a black screen. So we support a special `try_*` variant of each config. These configs have **higher priority**, but only get applied **once**. - -For example, you just have to clone a config and prefix it with `try_`: - -```toml -[user] -username="root" -try_username="teddy" -``` - -The next time Local Desktop starts, it will log in as `teddy` instead of `root`. But then the `try_` configs will be commented out like this: - -```toml -[user] -username="root" -# try_username="teddy" -``` - -So if the config didn't work, and you got stuck on a black screen, you can just restart Local Desktop, and things will go back to normal. Then you can uncomment the config and try with another value. If the config does work, you just have to remove the `try_` prefix to persist the config. - -Some important notes: -- This rule applies to **all** configs. -- It is not required for the `try_` config to be inside the same group as the normal config. But it is strongly recommended to do so, and to put the `try_` variant right under its normal variant. -- If a normal config appears multiple times, the **first** entry is applied. If a `try_` config appears multiple times, the **last** entry is applied. This behavior is not guaranteed, and is subject to change. But in general, it is **invalid** to have duplicate config keys inside a TOML file. -- `try_x` and `x` are not duplicate keys. `try_x` always has higher priority than `x`. +If a setting leaves you with a black screen, change it back here, or tap **Restore defaults**. diff --git a/gh-pages/docs/user/7-gpu-acceleration.md b/gh-pages/docs/user/7-gpu-acceleration.md index f220301d..2b4eb7ec 100644 --- a/gh-pages/docs/user/7-gpu-acceleration.md +++ b/gh-pages/docs/user/7-gpu-acceleration.md @@ -22,11 +22,10 @@ On a supported device this prints a renderer like `zink Vulkan 1.4(Turnip Adreno ## Turn it off -If an app misbehaves with the GPU, prefix your [`launch` command](/docs/user/custom-de#launch) with `LOCALDESKTOP_GPU=0`: +If an app misbehaves with the GPU, put `LOCALDESKTOP_GPU=0 ` in front of the **Launch** command in [Local Desktop settings](/docs/user/configurations), for example: -```toml title="/etc/localdesktop/localdesktop.toml" -[command] -try_launch="LOCALDESKTOP_GPU=0 XDG_RUNTIME_DIR=/tmp WAYLAND_DISPLAY=wayland-0 XDG_SESSION_TYPE=wayland XDG_CURRENT_DESKTOP=XFCE /usr/local/bin/startxfce4-localdesktop 2>&1" +```bash +LOCALDESKTOP_GPU=0 XDG_RUNTIME_DIR=/tmp WAYLAND_DISPLAY=wayland-0 XDG_SESSION_TYPE=wayland XDG_CURRENT_DESKTOP=XFCE /usr/local/bin/startxfce4-localdesktop 2>&1 ``` To turn it off for a single app, start it with the distro's Mesa instead: diff --git a/gh-pages/docs/user/8-external-display.md b/gh-pages/docs/user/8-external-display.md index 39f08366..5ce0cc92 100644 --- a/gh-pages/docs/user/8-external-display.md +++ b/gh-pages/docs/user/8-external-display.md @@ -25,7 +25,7 @@ The bear in the top left corner of the trackpad shows which way is up. ## Buttons - **Rotate** turns the phone's screen a quarter clockwise, trackpad and Android keyboard included, for when you lay the phone sideways while auto-rotate keeps it upright. -- **Keyboard** opens the Android keyboard on the phone and types into the desktop. It also opens by itself when a text field on the desktop takes focus, instead of the desktop's own touch keyboard. +- **Keyboard** opens the Android keyboard on the phone and types into the desktop. It also opens by itself when a text field on the desktop takes focus. ![The Android keyboard typing into the desktop](/img/external-display-keyboard.png#boxed) diff --git a/gh-pages/static/img/android-keyboard.webp b/gh-pages/static/img/android-keyboard.webp new file mode 100644 index 00000000..fa8f7074 Binary files /dev/null and b/gh-pages/static/img/android-keyboard.webp differ diff --git a/gh-pages/static/img/settings-step-1.webp b/gh-pages/static/img/settings-step-1.webp new file mode 100644 index 00000000..e86b7120 Binary files /dev/null and b/gh-pages/static/img/settings-step-1.webp differ diff --git a/gh-pages/static/img/settings-step-2.webp b/gh-pages/static/img/settings-step-2.webp new file mode 100644 index 00000000..576f261b Binary files /dev/null and b/gh-pages/static/img/settings-step-2.webp differ diff --git a/gh-pages/static/img/settings.webp b/gh-pages/static/img/settings.webp new file mode 100644 index 00000000..9abd4421 Binary files /dev/null and b/gh-pages/static/img/settings.webp differ diff --git a/gh-pages/static/img/squeekboard.webp b/gh-pages/static/img/squeekboard.webp deleted file mode 100644 index 6fcbece6..00000000 Binary files a/gh-pages/static/img/squeekboard.webp and /dev/null differ diff --git a/manifest.yaml b/manifest.yaml index 51101860..5c23c5df 100644 --- a/manifest.yaml +++ b/manifest.yaml @@ -18,6 +18,21 @@ android: label: "Local Desktop" extract_native_libs: true # Required for proot invocations use_cleartext_traffic: true # Required for connecting to ws://127.0.0.1, see assets/setup-progress.html + activities: + # The desktop: android.app.NativeActivity, filled in by the build. + - {} + # "Local Desktop settings" in App info. Its own process, so it still opens while the + # desktop is stuck, and can restart it. + - name: io.github.localdesktop.SettingsActivity + label: "Local Desktop settings" + process: ":settings" + theme: "@android:style/Theme.DeviceDefault.Settings" + exported: true + intent_filters: + - actions: + - android.intent.action.APPLICATION_PREFERENCES + categories: + - android.intent.category.DEFAULT services: - name: io.github.localdesktop.KeyboardAccessibilityService permission: android.permission.BIND_ACCESSIBILITY_SERVICE diff --git a/patches/xbuild/apk/src/manifest.rs b/patches/xbuild/apk/src/manifest.rs index a6212aa1..99fba185 100644 --- a/patches/xbuild/apk/src/manifest.rs +++ b/patches/xbuild/apk/src/manifest.rs @@ -120,6 +120,10 @@ pub struct Activity { pub intent_filters: Vec, #[serde(rename(serialize = "android:colorMode"))] pub color_mode: Option, + #[serde(rename(serialize = "android:process"))] + pub process: Option, + #[serde(rename(serialize = "android:theme"))] + pub theme: Option, } /// Android [service element](https://developer.android.com/guide/topics/manifest/service-element). diff --git a/src/android/accessibility.rs b/src/android/accessibility.rs index 89d37673..34f31aba 100644 --- a/src/android/accessibility.rs +++ b/src/android/accessibility.rs @@ -23,8 +23,10 @@ pub enum AppUserEvent { ExternalDisplayChanged, /// A mouse (or touch) on the external monitor moved, clicked or scrolled. ExternalPointer, - /// A text field on the desktop took focus while the phone is a trackpad. + /// A text field on the desktop took focus. KeyboardRequested, + /// The Android keyboard on the phone's screen came up, changed height or went away. + KeyboardInset, } #[derive(Clone, Copy, Debug)] diff --git a/src/android/app/run.rs b/src/android/app/run.rs index 35ced6ae..6b6c1813 100644 --- a/src/android/app/run.rs +++ b/src/android/app/run.rs @@ -8,7 +8,7 @@ use crate::android::{ pipewire_standalone_aaudio, wayland::{ bind, centralize, centralize_injected_keyboard, dispatch_clients, handle, idle, - on_external_pointer, on_vsync, paint_trackpad, start_frame_loop, + on_external_pointer, on_vsync, paint_trackpad, set_keyboard_inset, start_frame_loop, write_guest_output_state, CentralizedEvent, State, WaylandBackend, }, webview::ErrorVariant, @@ -16,6 +16,7 @@ use crate::android::{ external_display, proot::launch::launch, utils::{ + application_context::get_application_context, ndk::{self, run_in_jvm}, webview::show_webview_popup, }, @@ -85,7 +86,8 @@ fn configure_output(backend: &mut WaylandBackend) { ); let guest_scale = guest_scale_factor.round().max(1.0) as i32; - let touch_keyboard = winit.external_surface().is_none(); + let touch_keyboard = winit.external_surface().is_none() + && !get_application_context().local_config.android_keyboard; write_guest_output_state(window_size.w, window_size.h, guest_scale, touch_keyboard); for surface in backend.compositor.state.xdg_shell_state.toplevel_surfaces() { @@ -229,13 +231,16 @@ impl ApplicationHandler for PolarBearApp { } } AppUserEvent::KeyboardRequested => { - if backend.is_trackpad() && backend.window_visible() { + if backend.window_visible() { run_in_jvm( external_display::show_keyboard, self.frontend.android_app.clone(), ); } } + AppUserEvent::KeyboardInset => { + set_keyboard_inset(backend, external_display::keyboard_inset()); + } } } diff --git a/src/android/backend/wayland/event_handler.rs b/src/android/backend/wayland/event_handler.rs index de7a217c..4df1b99a 100644 --- a/src/android/backend/wayland/event_handler.rs +++ b/src/android/backend/wayland/event_handler.rs @@ -6,6 +6,7 @@ use crate::android::{ WaylandBackend, WaylandWatcher, }, external_display::PointerEvent, + utils::application_context::get_application_context, }; use smithay::backend::input::{AxisSource, ButtonState}; use smithay::backend::renderer::element::solid::SolidColorRenderElement; @@ -20,7 +21,7 @@ use smithay::backend::renderer::{Color32F, Frame, Renderer}; use smithay::input::keyboard::FilterResult; use smithay::input::pointer::{self, CursorImageStatus, CursorImageSurfaceData}; use smithay::reexports::wayland_server::protocol::wl_pointer::ButtonState as WlButtonState; -use smithay::utils::{Point, Rectangle, Transform, SERIAL_COUNTER}; +use smithay::utils::{Physical, Point, Rectangle, Size, Transform, SERIAL_COUNTER}; use smithay::wayland::compositor::with_states; use smithay::wayland::shell::xdg::ToplevelSurface; use smithay::{ @@ -424,36 +425,38 @@ pub fn handle(event: CentralizedEvent, backend: &mut WaylandBackend, event_loop: } // The desktop is on the external display, so the phone's own window doesn't size it. CentralizedEvent::Resized { .. } if backend.is_trackpad() => paint_trackpad(backend), - CentralizedEvent::Resized { - size, - guest_scale_factor, - } => { - backend.compositor.state.size = (size.w, size.h).into(); - - if let Some(output) = &backend.compositor.output { - output.change_current_state( - Some(Mode { - size: size.into(), - refresh: backend.refresh_millihz, - }), - Some(Transform::Normal), - Some(Scale::Integer(1)), - Some((0, 0).into()), - ); - } + CentralizedEvent::Resized { size, .. } => resize_desktop(backend, size), + _ => (), + } +} - let guest_scale = guest_scale_factor.round().max(1.0) as i32; - write_guest_output_state(size.w, size.h, guest_scale, true); +/// Size the desktop to the phone's window, less what the Android keyboard covers of it. +fn resize_desktop(backend: &mut WaylandBackend, window: Size) { + let size: Size = (window.w, (window.h - backend.keyboard_inset).max(1)).into(); + backend.compositor.state.size = (size.w, size.h).into(); + + if let Some(output) = &backend.compositor.output { + output.change_current_state( + Some(Mode { + size: size.into(), + refresh: backend.refresh_millihz, + }), + Some(Transform::Normal), + Some(Scale::Integer(1)), + Some((0, 0).into()), + ); + } - if let Some(surface) = get_surface(&backend.compositor.state) { - surface.xdg_toplevel().configure(size.w, size.h, vec![]); - } + let guest_scale = backend.guest_scale_factor.round().max(1.0) as i32; + let touch_keyboard = !get_application_context().local_config.android_keyboard; + write_guest_output_state(size.w, size.h, guest_scale, touch_keyboard); - backend.compositor.state.render_pending = true; - schedule_frame(backend); - } - _ => (), + if let Some(surface) = get_surface(&backend.compositor.state) { + surface.xdg_toplevel().configure(size.w, size.h, vec![]); } + + backend.compositor.state.render_pending = true; + schedule_frame(backend); } /// Show the phone's screen as a blank trackpad; `TrackpadControls` draws its outline on top. @@ -465,6 +468,23 @@ pub fn paint_trackpad(backend: &mut WaylandBackend) { } } +/// The Android keyboard on the phone's screen now covers its bottom `inset` pixels: fit the +/// desktop above it, as Android apps do, so the text field being typed into stays in sight. +pub fn set_keyboard_inset(backend: &mut WaylandBackend, inset: i32) { + if inset == backend.keyboard_inset { + return; + } + backend.keyboard_inset = inset; + if backend.is_trackpad() { + return; + } + let window = match &backend.graphic_renderer { + Some(winit) => winit.window_size(), + None => return, + }; + resize_desktop(backend, window); +} + /// A mouse on the external display, or one plugged into the phone: it moves the cursor, and its /// buttons and wheel go straight to the desktop. pub fn on_external_pointer(backend: &mut WaylandBackend, event: PointerEvent) { diff --git a/src/android/backend/wayland/mod.rs b/src/android/backend/wayland/mod.rs index 8a6a271c..1649ab4a 100644 --- a/src/android/backend/wayland/mod.rs +++ b/src/android/backend/wayland/mod.rs @@ -16,7 +16,8 @@ pub use output_state::write_guest_output_state; pub use compositor::{Compositor, State}; pub use event_centralizer::{centralize, centralize_injected_keyboard, CentralizedEvent}; pub use event_handler::{ - dispatch_clients, handle, idle, on_external_pointer, on_vsync, paint_trackpad, start_frame_loop, + dispatch_clients, handle, idle, on_external_pointer, on_vsync, paint_trackpad, + set_keyboard_inset, start_frame_loop, }; pub use frame_clock::FrameClock; pub use watcher::WaylandWatcher; @@ -82,6 +83,8 @@ pub struct WaylandBackend { pub frame_clock: Option, /// A frame was already submitted for the upcoming refresh, so the next one waits for vsync. pub rendered_since_vsync: bool, + /// How much of the bottom of the phone's screen the Android keyboard covers, in pixels. + pub keyboard_inset: i32, } impl WaylandBackend { diff --git a/src/android/backend/wayland/output_state.rs b/src/android/backend/wayland/output_state.rs index 183bfa6f..b62755d3 100644 --- a/src/android/backend/wayland/output_state.rs +++ b/src/android/backend/wayland/output_state.rs @@ -5,8 +5,8 @@ use std::path::PathBuf; /// /// The file lives in the proot-visible `/tmp` directory so scripts running inside the /// Xfce/labwc session can align wlroots output mode/scale with the Android winit window. -/// `touch_keyboard` is off while the desktop is on an external display, where the phone is the -/// trackpad and the Android keyboard types instead (see `localdesktop-keyboard-relay`). +/// `touch_keyboard` is on when Squeekboard types rather than the Android keyboard: the setting is +/// off, and the desktop is on the phone's screen (see `localdesktop-keyboard-relay`). pub fn write_guest_output_state(width: i32, height: i32, scale: i32, touch_keyboard: bool) { if width <= 0 || height <= 0 || scale <= 0 { return; diff --git a/src/android/dex/README.md b/src/android/dex/README.md index d13a33d9..3fcdf8e3 100644 --- a/src/android/dex/README.md +++ b/src/android/dex/README.md @@ -1,6 +1,6 @@ This directory stores JVM bytecode artifacts that the on-device `cargo run` APK builder can embed without requiring a Java or Kotlin toolchain in Termux. -`classes.dex` currently contains the Java classes in [`../java/io/github/localdesktop/`](../java/io/github/localdesktop/): the keyboard accessibility service, the prompt that asks before turning it on, and the external display presentation with its trackpad controls. +`classes.dex` currently contains the Java classes in [`../java/io/github/localdesktop/`](../java/io/github/localdesktop/): the keyboard accessibility service, the prompt that asks before turning it on, and the external display presentation with its trackpad controls, the Android keyboard on the phone's screen, and the settings page in App info. To regenerate it on a machine with `javac`, `d8`, and Android platform `android.jar` available: diff --git a/src/android/dex/classes.dex b/src/android/dex/classes.dex index 880e07cb..4846f4a6 100644 Binary files a/src/android/dex/classes.dex and b/src/android/dex/classes.dex differ diff --git a/src/android/external_display.rs b/src/android/external_display.rs index 6404c837..ed48cc27 100644 --- a/src/android/external_display.rs +++ b/src/android/external_display.rs @@ -89,12 +89,13 @@ struct Bridge { /// The event loop holds an EGL surface on `surface`. in_use: bool, pointer_events: VecDeque, + /// How much of the phone's screen the Android keyboard covers, in pixels. + keyboard_inset: i32, } static BRIDGE: OnceLock<(Mutex, Condvar)> = OnceLock::new(); -/// The guest touches this when a text field takes focus while the phone is a trackpad (see -/// `localdesktop-keyboard-relay`). +/// The guest touches this when a text field takes focus (see `localdesktop-keyboard-relay`). const KEYBOARD_REQUEST: &str = "tmp/localdesktop-keyboard"; fn bridge() -> &'static (Mutex, Condvar) { @@ -138,6 +139,10 @@ pub fn set_in_use(in_use: bool) { } } +pub fn keyboard_inset() -> i32 { + bridge().0.lock().unwrap().keyboard_inset +} + pub fn drain_pointer_events() -> Vec { bridge() .0 @@ -195,7 +200,7 @@ pub fn start(env: &mut JNIEnv, android_app: &AndroidApp) { call_java(env, android_app, "start"); } -/// Open the Android keyboard over the trackpad. +/// Open the Android keyboard, over the trackpad or on the phone's screen. pub fn show_keyboard(env: &mut JNIEnv, android_app: &AndroidApp) { call_java(env, android_app, "showKeyboard"); } @@ -329,3 +334,16 @@ pub extern "system" fn Java_io_github_localdesktop_TrackpadControls_nativeOnKey( }; accessibility::inject_key(scancode, state, event_time_ms.max(0) as u64); } + +#[no_mangle] +pub extern "system" fn Java_io_github_localdesktop_PhoneKeyboard_nativeOnInset( + _env: JNIEnv, + _class: JClass, + bottom: jint, +) { + let mut bridge = bridge().0.lock().unwrap(); + if bridge.keyboard_inset != bottom { + bridge.keyboard_inset = bottom; + notify(&bridge, AppUserEvent::KeyboardInset); + } +} diff --git a/src/android/java/io/github/localdesktop/ExternalDisplay.java b/src/android/java/io/github/localdesktop/ExternalDisplay.java index 236afc61..838b1783 100644 --- a/src/android/java/io/github/localdesktop/ExternalDisplay.java +++ b/src/android/java/io/github/localdesktop/ExternalDisplay.java @@ -80,13 +80,18 @@ public void run() { }); } - /** A text field on the desktop took focus: type into it with the Android keyboard. */ - public static void showKeyboard(Activity activity) { + /** + * A text field on the desktop took focus: type into it with the Android keyboard, over the + * trackpad or on the phone's own screen. + */ + public static void showKeyboard(final Activity activity) { activity.runOnUiThread(new Runnable() { @Override public void run() { if (controls != null) { controls.showKeyboard(); + } else { + PhoneKeyboard.show(activity); } } }); @@ -125,6 +130,7 @@ private static void update() { presentation = null; return; } + PhoneKeyboard.hide(); controls = new TrackpadControls(activity); controls.show(); Toast.makeText(activity, "The desktop is on the external display. Use this screen as a trackpad.", diff --git a/src/android/java/io/github/localdesktop/PhoneKeyboard.java b/src/android/java/io/github/localdesktop/PhoneKeyboard.java new file mode 100644 index 00000000..9f0c0091 --- /dev/null +++ b/src/android/java/io/github/localdesktop/PhoneKeyboard.java @@ -0,0 +1,109 @@ +package io.github.localdesktop; + +import android.app.Activity; +import android.content.Context; +import android.os.Build; +import android.text.InputType; +import android.view.KeyEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowInsets; +import android.view.inputmethod.BaseInputConnection; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputMethodManager; + +/** + * The Android keyboard on the phone's own screen, typing into the desktop shown there. While it + * is up, the desktop fits above it. + */ +final class PhoneKeyboard { + private static KeyInput input; + + private PhoneKeyboard() {} + + static void show(Activity activity) { + final KeyInput input = attach(activity); + input.setFocusableInTouchMode(true); + input.requestFocus(); + // After the input method has connected to the newly focused view. + input.post(new Runnable() { + @Override + public void run() { + InputMethodManager imm = (InputMethodManager) + input.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + imm.showSoftInput(input, 0); + } + }); + } + + static void hide() { + if (input == null) { + return; + } + InputMethodManager imm = (InputMethodManager) + input.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(input.getWindowToken(), 0); + input.setFocusableInTouchMode(false); + input.clearFocus(); + } + + private static KeyInput attach(Activity activity) { + if (input != null && input.getContext() == activity) { + return input; + } + input = new KeyInput(activity); + activity.addContentView(input, new ViewGroup.LayoutParams(1, 1)); + if (Build.VERSION.SDK_INT >= 30) { + input.setOnApplyWindowInsetsListener(new View.OnApplyWindowInsetsListener() { + @Override + public WindowInsets onApplyWindowInsets(View view, WindowInsets insets) { + int bottom = insets.isVisible(WindowInsets.Type.ime()) + ? insets.getInsets(WindowInsets.Type.ime()).bottom : 0; + nativeOnInset(bottom); + if (bottom == 0) { + // Put away with the back gesture: the next tap on a text field asks again. + view.setFocusableInTouchMode(false); + } + return insets; + } + }); + } + return input; + } + + /** Takes what the Android keyboard types and passes it on as key presses. */ + private static final class KeyInput extends View { + KeyInput(Context context) { + super(context); + } + + @Override + public boolean onCheckIsTextEditor() { + return true; + } + + @Override + public InputConnection onCreateInputConnection(EditorInfo outAttrs) { + // No suggestions, so each character arrives as soon as it is typed. + outAttrs.inputType = InputType.TYPE_CLASS_TEXT + | InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD + | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS; + outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN + | EditorInfo.IME_FLAG_NO_EXTRACT_UI + | EditorInfo.IME_ACTION_NONE; + return new BaseInputConnection(this, false) { + @Override + public boolean sendKeyEvent(KeyEvent event) { + // Straight to the desktop: the activity's own key events go to native code, + // which doesn't see keys the input method makes up. + TrackpadControls.forwardKey(event); + return true; + } + }; + } + } + + /** How much of the screen's bottom the keyboard covers, in pixels; 0 once it is gone. */ + private static native void nativeOnInset(int bottom); +} diff --git a/src/android/java/io/github/localdesktop/SettingsActivity.java b/src/android/java/io/github/localdesktop/SettingsActivity.java new file mode 100644 index 00000000..92cbe0cd --- /dev/null +++ b/src/android/java/io/github/localdesktop/SettingsActivity.java @@ -0,0 +1,230 @@ +package io.github.localdesktop; + +import android.app.Activity; +import android.app.ActivityManager; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.graphics.Typeface; +import android.os.Build; +import android.os.Bundle; +import android.os.Process; +import android.text.InputType; +import android.util.TypedValue; +import android.view.View; +import android.view.WindowInsets; +import android.widget.Button; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.Switch; +import android.widget.TextView; +import android.widget.Toast; + +/** + * Local Desktop's settings, opened from the app's page in Android's settings. It runs in its own + * process, so a desktop that is stuck or keeps crashing can still be fixed from here. + * + * The desktop reads these when it starts (see `core::config` and `ApplicationContext`), and + * writes what it starts with to the "defaults" preferences for "Restore defaults". + */ +public final class SettingsActivity extends Activity { + static final String PREFERENCES = "settings"; + static final String DEFAULTS = "defaults"; + private static final String[] TEXTS = {"username", "check", "install", "launch"}; + + private Switch androidKeyboard; + private final EditText[] texts = new EditText[TEXTS.length]; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (getActionBar() != null) { + // Android 15 lays the page out under it; the title goes in the page instead. + getActionBar().hide(); + } + LinearLayout list = new LinearLayout(this); + list.setOrientation(LinearLayout.VERTICAL); + list.setPadding(dp(16), dp(8), dp(16), dp(24)); + TextView title = new TextView(this); + title.setText("Local Desktop settings"); + title.setTextSize(TypedValue.COMPLEX_UNIT_SP, 28); + title.setPadding(0, dp(24), 0, 0); + list.addView(title); + + heading(list, "Desktop"); + androidKeyboard = new Switch(this); + androidKeyboard.setText("Use the Android keyboard"); + androidKeyboard.setTextSize(TypedValue.COMPLEX_UNIT_SP, 16); + androidKeyboard.setPadding(0, dp(12), 0, 0); + list.addView(androidKeyboard); + note(list, "Your phone's keyboard comes up when a text field takes focus. Off: Squeekboard, the desktop's own on-screen keyboard."); + texts[0] = field(list, "User", false); + note(list, "Log in as this user. It has to exist in Linux first."); + + heading(list, "Commands"); + note(list, "How Local Desktop checks for, installs and starts the desktop environment."); + texts[1] = field(list, "Check", true); + texts[2] = field(list, "Install", true); + texts[3] = field(list, "Launch", true); + + Button restore = new Button(this); + restore.setText("Restore defaults"); + restore.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + getSharedPreferences(PREFERENCES, MODE_PRIVATE).edit() + .remove("android_keyboard").remove(TEXTS[0]).remove(TEXTS[1]) + .remove(TEXTS[2]).remove(TEXTS[3]).commit(); + load(); + } + }); + Button restart = new Button(this); + restart.setText("Restart Local Desktop"); + restart.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + save(); + restartDesktop(); + } + }); + LinearLayout buttons = new LinearLayout(this); + buttons.setPadding(0, dp(16), 0, 0); + buttons.addView(restore); + buttons.addView(restart); + list.addView(buttons); + note(list, "Changes apply the next time Local Desktop starts."); + + // Opens at the top, without a field taking focus and bringing up the keyboard. + list.setFocusableInTouchMode(true); + list.requestFocus(); + final ScrollView scroll = new ScrollView(this); + scroll.addView(list); + if (Build.VERSION.SDK_INT >= 30) { + // Clear of the status and navigation bars, which Android 15 draws over the page. + scroll.setOnApplyWindowInsetsListener(new View.OnApplyWindowInsetsListener() { + @Override + public WindowInsets onApplyWindowInsets(View view, WindowInsets insets) { + android.graphics.Insets bars = insets.getInsets( + WindowInsets.Type.systemBars() | WindowInsets.Type.ime()); + view.setPadding(bars.left, bars.top, bars.right, bars.bottom); + return insets; + } + }); + } + setContentView(scroll); + load(); + } + + @Override + protected void onPause() { + super.onPause(); + save(); + } + + /** What is set, and the defaults for the rest. */ + private void load() { + SharedPreferences preferences = getSharedPreferences(PREFERENCES, MODE_PRIVATE); + SharedPreferences defaults = getSharedPreferences(DEFAULTS, MODE_PRIVATE); + androidKeyboard.setChecked(preferences.getBoolean("android_keyboard", + defaults.getBoolean("android_keyboard", true))); + for (int i = 0; i < TEXTS.length; i++) { + texts[i].setText(preferences.getString(TEXTS[i], defaults.getString(TEXTS[i], ""))); + } + } + + /** + * Keeps only what differs from the defaults, so a default that changes in an update still + * reaches settings nobody touched. + */ + private void save() { + SharedPreferences defaults = getSharedPreferences(DEFAULTS, MODE_PRIVATE); + SharedPreferences.Editor editor = getSharedPreferences(PREFERENCES, MODE_PRIVATE).edit(); + if (androidKeyboard.isChecked() == defaults.getBoolean("android_keyboard", true)) { + editor.remove("android_keyboard"); + } else { + editor.putBoolean("android_keyboard", androidKeyboard.isChecked()); + } + for (int i = 0; i < TEXTS.length; i++) { + String value = texts[i].getText().toString().trim(); + if (value.isEmpty() || value.equals(defaults.getString(TEXTS[i], null))) { + editor.remove(TEXTS[i]); + } else { + editor.putString(TEXTS[i], value); + } + } + // Written before the desktop restarts and reads them. + editor.commit(); + } + + /** Ends the desktop's process, which takes its Linux processes with it, and starts it again. */ + private void restartDesktop() { + ActivityManager manager = (ActivityManager) getSystemService(Context.ACTIVITY_SERVICE); + if (manager.getRunningAppProcesses() != null) { + for (ActivityManager.RunningAppProcessInfo info : manager.getRunningAppProcesses()) { + if (info.processName.equals(getPackageName())) { + Process.killProcess(info.pid); + } + } + } + Intent launch = getPackageManager().getLaunchIntentForPackage(getPackageName()); + if (launch == null) { + Toast.makeText(this, "Open Local Desktop to apply the changes.", Toast.LENGTH_LONG).show(); + return; + } + launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + startActivity(launch); + finish(); + } + + private void heading(LinearLayout list, String text) { + TextView view = new TextView(this); + view.setText(text); + view.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14); + view.setTypeface(Typeface.DEFAULT_BOLD); + view.setTextColor(accentColor()); + view.setPadding(0, dp(24), 0, 0); + list.addView(view); + } + + private void note(LinearLayout list, String text) { + TextView view = new TextView(this); + view.setText(text); + view.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14); + view.setAlpha(0.7f); + view.setPadding(0, dp(4), 0, 0); + list.addView(view); + } + + private EditText field(LinearLayout list, String label, boolean command) { + TextView title = new TextView(this); + title.setText(label); + title.setTextSize(TypedValue.COMPLEX_UNIT_SP, 16); + title.setPadding(0, dp(16), 0, 0); + list.addView(title); + EditText edit = new EditText(this); + // Empty until Local Desktop has started once and written its defaults; empty means those. + edit.setHint("Default"); + edit.setTextSize(TypedValue.COMPLEX_UNIT_SP, command ? 13 : 16); + if (command) { + edit.setTypeface(Typeface.MONOSPACE); + edit.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE + | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); + } else { + edit.setSingleLine(true); + edit.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); + } + list.addView(edit); + return edit; + } + + private int accentColor() { + TypedValue value = new TypedValue(); + getTheme().resolveAttribute(android.R.attr.colorAccent, value, true); + return value.data; + } + + private int dp(int value) { + return Math.round(value * getResources().getDisplayMetrics().density); + } +} diff --git a/src/android/proguard-rules.pro b/src/android/proguard-rules.pro index 70cd193a..10860009 100644 --- a/src/android/proguard-rules.pro +++ b/src/android/proguard-rules.pro @@ -13,3 +13,7 @@ -keep class io.github.localdesktop.TrackpadControls { native ; } + +-keep class io.github.localdesktop.PhoneKeyboard { + native ; +} diff --git a/src/android/proot/setup.rs b/src/android/proot/setup.rs index cdb5e1a2..83aa939b 100644 --- a/src/android/proot/setup.rs +++ b/src/android/proot/setup.rs @@ -1019,9 +1019,8 @@ xfconf-query -c xfce4-panel -p /panels/panel-2/autohide-behavior -s 0 2>/dev/nul ), ); - // Squeekboard types through labwc's virtual-keyboard protocol and pops up when a text field - // gets focus. Onboard can only type through X11 (XTest) or /dev/uinput, neither of which - // reaches Wayland apps here, and as a regular window it takes focus from what it types into. + // Squeekboard notices when a text field gets focus, through labwc's input-method protocol, and + // types itself or has the Android keyboard type instead (see `localdesktop-keyboard-relay`). let _ = fs::write( fs_root .join("usr/share/glib-2.0/schemas") @@ -1032,6 +1031,12 @@ xfconf-query -c xfce4-panel -p /panels/panel-2/autohide-behavior -s 0 2>/dev/nul write_executable( &fs_root.join("usr/local/bin/localdesktop-keyboard"), r#"#!/bin/sh +LOCALDESKTOP_TOUCH_KEYBOARD=1 +. /tmp/localdesktop-output 2>/dev/null +if [ "$LOCALDESKTOP_TOUCH_KEYBOARD" = 0 ]; then + touch /tmp/localdesktop-keyboard + exit +fi osk() { gdbus call --session --dest sm.puri.OSK0 --object-path /sm/puri/OSK0 "$@"; } case "$(osk --method org.freedesktop.DBus.Properties.Get sm.puri.OSK0 Visible 2>/dev/null)" in *true*) osk --method sm.puri.OSK0.SetVisible false >/dev/null ;; @@ -1046,7 +1051,7 @@ esac r#"[Desktop Entry] Type=Application Name=On-screen keyboard -Comment=Show or hide the on-screen keyboard +Comment=Show the on-screen keyboard Exec=/usr/local/bin/localdesktop-keyboard Icon=input-keyboard Terminal=false @@ -1228,8 +1233,8 @@ done "#, ) .expect("Failed to write labwc rc.xml defaults"); - // While the desktop is on an external display the phone is its keyboard: when a text field - // brings up Squeekboard, put it away and have the app open the Android keyboard instead. + // Unless it is to type (see `write_guest_output_state`), Squeekboard only tells when a text + // field gets focus: put it away and have the app open the Android keyboard instead. write_executable( &fs_root.join("usr/local/bin/localdesktop-keyboard-relay"), r#"#!/bin/sh @@ -1443,6 +1448,7 @@ pub fn setup(android_app: AndroidApp) -> PolarBearBackend { touch_fingers: 0, cursor_position: (0.0, 0.0).into(), external_buttons: 0, + keyboard_inset: 0, android_app, }) } else { diff --git a/src/android/utils/application_context.rs b/src/android/utils/application_context.rs index 05cbd9f5..a6b8a88f 100644 --- a/src/android/utils/application_context.rs +++ b/src/android/utils/application_context.rs @@ -1,6 +1,6 @@ use crate::{ - android::utils::ndk::run_in_jvm, - core::config::{arch_fs_root, init_arch_fs_root, parse_config, LocalConfig, CONFIG_FILE}, + android::utils::{ndk::run_in_jvm, settings}, + core::config::{arch_fs_root, init_arch_fs_root, LocalConfig}, }; use jni::{ objects::{JObject, JString}, @@ -42,8 +42,7 @@ impl ApplicationContext { "XLOCALEDIR", format!("{}/usr/share/X11/locale", arch_fs_root()), ); - let full_config_path = format!("{}{}", arch_fs_root(), CONFIG_FILE); - let local_config = parse_config(full_config_path); + let local_config = settings::load(&mut env, &activity); let permission_all_files_access = Self::is_all_files_access_granted(android_app); { diff --git a/src/android/utils/settings.rs b/src/android/utils/settings.rs new file mode 100644 index 00000000..eb05c187 --- /dev/null +++ b/src/android/utils/settings.rs @@ -0,0 +1,140 @@ +//! The settings from "Additional settings in the app" in Android's App info (`SettingsActivity`), kept in +//! the app's SharedPreferences: only what differs from the defaults is stored there. + +use crate::core::config::LocalConfig; +use jni::{ + objects::{JObject, JString, JValue}, + JNIEnv, +}; + +const PREFERENCES: &str = "settings"; +const DEFAULTS: &str = "defaults"; + +/// Write the defaults for the settings page, and return the settings to start with. +pub fn load(env: &mut JNIEnv, activity: &JObject) -> LocalConfig { + let defaults = LocalConfig::default(); + let result = (|| -> jni::errors::Result { + let stored = preferences(env, activity, DEFAULTS)?; + let editor = env + .call_method( + &stored, + "edit", + "()Landroid/content/SharedPreferences$Editor;", + &[], + )? + .l()?; + put_string(env, &editor, "username", &defaults.user.username)?; + put_string(env, &editor, "check", &defaults.command.check)?; + put_string(env, &editor, "install", &defaults.command.install)?; + put_string(env, &editor, "launch", &defaults.command.launch)?; + put_boolean(env, &editor, "android_keyboard", defaults.android_keyboard)?; + env.call_method(&editor, "apply", "()V", &[])?; + + let settings = preferences(env, activity, PREFERENCES)?; + let mut config = defaults.clone(); + if let Some(username) = get_string(env, &settings, "username")? { + config.user.username = username; + } + if let Some(check) = get_string(env, &settings, "check")? { + config.command.check = check; + } + if let Some(install) = get_string(env, &settings, "install")? { + config.command.install = install; + } + if let Some(launch) = get_string(env, &settings, "launch")? { + config.command.launch = launch; + } + config.android_keyboard = + get_boolean(env, &settings, "android_keyboard", config.android_keyboard)?; + Ok(config) + })(); + result.unwrap_or_else(|error| { + log::error!("Failed to read the settings, using the defaults: {error}"); + let _ = env.exception_clear(); + defaults + }) +} + +fn preferences<'local>( + env: &mut JNIEnv<'local>, + activity: &JObject, + name: &str, +) -> jni::errors::Result> { + let name = env.new_string(name)?; + env.call_method( + activity, + "getSharedPreferences", + "(Ljava/lang/String;I)Landroid/content/SharedPreferences;", + &[(&name).into(), JValue::Int(0)], + )? + .l() +} + +fn get_string( + env: &mut JNIEnv, + preferences: &JObject, + key: &str, +) -> jni::errors::Result> { + let key = env.new_string(key)?; + let value = env + .call_method( + preferences, + "getString", + "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;", + &[(&key).into(), (&JObject::null()).into()], + )? + .l()?; + if value.is_null() { + return Ok(None); + } + Ok(Some(env.get_string(&JString::from(value))?.into())) +} + +fn get_boolean( + env: &mut JNIEnv, + preferences: &JObject, + key: &str, + default: bool, +) -> jni::errors::Result { + let key = env.new_string(key)?; + env.call_method( + preferences, + "getBoolean", + "(Ljava/lang/String;Z)Z", + &[(&key).into(), JValue::Bool(default.into())], + )? + .z() +} + +fn put_string( + env: &mut JNIEnv, + editor: &JObject, + key: &str, + value: &str, +) -> jni::errors::Result<()> { + let key = env.new_string(key)?; + let value = env.new_string(value)?; + env.call_method( + editor, + "putString", + "(Ljava/lang/String;Ljava/lang/String;)Landroid/content/SharedPreferences$Editor;", + &[(&key).into(), (&value).into()], + )?; + Ok(()) +} + +fn put_boolean( + env: &mut JNIEnv, + editor: &JObject, + key: &str, + value: bool, +) -> jni::errors::Result<()> { + let key = env.new_string(key)?; + env.call_method( + editor, + "putBoolean", + "(Ljava/lang/String;Z)Landroid/content/SharedPreferences$Editor;", + &[(&key).into(), JValue::Bool(value.into())], + )?; + Ok(()) +} diff --git a/src/bin/build_apk.rs b/src/bin/build_apk.rs index 3b7e0f96..a538c0fa 100644 --- a/src/bin/build_apk.rs +++ b/src/bin/build_apk.rs @@ -1194,6 +1194,10 @@ pub mod apk { pub intent_filters: Vec, #[serde(rename(serialize = "android:colorMode"))] pub color_mode: Option, + #[serde(rename(serialize = "android:process"))] + pub process: Option, + #[serde(rename(serialize = "android:theme"))] + pub theme: Option, } /// Android [service element](https://developer.android.com/guide/topics/manifest/service-element). diff --git a/src/core/config.rs b/src/core/config.rs index 5a752a86..be1514d9 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -1,10 +1,4 @@ -use serde::{Deserialize, Serialize}; -use std::{ - fs::{self, OpenOptions}, - io::Write, - path::Path, - sync::OnceLock, -}; +use std::{fs, path::Path, sync::OnceLock}; pub const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -73,26 +67,26 @@ pub const PIPEWIRE_GUEST_RUNTIME_DIR: &str = "/tmp"; /// PipeWire-Pulse socket as seen from inside the proot guest. pub const PULSE_GUEST_SERVER: &str = "unix:/tmp/pulse/native"; -/// Make sure the config keys are all lowercase, and config values are single-line. Use \n for multi-line config values if needed -/// If a key exists multiple time, the first entry is applied -/// If a `try_` config exsists multiple time, the last entry is applied -/// But in general, it is **invalid** to have duplicated config keys inside a TOML file -pub const CONFIG_FILE: &str = "/etc/localdesktop/localdesktop.toml"; - -#[derive(Debug, Serialize, Deserialize, Default, Clone)] +/// Settings from "Additional settings in the app" in Android's App info (see `utils::settings`). +#[derive(Debug, Clone)] pub struct LocalConfig { - #[serde(default)] pub user: UserConfig, - - /// What happens if we don't assign this `#[serde(default)]` attribute? - /// The answer: If the user omits the `[command]` group, the WHOLE config fails to parse - /// => The default `[user]` group is applied (with `username=root`) even if the `[user]` settings are completely valid. - /// => So make sure that every config group has a `#[serde(default)]` attribute to avoid invalid sections breaking unrelated parts of the config. - #[serde(default)] pub command: CommandConfig, + /// The Android keyboard types into the desktop, instead of Squeekboard. + pub android_keyboard: bool, +} + +impl Default for LocalConfig { + fn default() -> Self { + Self { + user: UserConfig::default(), + command: CommandConfig::default(), + android_keyboard: true, + } + } } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Clone)] pub struct UserConfig { pub username: String, } @@ -105,13 +99,10 @@ impl Default for UserConfig { } } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Clone)] pub struct CommandConfig { - #[serde(default = "default_check")] pub check: String, - #[serde(default = "default_install")] pub install: String, - #[serde(default = "default_launch")] pub launch: String, } @@ -139,174 +130,3 @@ impl Default for CommandConfig { } } } - -/// This function does 2 major tasks: -/// - Read config from `CONFIG_FILE`, and override configs with their `try_*` versions, and return the configs line by line -/// - Write back to the config file, with `try_*` configs commented out -/// -/// **Important**: As each call to this function will comment out the `try_*` config, it is **non-idempotent**. -fn process_config_file(full_config_path: String) -> Vec { - let mut write_back_lines: Vec = vec![]; - let mut effective_config: Vec = vec![]; - - if let Ok(content) = fs::read_to_string(&full_config_path) { - for line in content.lines() { - let trimmed = line.trim(); - - if let Some((key, value)) = trimmed.split_once('=') { - let key = key.trim(); - let value = value.trim(); - - if key.starts_with("try_") { - // Comment out the `try_*` configs - write_back_lines.push(format!("# {}", trimmed)); - - // Prefer the `try_*` configs - let actual_key = key.trim_start_matches("try_"); - if let Some(line_index) = effective_config - .iter() - .position(|line| line.starts_with(&format!("{}=", actual_key))) - { - // Config exists, overriding - effective_config[line_index] = format!("{}={}", actual_key, value); - } else { - // Config does not exist, appending - effective_config.push(format!("{}={}", actual_key, value)); - // Make sure there are no spaces around = so that the check existing key logic works - } - } else { - // Keep the config as is - write_back_lines.push(trimmed.to_string()); - - if effective_config - .iter() - .any(|line| line.starts_with(&format!("{}=", key))) - { - // If already overridden by try_ version, skip inserting - } else { - // Config does not exist, appending - effective_config.push(format!("{}={}", key, value)); // Make sure there are no spaces around = so that the check existing key logic works - } - } - } else { - // Keep the line as is - write_back_lines.push(trimmed.to_string()); - effective_config.push(trimmed.to_string()); - } - } - - // Rewrite config with try_* lines commented out - let _ = OpenOptions::new() - .write(true) - .truncate(true) - .open(&full_config_path) - .and_then(|mut file| { - for line in &write_back_lines { - writeln!(file, "{}", line)?; - } - Ok(()) - }); - } - - // Convert effective config back to lines - effective_config -} - -pub fn parse_config(full_config_path: String) -> LocalConfig { - let lines = process_config_file(full_config_path); - let content = lines.join("\n"); - if let Ok(config) = toml::from_str::(&content) { - return config; - } - // Config malformed, use the default config and the user can modify it again - let default_config = LocalConfig::default(); - default_config -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - use tempfile::tempdir; - - fn with_config_file(content: &str, f: impl Fn(String)) -> () { - let dir = tempdir().unwrap(); - let base_dir = dir.path().to_str().unwrap(); - let path = format!("{}/etc/localdesktop", base_dir); - fs::create_dir_all(&path).unwrap(); - let file_path = format!("{}/localdesktop.toml", path); - fs::write(&file_path, content).unwrap(); - f(file_path) - } - - #[test] - fn should_handle_configs_without_try() { - with_config_file( - r#" - [user] - username = "alice" - - [command] - check = "check-cmd" - install = "install-cmd" - launch = "launch-cmd" - "#, - |full_config_path| { - let config = parse_config(full_config_path); - assert_eq!(config.user.username, "alice"); - assert_eq!(config.command.check, "check-cmd"); - assert_eq!(config.command.install, "install-cmd"); - assert_eq!(config.command.launch, "launch-cmd"); - }, - ); - } - - #[test] - fn should_handle_configs_with_try() { - with_config_file( - r#" - [user] - username = "root" - try_username = "testuser" - - [command] - check = "check-cmd" - try_check = "try-check" - install = "install-cmd" - launch = "launch-cmd" - "#, - |full_config_path| { - let config = parse_config(full_config_path); - assert_eq!(config.user.username, "testuser"); - assert_eq!(config.command.check, "try-check"); - assert_eq!(config.command.install, "install-cmd") - }, - ); - } - - #[test] - fn should_comment_out_try_configs() { - with_config_file( - r#" - username = "root" - try_username = "commented" - - check = "normal" - try_check = "try" - "#, - |full_config_path| { - let _ = parse_config(full_config_path.clone()); // This triggers rewriting the config file - let content = fs::read_to_string(full_config_path).unwrap(); - - assert!( - content.contains("# try_username = \"commented\""), - "❌ `try_username` is not commented out after being applied" - ); - assert!( - content.contains("# try_check = \"try\""), - "❌ `try_check` is not commented out after being applied" - ); - }, - ); - } -} diff --git a/src/lib.rs b/src/lib.rs index 21a3470b..9aab551e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,6 +26,7 @@ pub mod android { pub mod application_context; pub mod fullscreen_immersive; pub mod ndk; + pub mod settings; pub mod webview; } }