diff --git a/Cargo.lock b/Cargo.lock index 2653583..8e0968c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2348,7 +2348,7 @@ dependencies = [ [[package]] name = "localdesktop" -version = "1.1.1" +version = "1.2.0" dependencies = [ "android-sdkmanager-rs", "android_logger", diff --git a/Cargo.toml b/Cargo.toml index 30ecfe9..cd99f17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "localdesktop" -version = "1.1.1" +version = "1.2.0" edition = "2021" build = "build.rs" default-run = "build_apk" diff --git a/src/android/backend/wayland/compositor.rs b/src/android/backend/wayland/compositor.rs index ec02796..9d4490a 100644 --- a/src/android/backend/wayland/compositor.rs +++ b/src/android/backend/wayland/compositor.rs @@ -1,8 +1,8 @@ use super::bind::bind_socket; use smithay::{ backend::renderer::utils::on_commit_buffer_handler, - delegate_compositor, delegate_data_device, delegate_output, delegate_seat, delegate_shm, - delegate_xdg_shell, + delegate_compositor, delegate_data_device, delegate_output, delegate_relative_pointer, + delegate_seat, delegate_shm, delegate_xdg_shell, input::{keyboard::KeyboardHandle, touch::TouchHandle, Seat, SeatHandler, SeatState}, output::Output, reexports::{ @@ -17,6 +17,7 @@ use smithay::{ SurfaceAttributes, TraversalAction, }, output::OutputHandler, + relative_pointer::RelativePointerManagerState, selection::{ data_device::{ ClientDndGrabHandler, DataDeviceHandler, DataDeviceState, ServerDndGrabHandler, @@ -59,6 +60,10 @@ pub struct State { pub shm_state: ShmState, pub data_device_state: DataDeviceState, pub seat_state: SeatState, + /// Xwayland sends scrolling through its relative pointer device, and pointer motion through + /// it too only when the compositor reports relative motion. Without this, X apps see the + /// pointer switch devices around every scroll and GTK drops the scroll. + pub relative_pointer_state: RelativePointerManagerState, pub size: Size, /// What the client wants the cursor to look like. pub cursor_image: CursorImageStatus, @@ -191,6 +196,7 @@ delegate_shm!(State); delegate_seat!(State); delegate_data_device!(State); delegate_output!(State); +delegate_relative_pointer!(State); impl Compositor { pub fn build() -> Result> { @@ -218,6 +224,7 @@ impl Compositor { shm_state: ShmState::new::(&dh, vec![]), data_device_state: DataDeviceState::new::(&dh), seat_state, + relative_pointer_state: RelativePointerManagerState::new::(&dh), size: (1920, 1080).into(), cursor_image: CursorImageStatus::default_named(), }; diff --git a/src/android/backend/wayland/event_handler.rs b/src/android/backend/wayland/event_handler.rs index e78769c..35677e3 100644 --- a/src/android/backend/wayland/event_handler.rs +++ b/src/android/backend/wayland/event_handler.rs @@ -75,15 +75,26 @@ fn emit_pointer_motion( let state = &mut compositor.state; if let Some(focus) = pointer_focus(state) { let serial = SERIAL_COUNTER.next_serial(); + let delta = Point::from((x, y)) - pointer.current_location(); pointer.motion( state, - Some(focus), + Some(focus.clone()), &pointer::MotionEvent { location: (x, y).into(), serial, time, }, ); + // In the same frame, so Xwayland moves its relative pointer device (see `State`). + pointer.relative_motion( + state, + Some(focus), + &pointer::RelativeMotionEvent { + delta, + delta_unaccel: delta, + utime: time as u64 * 1000, + }, + ); pointer.frame(state); } } @@ -293,22 +304,12 @@ pub fn handle(event: CentralizedEvent, backend: &mut WaylandBackend, event_loop: } } InputEvent::PointerMotionAbsolute { event, .. } => { - let compositor = &mut backend.compositor; - let pointer = compositor.pointer.clone(); - let serial = SERIAL_COUNTER.next_serial(); - - if let Some(surface) = get_surface(&compositor.state) { - pointer.motion( - &mut compositor.state, - Some((surface.wl_surface().clone(), (0f64, 0f64).into())), - &pointer::MotionEvent { - location: (event.x(), event.y()).into(), - serial, - time: event.time_msec(), - }, - ); - } - pointer.frame(&mut compositor.state); + emit_pointer_motion( + &mut backend.compositor, + event.x(), + event.y(), + event.time_msec(), + ); } InputEvent::PointerButton { event, .. } => { let serial = SERIAL_COUNTER.next_serial(); @@ -468,14 +469,22 @@ pub fn paint_trackpad(backend: &mut WaylandBackend) { } } -/// A mouse on the external display: it points where it is, and its buttons and wheel go -/// straight to the desktop. +/// 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) { let time = event.time_ms as u32; let size = backend.compositor.state.size; + let (x, y) = if event.relative { + // Mouse counts, scaled up on monitors larger than 1080p so crossing them is no chore. + let speed = (size.w as f64 / 1920.0).max(1.0); + let cursor = backend.cursor_position; + (cursor.x + event.x * speed, cursor.y + event.y * speed) + } else { + (event.x, event.y) + }; backend.cursor_position = ( - event.x.clamp(0.0, (size.w - 1).max(0) as f64), - event.y.clamp(0.0, (size.h - 1).max(0) as f64), + x.clamp(0.0, (size.w - 1).max(0) as f64), + y.clamp(0.0, (size.h - 1).max(0) as f64), ) .into(); let cursor = backend.cursor_position; @@ -592,7 +601,7 @@ fn redraw(backend: &mut WaylandBackend) -> Result<(), String> { .render(&mut framebuffer, size, Transform::Flipped180) .map_err(|error| format!("Failed to render frame: {error:?}"))?; frame - .clear(Color32F::new(0.1, 0.0, 0.0, 1.0), &[damage]) + .clear(Color32F::new(0.0, 0.0, 0.0, 1.0), &[damage]) .map_err(|error| format!("Failed to clear frame: {error:?}"))?; draw_render_elements(&mut frame, 1.0, &elements, &[damage]) .map_err(|error| format!("Failed to draw render elements: {error:?}"))?; diff --git a/src/android/dex/classes.dex b/src/android/dex/classes.dex index 69d7252..4a37509 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 728641a..e4d9090 100644 --- a/src/android/external_display.rs +++ b/src/android/external_display.rs @@ -61,11 +61,13 @@ impl Drop for ExternalSurface { } } -/// A mouse (or touch) on the external monitor, in its pixels. +/// A mouse (or touch) on the external monitor, or a mouse plugged into the phone. #[derive(Debug, Clone, Copy)] pub struct PointerEvent { + /// Where on the monitor, in its pixels, or how far a phone's mouse moved when `relative`. pub x: f64, pub y: f64, + pub relative: bool, /// `MotionEvent.getButtonState()`. pub buttons: i32, /// `AXIS_HSCROLL` and `AXIS_VSCROLL`, in wheel notches. @@ -231,16 +233,44 @@ pub extern "system" fn Java_app_polarbear_ExternalDisplay_nativeOnPointer( scroll_y: jfloat, event_time_ms: jlong, ) { - let mut bridge = bridge().0.lock().unwrap(); - let wake = bridge.pointer_events.is_empty(); - bridge.pointer_events.push_back(PointerEvent { + push_pointer_event(PointerEvent { x: x as f64, y: y as f64, + relative: false, buttons, scroll_x: scroll_x as f64, scroll_y: scroll_y as f64, time_ms: event_time_ms.max(0) as u64, }); +} + +/// A mouse plugged into the phone, captured while it is a trackpad. +#[no_mangle] +pub extern "system" fn Java_app_polarbear_TrackpadControls_nativeOnMouse( + _env: JNIEnv, + _class: JClass, + dx: jfloat, + dy: jfloat, + buttons: jint, + scroll_x: jfloat, + scroll_y: jfloat, + event_time_ms: jlong, +) { + push_pointer_event(PointerEvent { + x: dx as f64, + y: dy as f64, + relative: true, + buttons, + scroll_x: scroll_x as f64, + scroll_y: scroll_y as f64, + time_ms: event_time_ms.max(0) as u64, + }); +} + +fn push_pointer_event(event: PointerEvent) { + let mut bridge = bridge().0.lock().unwrap(); + let wake = bridge.pointer_events.is_empty(); + bridge.pointer_events.push_back(event); if wake { notify(&bridge, AppUserEvent::ExternalPointer); } diff --git a/src/android/java/app/polarbear/ExternalDisplay.java b/src/android/java/app/polarbear/ExternalDisplay.java index 02733a6..9990b7f 100644 --- a/src/android/java/app/polarbear/ExternalDisplay.java +++ b/src/android/java/app/polarbear/ExternalDisplay.java @@ -151,13 +151,13 @@ protected void onCreate(Bundle savedInstanceState) { view.setOnGenericMotionListener(new View.OnGenericMotionListener() { @Override public boolean onGenericMotion(View view, MotionEvent event) { - return onPointer(event); + return onPointer(event, false); } }); view.setOnTouchListener(new View.OnTouchListener() { @Override public boolean onTouch(View view, MotionEvent event) { - return onPointer(event); + return onPointer(event, true); } }); setContentView(view); @@ -170,10 +170,12 @@ public boolean dispatchKeyEvent(KeyEvent event) { return true; } - private boolean onPointer(MotionEvent event) { + /** `pressing`: a touch event, which Android only sends while something is pressed. */ + private boolean onPointer(MotionEvent event, boolean pressing) { int buttons = event.getButtonState(); - // A touch screen or pen on the monitor clicks like a mouse. - if (!event.isFromSource(InputDevice.SOURCE_MOUSE)) { + // A touch screen or pen on the monitor clicks like a mouse, and so does a mouse that + // reports no button (a touchpad tapping, an injected click). + if (!event.isFromSource(InputDevice.SOURCE_MOUSE) || (pressing && buttons == 0)) { int action = event.getActionMasked(); buttons = action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL ? 0 : MotionEvent.BUTTON_PRIMARY; diff --git a/src/android/java/app/polarbear/KeyboardShortcutsPrompt.java b/src/android/java/app/polarbear/KeyboardShortcutsPrompt.java index 7d8473e..161374b 100644 --- a/src/android/java/app/polarbear/KeyboardShortcutsPrompt.java +++ b/src/android/java/app/polarbear/KeyboardShortcutsPrompt.java @@ -6,12 +6,15 @@ import android.content.DialogInterface; import android.content.Intent; import android.content.SharedPreferences; -import android.content.res.Configuration; +import android.os.Bundle; import android.provider.Settings; +import android.view.InputDevice; public final class KeyboardShortcutsPrompt { private static final String PREFS = "keyboard_shortcuts"; private static final String ANSWERED = "answered"; + /** "Not now" for as long as the app runs. */ + private static boolean notNow; private KeyboardShortcutsPrompt() {} @@ -27,38 +30,64 @@ public void run() { private static void show(final Activity activity) { final SharedPreferences prefs = activity.getSharedPreferences(PREFS, Activity.MODE_PRIVATE); if (prefs.getBoolean(ANSWERED, false) - || activity.getResources().getConfiguration().keyboard != Configuration.KEYBOARD_QWERTY + || notNow + || !hasPhysicalKeyboard() || isServiceEnabled(activity)) { return; } CharSequence app = activity.getApplicationInfo().loadLabel(activity.getPackageManager()); new AlertDialog.Builder(activity, android.R.style.Theme_DeviceDefault_Dialog_Alert) - .setTitle("Forward keyboard shortcuts?") + .setTitle("Use Alt+Tab on the desktop?") .setMessage( - "Android keeps some shortcuts of a physical keyboard for itself, such as Alt+Tab and the Meta key. " - + app + " can pass them to the Linux desktop with Android's accessibility service.\n\n" - + "When it is on, " + app + " receives the keys you press on a physical keyboard and uses them " - + "only while the desktop is on screen. It does not read the screen or other apps, and it does " - + "not store or send your keystrokes anywhere.\n\n" - + "You can turn it off at any time in Android's accessibility settings.") + "Android keeps Alt+Tab and the Meta key for itself. The " + app + + " keyboard service passes them to the desktop.\n\n" + + "In the settings that open:\n" + + "1. Tap Downloaded apps (or Installed apps)\n" + + "2. Tap " + app + " and switch it on\n" + + "3. Tap Allow") .setCancelable(false) .setPositiveButton("Turn on", new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { prefs.edit().putBoolean(ANSWERED, true).apply(); - activity.startActivity(new Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)); + activity.startActivity(accessibilitySettings(activity)); } }) - .setNegativeButton("No thanks", new DialogInterface.OnClickListener() { + .setNegativeButton("Not now", new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { - prefs.edit().putBoolean(ANSWERED, true).apply(); + notNow = true; } }) .show(); } + /** Unlike the configuration, this already knows about a keyboard that just connected. */ + private static boolean hasPhysicalKeyboard() { + for (int id : InputDevice.getDeviceIds()) { + InputDevice device = InputDevice.getDevice(id); + if (device != null && !device.isVirtual() + && device.getKeyboardType() == InputDevice.KEYBOARD_TYPE_ALPHABETIC) { + return true; + } + } + return false; + } + + /** + * Android's accessibility settings, asked to highlight our service. Apps cannot open the + * service's own page; Settings apps that don't know the highlight extras just ignore them. + */ + private static Intent accessibilitySettings(Activity activity) { + String key = new ComponentName(activity, KeyboardAccessibilityService.class).flattenToString(); + Bundle args = new Bundle(); + args.putString(":settings:fragment_args_key", key); + return new Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) + .putExtra(":settings:fragment_args_key", key) + .putExtra(":settings:show_fragment_args", args); + } + private static boolean isServiceEnabled(Activity activity) { String enabled = Settings.Secure.getString( activity.getContentResolver(), Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES); diff --git a/src/android/java/app/polarbear/TrackpadControls.java b/src/android/java/app/polarbear/TrackpadControls.java index de11b5b..0d001d8 100644 --- a/src/android/java/app/polarbear/TrackpadControls.java +++ b/src/android/java/app/polarbear/TrackpadControls.java @@ -4,6 +4,7 @@ import android.app.Dialog; import android.content.Context; import android.content.pm.ActivityInfo; +import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; @@ -12,11 +13,14 @@ import android.graphics.drawable.AdaptiveIconDrawable; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; +import android.hardware.input.InputManager; import android.os.Build; import android.os.Bundle; import android.text.InputType; import android.view.Gravity; +import android.view.InputDevice; import android.view.KeyEvent; +import android.view.MotionEvent; import android.view.Surface; import android.view.View; import android.view.Window; @@ -28,11 +32,19 @@ import android.view.inputmethod.InputMethodManager; import android.widget.LinearLayout; -/** Buttons on the phone while it is a trackpad: turn the screen, and type with the Android keyboard. */ -final class TrackpadControls extends Dialog { +/** + * Buttons on the phone while it is a trackpad: turn the screen, and type with the Android + * keyboard. A mouse plugged into the phone moves the cursor on the external display. + */ +final class TrackpadControls extends Dialog implements InputManager.InputDeviceListener { private final Activity activity; private final Pad pad; + private final Badge badge; + private LinearLayout bar; private KeyboardButton keyboard; + /** A mouse is plugged in, so this window keeps focus to capture it. */ + private boolean mouseAttached; + private boolean keyboardAttached; private boolean keyboardShown; /** Waiting for the window to take focus before the keyboard can come up. */ private boolean keyboardPending; @@ -43,12 +55,14 @@ final class TrackpadControls extends Dialog { super(activity); this.activity = activity; pad = new Pad(activity); + badge = new Badge(activity); } @Override public void show() { // Under the buttons. pad.show(); + badge.show(); super.show(); } @@ -64,13 +78,9 @@ protected void onCreate(Bundle savedInstanceState) { // Touches elsewhere reach the trackpad, and keys the activity, until the keyboard is up. window.addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE); - window.setGravity(Gravity.TOP | Gravity.RIGHT); - WindowManager.LayoutParams attributes = window.getAttributes(); - attributes.x = dp(Pad.INSET + 8); - attributes.y = dp(Pad.INSET + 8); - window.setAttributes(attributes); + placeInCorner(window, Gravity.RIGHT); - LinearLayout bar = new LinearLayout(getContext()); + bar = new LinearLayout(getContext()); bar.setOrientation(LinearLayout.HORIZONTAL); IconButton rotate = new IconButton(getContext(), IconButton.ROTATE); style(rotate, "Rotate screen"); @@ -88,10 +98,27 @@ public void onClick(View view) { setKeyboardShown(!keyboardShown); } }); + ((LinearLayout.LayoutParams) keyboard.getLayoutParams()).leftMargin = dp(8); bar.addView(rotate); bar.addView(keyboard); setContentView(bar); + if (Build.VERSION.SDK_INT >= 26) { + View.OnCapturedPointerListener mouse = new View.OnCapturedPointerListener() { + @Override + public boolean onCapturedPointer(View view, MotionEvent event) { + return onMouse(event); + } + }; + // Captured events go to whichever of these has focus. + bar.setOnCapturedPointerListener(mouse); + keyboard.setOnCapturedPointerListener(mouse); + InputManager inputManager = (InputManager) getContext().getSystemService(Context.INPUT_SERVICE); + inputManager.registerInputDeviceListener(this, null); + updateMouse(); + updateKeyboard(); + } + if (Build.VERSION.SDK_INT >= 30) { // The keyboard can also go away with the back gesture. bar.setOnApplyWindowInsetsListener(new View.OnApplyWindowInsetsListener() { @@ -112,6 +139,11 @@ public WindowInsets onApplyWindowInsets(View view, WindowInsets insets) { @Override public void onWindowFocusChanged(boolean hasFocus) { super.onWindowFocusChanged(hasFocus); + // Capture ends whenever the window loses focus, e.g. to the notification shade. + if (hasFocus && mouseAttached && Build.VERSION.SDK_INT >= 26) { + View focused = getWindow().getDecorView().findFocus(); + (focused != null ? focused : bar).requestPointerCapture(); + } if (hasFocus && keyboardPending) { keyboardPending = false; // After the input method has connected to the newly focused button. @@ -131,11 +163,118 @@ public void run() { @Override public void dismiss() { + if (Build.VERSION.SDK_INT >= 26) { + ((InputManager) getContext().getSystemService(Context.INPUT_SERVICE)).unregisterInputDeviceListener(this); + } setKeyboardShown(false); pad.dismiss(); + badge.dismiss(); super.dismiss(); } + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + // Keys from the Android keyboard, and from a physical one while this window has focus. + if (event.getKeyCode() == KeyEvent.KEYCODE_BACK) { + return super.dispatchKeyEvent(event); + } + forwardKey(event); + return true; + } + + @Override + public void onInputDeviceAdded(int deviceId) { + updateMouse(); + updateKeyboard(); + } + + @Override + public void onInputDeviceRemoved(int deviceId) { + updateMouse(); + updateKeyboard(); + } + + @Override + public void onInputDeviceChanged(int deviceId) { + updateMouse(); + updateKeyboard(); + } + + /** + * Android keeps shortcuts such as Alt+Tab for itself; only the keyboard accessibility service + * passes them on. Offer it as soon as a keyboard connects, not just when the app opens. + */ + private void updateKeyboard() { + boolean attached = false; + for (int id : InputDevice.getDeviceIds()) { + InputDevice device = InputDevice.getDevice(id); + if (device != null && !device.isVirtual() + && device.getKeyboardType() == InputDevice.KEYBOARD_TYPE_ALPHABETIC) { + attached = true; + } + } + if (attached && !keyboardAttached) { + // By name: Google Play builds leave the service and its prompt out. + try { + Class.forName(getClass().getPackage().getName() + ".KeyboardShortcutsPrompt") + .getMethod("showIfNeeded", Activity.class) + .invoke(null, activity); + } catch (ReflectiveOperationException e) { + // Not in this build. + } + } + keyboardAttached = attached; + } + + /** + * With a mouse plugged in, take focus and capture it: Android hides its pointer on the phone, + * and the mouse moves the cursor on the external display instead. + */ + private void updateMouse() { + boolean attached = false; + for (int id : InputDevice.getDeviceIds()) { + InputDevice device = InputDevice.getDevice(id); + // A captured mouse reports itself as relative only, so both count. + if (device != null && !device.isVirtual() + && (device.supportsSource(InputDevice.SOURCE_MOUSE) + || device.supportsSource(InputDevice.SOURCE_MOUSE_RELATIVE))) { + attached = true; + } + } + if (attached == mouseAttached) { + return; + } + mouseAttached = attached; + if (attached) { + getWindow().clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE); + bar.setFocusableInTouchMode(true); + bar.requestFocus(); + } else if (!keyboardShown) { + bar.setFocusableInTouchMode(false); + getWindow().addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE); + } + } + + private boolean onMouse(MotionEvent event) { + // A captured mouse reports how far it moved, batched since the last event. + float dx = event.getX(); + float dy = event.getY(); + for (int i = 0; i < event.getHistorySize(); i++) { + dx += event.getHistoricalX(i); + dy += event.getHistoricalY(i); + } + float scrollX = 0; + float scrollY = 0; + if (event.getActionMasked() == MotionEvent.ACTION_SCROLL) { + dx = 0; + dy = 0; + scrollX = event.getAxisValue(MotionEvent.AXIS_HSCROLL); + scrollY = event.getAxisValue(MotionEvent.AXIS_VSCROLL); + } + nativeOnMouse(dx, dy, event.getButtonState(), scrollX, scrollY, event.getEventTime()); + return true; + } + /** * Turns the whole screen a quarter clockwise, Android keyboard included, for a phone lying * sideways while auto-rotate keeps it upright. @@ -180,17 +319,42 @@ private void setKeyboardShown(boolean shown) { keyboardPending = false; imm.hideSoftInputFromWindow(keyboard.getWindowToken(), 0); keyboard.setFocusableInTouchMode(false); - window.addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE); + if (mouseAttached) { + bar.requestFocus(); + } else { + window.addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE); + } } keyboard.setSelected(shown); keyboard.invalidate(); } + /** + * The buttons' corner of the trackpad, mirrored by the badge's: `Pad.GAP` inside the border + * from the top and the side. Measured from the screen's edges, like the pad, rather than from + * below the status bar and camera cutout. + */ + private static void placeInCorner(Window window, int side) { + window.setGravity(Gravity.TOP | side); + window.addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN + | WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS); + WindowManager.LayoutParams attributes = window.getAttributes(); + float density = window.getContext().getResources().getDisplayMetrics().density; + attributes.x = Math.round((Pad.INSET + Pad.GAP) * density); + attributes.y = Math.round((Pad.INSET + Pad.GAP) * density); + if (Build.VERSION.SDK_INT >= 28) { + attributes.layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; + } + if (Build.VERSION.SDK_INT >= 30) { + attributes.setFitInsetsTypes(0); + } + window.setAttributes(attributes); + } + private void style(View view, String label) { view.setContentDescription(label); - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(dp(44), dp(44)); - params.setMargins(dp(4), 0, dp(4), 0); - view.setLayoutParams(params); + view.setLayoutParams(new LinearLayout.LayoutParams(dp(44), dp(44))); } private int dp(int value) { @@ -198,11 +362,13 @@ private int dp(int value) { } /** - * The trackpad itself, drawn like Samsung DeX: a border, a cross through the middle, and the - * app's logo on top so the right way up is clear. Touches go through it to the activity. + * The trackpad itself, drawn like Samsung DeX: a border and a cross through the middle. + * Touches go through it to the activity. */ private static final class Pad extends Dialog { static final int INSET = 12; + /** Between the border and the logo or buttons in its corners. */ + static final int GAP = 12; Pad(Activity activity) { // Not a floating dialog, which would keep margins around it. @@ -233,25 +399,10 @@ protected void onCreate(Bundle savedInstanceState) { private static final class PadView extends View { private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); - private final Drawable logo; - /** `logo` is an adaptive icon layer, with the art in its middle two thirds. */ - private final boolean logoIsLayer; PadView(Context context) { super(context); paint.setStyle(Paint.Style.STROKE); - Drawable icon = context.getApplicationInfo().loadIcon(context.getPackageManager()); - if (Build.VERSION.SDK_INT >= 33 && icon instanceof AdaptiveIconDrawable - && ((AdaptiveIconDrawable) icon).getMonochrome() != null) { - // The silhouette, in the pad's grey. - logo = ((AdaptiveIconDrawable) icon).getMonochrome().mutate(); - logo.setColorFilter(0xFF8A8A8A, PorterDuff.Mode.SRC_IN); - logoIsLayer = true; - } else { - // An icon without one (a full-bleed picture) as the launcher shows it. - logo = icon; - logoIsLayer = false; - } } @Override @@ -269,18 +420,92 @@ protected void onDraw(Canvas canvas) { paint.setStrokeWidth(1.5f * density); paint.setColor(0xFF4A4A4A); canvas.drawRoundRect(border, 16 * density, 16 * density, paint); + } + } - // Over the top of the cross, on a patch of the pad's black. - int size = Math.round(48 * density); - int left = Math.round(centerX - size / 2f); - int top = Math.round(border.top + 12 * density); - paint.setStyle(Paint.Style.FILL); - paint.setColor(Color.BLACK); - canvas.drawRect(left, top, left + size, top + size, paint); - paint.setStyle(Paint.Style.STROKE); + /** + * The app's logo in the top left corner, across from the buttons, so the right way up is + * clear. Touches go through it to the activity. + */ + private final class Badge extends Dialog { + Badge(Activity activity) { + super(activity); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + requestWindowFeature(Window.FEATURE_NO_TITLE); + super.onCreate(savedInstanceState); + setCancelable(false); + Window window = getWindow(); + window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); + window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); + window.addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE + | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE); + placeInCorner(window, Gravity.LEFT); + LinearLayout frame = new LinearLayout(getContext()); + View logo = new LogoView(getContext()); + style(logo, null); + frame.addView(logo); + setContentView(frame); + } + } + + private static final class LogoView extends View { + private final Drawable logo; + /** `logo` is an adaptive icon layer, with the art in its middle two thirds. */ + private final boolean logoIsLayer; + private Bitmap art; + + LogoView(Context context) { + super(context); + Drawable icon = context.getApplicationInfo().loadIcon(context.getPackageManager()); + if (Build.VERSION.SDK_INT >= 33 && icon instanceof AdaptiveIconDrawable + && ((AdaptiveIconDrawable) icon).getMonochrome() != null) { + // The silhouette, in the pad's grey. + logo = ((AdaptiveIconDrawable) icon).getMonochrome().mutate(); + logo.setColorFilter(0xFF8A8A8A, PorterDuff.Mode.SRC_IN); + logoIsLayer = true; + } else { + // An icon without one (a full-bleed picture) as the launcher shows it. + logo = icon; + logoIsLayer = false; + } + } + + @Override + protected void onDraw(Canvas canvas) { + int size = Math.min(getWidth(), getHeight()); + if (size <= 0) { + return; + } + if (art == null || art.getWidth() > size || art.getHeight() > size) { + art = render(size); + } + // The art itself in the corner, so its gaps to the border match the buttons'. + canvas.drawBitmap(art, 0, 0, null); + } + + /** The logo at `size`, trimmed to what it actually draws. */ + private Bitmap render(int size) { + Bitmap full = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888); int bleed = logoIsLayer ? size / 4 : 0; - logo.setBounds(left - bleed, top - bleed, left + size + bleed, top + size + bleed); - logo.draw(canvas); + logo.setBounds(-bleed, -bleed, size + bleed, size + bleed); + logo.draw(new Canvas(full)); + int left = size; + int top = size; + for (int y = 0; y < size; y++) { + for (int x = 0; x < size; x++) { + if (Color.alpha(full.getPixel(x, y)) > 0) { + left = Math.min(left, x); + top = Math.min(top, y); + } + } + } + if (left == size) { + return full; + } + return Bitmap.createBitmap(full, left, top, size - left, size - top); } } @@ -360,23 +585,21 @@ public InputConnection onCreateInputConnection(EditorInfo outAttrs) { | EditorInfo.IME_ACTION_NONE; return new BaseInputConnection(this, false); } - - @Override - public boolean dispatchKeyEvent(KeyEvent event) { - if (event.getKeyCode() == KeyEvent.KEYCODE_BACK) { - return super.dispatchKeyEvent(event); - } - forwardKey(event); - return true; - } } /** Passes a key on to the desktop, from the Android keyboard or one typing on the monitor. */ static void forwardKey(KeyEvent event) { + // Apps on the desktop repeat held keys themselves. + if (event.getRepeatCount() > 0) { + return; + } if (event.getAction() == KeyEvent.ACTION_DOWN || event.getAction() == KeyEvent.ACTION_UP) { nativeOnKey(event.getAction() == KeyEvent.ACTION_DOWN, event.getKeyCode(), event.getEventTime()); } } private static native void nativeOnKey(boolean down, int keyCode, long eventTimeMs); + + private static native void nativeOnMouse( + float dx, float dy, int buttons, float scrollX, float scrollY, long eventTimeMs); } diff --git a/src/android/proot/setup.rs b/src/android/proot/setup.rs index 151d966..a38257c 100644 --- a/src/android/proot/setup.rs +++ b/src/android/proot/setup.rs @@ -142,6 +142,45 @@ fn setup_rootfs(options: &SetupOptions) -> StageOutput { })) } +/// Root filesystems unpacked before setuid programs kept their proot metadata: read the archive +/// once more to give sudo, su and passwd back their setuid bit, without unpacking it again. +fn setup_setuid_metadata(options: &SetupOptions) -> StageOutput { + let rootfs_dir = get_application_context().rootfs_dir; + if !rootfs::needs_setuid_metadata(&rootfs_dir) { + return None; + } + + let android_app = options.android_app.clone(); + let mpsc_sender = options.mpsc_sender.clone(); + let total = options.manifest.uncompressed_size.max(1); + Some(thread::spawn(move || { + let name = CString::new(ROOTFS_ARCHIVE).expect("asset name has no NUL bytes"); + let Some(asset) = android_app.asset_manager().open(&name) else { + log::error!("This APK does not contain {ROOTFS_ARCHIVE}"); + return; + }; + let mut last_percent = None; + let result = rootfs::restore_setuid_metadata( + BufReader::with_capacity(1 << 20, asset), + &rootfs_dir, + |read| { + let percent = (read * 100 / total).min(100); + if last_percent != Some(percent) { + last_percent = Some(percent); + mpsc_sender + .send(SetupMessage::Progress(format!( + "Setting up sudo... {percent}%" + ))) + .unwrap_or(()); + } + }, + ); + if let Err(error) = result { + log::error!("Failed to restore setuid programs: {error}"); + } + })) +} + fn simulate_linux_sysdata_stage(options: &SetupOptions) -> StageOutput { let fs_root = get_application_context().rootfs_dir; let mpsc_sender = options.mpsc_sender.clone(); @@ -497,6 +536,7 @@ pub fn setup(android_app: AndroidApp) -> PolarBearBackend { let stages: Vec = vec![ Box::new(setup_rootfs), // Step 1. Unpack the OS bundled in the APK + Box::new(setup_setuid_metadata), // Step 1b. sudo for an OS unpacked by an older version Box::new(simulate_linux_sysdata_stage), // Step 2. Simulate Linux system data Box::new(setup_home), // Step 3. Persistent /home and /etc/localdesktop Box::new(setup_machine_id), // Step 4. Seed /etc/machine-id for D-Bus clients diff --git a/src/core/rootfs.rs b/src/core/rootfs.rs index 4d30777..98a7c67 100644 --- a/src/core/rootfs.rs +++ b/src/core/rootfs.rs @@ -30,6 +30,10 @@ const STAMP_FILE: &str = ".elementary-mobile-rootfs"; /// Suffix of the directory an archive is unpacked into before it is moved into place. const PARTIAL_SUFFIX: &str = "partial"; +/// File inside a root filesystem whose setuid programs (sudo, su, passwd) have their proot +/// metadata. Root filesystems unpacked before it existed get it from `restore_setuid_metadata`. +const SETUID_STAMP: &str = ".elementary-mobile-setuid"; + #[derive(Debug, Clone, Deserialize)] pub struct RootfsManifest { /// SHA-256 of the archive. Changes whenever the bundled OS does. @@ -113,6 +117,7 @@ pub fn install( } _ => { entry.unpack_in(&partial)?; + write_setuid_metadata(&partial, &entry)?; // The guest runs as a fake root that should be able to change any file, but the // kernel still checks the owner bits of the app's own user. if kind == EntryType::Regular && mode & 0o600 != 0o600 { @@ -135,9 +140,56 @@ pub fn install( progress(read_bytes.get()); fs::write(partial.join(STAMP_FILE), &manifest.id)?; + fs::write(partial.join(SETUID_STAMP), "")?; fs::rename(&partial, rootfs) } +/// Whether `rootfs` was unpacked before setuid programs got their proot metadata. +pub fn needs_setuid_metadata(rootfs: &Path) -> bool { + rootfs.join(STAMP_FILE).exists() && !rootfs.join(SETUID_STAMP).exists() +} + +/// Give the setuid programs of an already unpacked `rootfs` their proot metadata, reading +/// `archive` (the one it was unpacked from) without unpacking it again. +pub fn restore_setuid_metadata( + archive: R, + rootfs: &Path, + mut progress: impl FnMut(u64), +) -> io::Result<()> { + let read_bytes = Rc::new(Cell::new(0u64)); + let reader = CountingReader { + inner: XzDecoder::new(archive), + count: read_bytes.clone(), + }; + let mut archive = Archive::new(reader); + for entry in archive.entries()? { + write_setuid_metadata(rootfs, &entry?)?; + progress(read_bytes.get()); + } + fs::write(rootfs.join(SETUID_STAMP), "") +} + +/// Android does not let apps set the setuid bit or change owners, so proot's fake root keeps them +/// in a `.proot-meta-file..meta` file next to the file. Without it, sudo and su run as the +/// user who started them and cannot become root. +fn write_setuid_metadata(root: &Path, entry: &tar::Entry) -> io::Result<()> { + let header = entry.header(); + let mode = header.mode().unwrap_or(0); + if header.entry_type() != EntryType::Regular || mode & 0o6000 == 0 { + return Ok(()); + } + let Some(dst) = destination(root, &entry.path()?) else { + return Ok(()); + }; + let (Some(dir), Some(name)) = (dst.parent(), dst.file_name()) else { + return Ok(()); + }; + let meta = dir.join(format!(".proot-meta-file.{}.meta", name.to_string_lossy())); + let (uid, gid) = (header.uid().unwrap_or(0), header.gid().unwrap_or(0)); + // proot reads the mode as octal digits written in decimal. + fs::write(meta, format!("{:o}\n{uid}\n{gid}\n", mode & 0o7777)) +} + /// Remove what an interrupted `install` left behind. Cheap when there is nothing to clean up. pub fn remove_partial(rootfs: &Path) -> io::Result<()> { remove_tree(&partial_dir(rootfs))