Skip to content

Feature request: Left-edge swipe-to-back gesture daemon #246

Description

@vodidan

Description:

I really missed swipe back on the flx1s and seemed like usability gap — especially when switching between Linux and Android apps
where the expected gesture differs.

This is a proof-of-concept daemon that implements a left-edge swipe-to-back gesture, working correctly for both Linux and Android apps and across all four display orientations.

Behaviour:

  • Swipe inward from the physical left edge → sends back navigation to the focused app
  • Linux app focused → injects Alt+Left via uinput virtual keyboard
  • Android app focused → sends KEYCODE_BACK via andromeda shell input keyevent 4
  • Detects Linux vs Android by watching app_id via wlr-foreign-toplevel-management-unstable-v1 (Android windows have prefix android.)
  • Rotation-aware: correctly maps the physical left edge in portrait, landscape, and inverted orientations by tracking wl_output.geometry transform

Implementation:

The daemon reads raw multitouch events from /dev/input/event3 using python-evdev and runs a Wayland client in a background thread using python-pywayland.

Supporting files:

/usr/local/bin/android-back
#!/bin/bash
/usr/bin/andromeda shell input keyevent 4
/usr/local/bin/android-back — allows the user daemon to call android-back without a password
furios ALL=(ALL) NOPASSWD: /usr/local/bin/android-back
/etc/udev/rules.d/60-uinput-input-group.rules — allows the input group to open /dev/uinput:
KERNEL=="uinput", GROUP="input", MODE="0660"
~/.config/systemd/user/swipe-back.service:
[Unit]
Description=Left-edge swipe-to-back gesture daemon
After=default.target

[Service]
ExecStart=/home/furios/.local/bin/swipe-back
Restart=on-failure
RestartSec=2

[Install]
WantedBy=default.target
~/.local/bin/swipe-back
#!/usr/bin/env python3
"""
Left-edge swipe-to-back gesture daemon for FuriPhone.
- Linux app focused  → Alt+Left
- Android app focused → KEYCODE_BACK via andromeda shell
Handles all four display rotations via wl_output transform tracking.
"""
import evdev
from evdev import UInput, ecodes as e
import os, sys, struct, subprocess, tempfile, threading, time, select, shutil

TOUCH_DEV   = '/dev/input/event3'
TOUCH_MAX_X = 720
TOUCH_MAX_Y = 1600
EDGE        = 60    # px from the "left" edge (axis depends on rotation)
MIN_SWIPE   = 120   # minimum travel along the swipe axis
MAX_DRIFT   = 200   # maximum travel on the perpendicular axis
MAX_TIME    = 0.7   # seconds

STATE_ACTIVATED = 2  # ZWLR_FOREIGN_TOPLEVEL_HANDLE_V1_STATE_ACTIVATED

# wl_output transform constants (Wayland spec)
TRANSFORM_NORMAL = 0   # portrait
TRANSFORM_90     = 1   # 90° CCW
TRANSFORM_180    = 2   # 180°
TRANSFORM_270    = 3   # 270° CCW (= 90° CW)

_focused    = [None]
_transform  = [TRANSFORM_NORMAL]
_lock       = threading.Lock()
_last_fire  = 0.0
DEBOUNCE    = 0.3

_PROTO_XML = """\
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="wlr_foreign_toplevel_management_unstable_v1">
  <interface name="zwlr_foreign_toplevel_manager_v1" version="3">
    <event name="toplevel">
      <arg name="toplevel" type="new_id" interface="zwlr_foreign_toplevel_handle_v1"/>
    </event>
    <event name="finished" since="2"/>
    <request name="stop"/>
  </interface>
  <interface name="zwlr_foreign_toplevel_handle_v1" version="3">
    <event name="title"><arg name="title" type="string"/></event>
    <event name="app_id"><arg name="app_id" type="string"/></event>
    <event name="output_enter" since="1"><arg name="output" type="uint"/></event>
    <event name="output_leave" since="1"><arg name="output" type="uint"/></event>
    <event name="state"><arg name="state" type="array"/></event>
    <event name="done"/>
    <event name="closed"/>
    <event name="parent" since="3"><arg name="parent" type="uint"/></event>
    <request name="destroy" type="destructor"/>
  </interface>
</protocol>"""

def _focus_tracker():
    out_dir = None
    xml_path = None
    try:
        xml_fd, xml_path = tempfile.mkstemp(suffix='.xml', prefix='swipe-back-')
        out_dir = tempfile.mkdtemp(prefix='swipe-back-gen-')
        with os.fdopen(xml_fd, 'w') as f:
            f.write(_PROTO_XML)

        r = subprocess.run(
            [sys.executable, '-m', 'pywayland.scanner', '-i', xml_path, '-o', out_dir],
            capture_output=True, text=True
        )
        if r.returncode != 0:
            print(f'[swipe-back] scanner failed: {r.stderr}', flush=True)
            return

        sys.path.insert(0, out_dir)
        from wlr_foreign_toplevel_management_unstable_v1 import ZwlrForeignToplevelManagerV1
        from pywayland.client import Display
        from pywayland.protocol.wayland import WlOutput

        display  = Display()
        display.connect()
        registry = display.get_registry()
        handles  = {}

        def on_toplevel(mgr, handle):
            info = {'app_id': None, 'activated': False}
            key  = id(handle)
            handles[key] = info

            def on_app_id(handle, app_id):
                info['app_id'] = app_id
                if info['activated']:
                    with _lock:
                        _focused[0] = app_id

            def on_state(handle, raw):
                try:
                    data = bytes(raw) if not isinstance(raw, (bytes, bytearray)) else raw
                    n    = len(data) // 4
                    states = struct.unpack(f'<{n}I', data) if n else ()
                except Exception:
                    states = ()
                activated = STATE_ACTIVATED in states
                if activated != info['activated']:
                    info['activated'] = activated
                    if activated:
                        with _lock:
                            _focused[0] = info['app_id']

            def on_closed(handle):
                handles.pop(key, None)

            handle.dispatcher['app_id'] = on_app_id
            handle.dispatcher['state']  = on_state
            handle.dispatcher['closed'] = on_closed

        def on_global(registry, name, interface, version):
            if interface == 'zwlr_foreign_toplevel_manager_v1':
                mgr = registry.bind(name, ZwlrForeignToplevelManagerV1, min(version, 3))
                mgr.dispatcher['toplevel'] = on_toplevel

            elif interface == 'wl_output':
                output = registry.bind(name, WlOutput, min(version, 4))

                def on_geometry(output, x, y, pw, ph, subpixel, make, model, transform):
                    with _lock:
                        _transform[0] = transform
                    print(f'[swipe-back] display transform → {transform}', flush=True)

                output.dispatcher['geometry'] = on_geometry

        registry.dispatcher['global'] = on_global

        display.roundtrip()
        display.roundtrip()
        print('[swipe-back] focus tracker active', flush=True)

        fd = display.get_fd()
        while True:
            display.flush()
            select.select([fd], [], [], 1.0)
            display.roundtrip()

    except Exception as ex:
        print(f'[swipe-back] focus tracker error: {ex}', flush=True)
        with _lock:
            _focused[0] = None
    finally:
        if xml_path and os.path.exists(xml_path):
            os.unlink(xml_path)
        if out_dir:
            shutil.rmtree(out_dir, ignore_errors=True)


def _make_uinput():
    return UInput({e.EV_KEY: [e.KEY_LEFTALT, e.KEY_LEFT]},
                  name='swipe-back-virtual', version=0x3)

def _send_linux_back(ui):
    ui.write(e.EV_KEY, e.KEY_LEFTALT, 1)
    ui.write(e.EV_KEY, e.KEY_LEFT,    1)
    ui.write(e.EV_SYN, e.SYN_REPORT,  0)
    ui.write(e.EV_KEY, e.KEY_LEFT,    0)
    ui.write(e.EV_KEY, e.KEY_LEFTALT, 0)
    ui.write(e.EV_SYN, e.SYN_REPORT,  0)

def _send_android_back():
    r = subprocess.run(['sudo', '-n', '/usr/local/bin/android-back'],
                       capture_output=True, timeout=2)
    if r.returncode != 0:
        print(f'[swipe-back] android-back failed: {r.stderr.decode(errors="replace")}',
              flush=True)

def _is_gesture(s, dt):
    """Return True if the completed touch matches a back swipe for the current transform."""
    x0, y0 = s['x0'], s['y0']
    x,  y  = s['x'],  s['y']
    dx = x - x0
    dy = y - y0

    with _lock:
        t = _transform[0]

    if dt > MAX_TIME:
        return False

    # Raw touch coords are always in the sensor's native space, independent of
    # display rotation. On FuriPhone the panel is natively landscape, so:
    #   portrait  → phoc reports transform=1 (90° CCW to rotate landscape→portrait)
    #   landscape → phoc reports transform=0 (native, no rotation)
    #
    # Physical left edge in each orientation:
    #   transform 0 (landscape / native): raw x=0          → swipe right (+dx)
    #   transform 1 (portrait, 90° CCW):  raw y=MAX_Y      → swipe up   (-dy)
    #   transform 2 (upside-down):        raw x=MAX_X      → swipe left  (-dx)
    #   transform 3 (270° CCW / 90° CW):  raw y=0          → swipe down  (+dy)
    if t == TRANSFORM_NORMAL:
        return x0 < EDGE and dx >= MIN_SWIPE and abs(dy) <= MAX_DRIFT
    elif t == TRANSFORM_90:
        return y0 > TOUCH_MAX_Y - EDGE and -dy >= MIN_SWIPE and abs(dx) <= MAX_DRIFT
    elif t == TRANSFORM_180:
        return x0 > TOUCH_MAX_X - EDGE and -dx >= MIN_SWIPE and abs(dy) <= MAX_DRIFT
    elif t == TRANSFORM_270:
        return y0 < EDGE and dy >= MIN_SWIPE and abs(dx) <= MAX_DRIFT

    return False

def _handle_gesture(ui):
    global _last_fire
    now = time.monotonic()
    if now - _last_fire < DEBOUNCE:
        return
    _last_fire = now

    with _lock:
        app_id = _focused[0]
    if app_id and app_id.startswith('android.'):
        _send_android_back()
    else:
        _send_linux_back(ui)


def main():
    threading.Thread(target=_focus_tracker, daemon=True).start()

    dev      = evdev.InputDevice(TOUCH_DEV)
    ui       = _make_uinput()
    slots    = {}
    cur_slot = dev.absinfo(e.ABS_MT_SLOT).value

    for ev in dev.read_loop():
        if ev.type != e.EV_ABS:
            continue
        if ev.code == e.ABS_MT_SLOT:
            cur_slot = ev.value
        elif ev.code == e.ABS_MT_TRACKING_ID:
            if ev.value == -1:
                s = slots.pop(cur_slot, None)
                if s and s['x0'] is not None and s['x'] is not None:
                    dt = time.monotonic() - s['t0']
                    if _is_gesture(s, dt):
                        _handle_gesture(ui)
            else:
                slots[cur_slot] = {'x0': None, 'y0': None, 'x': None, 'y': None,
                                   't0': time.monotonic()}
        elif ev.code == e.ABS_MT_POSITION_X and cur_slot in slots:
            s = slots[cur_slot]
            if s['x0'] is None:
                s['x0'] = ev.value
            s['x'] = ev.value
        elif ev.code == e.ABS_MT_POSITION_Y and cur_slot in slots:
            s = slots[cur_slot]
            if s['y0'] is None:
                s['y0'] = ev.value
            s['y'] = ev.value

if __name__ == '__main__':
    main()

Dependencies:
python3-evdev, python3-pywayland

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions