diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e3669c1..eb929e1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -28,6 +28,7 @@ jobs:
sudo apt-get install -y --no-install-recommends \
build-essential \
cmake \
+ libx11-dev \
libssl-dev \
pkg-config \
zlib1g-dev
@@ -61,6 +62,7 @@ jobs:
gcc-c++ \
gtk3-devel \
libayatana-appindicator3-devel \
+ libX11-devel \
make \
openssl-devel \
pkgconf-pkg-config \
@@ -94,6 +96,7 @@ jobs:
sudo apt-get install -y --no-install-recommends \
build-essential \
cmake \
+ libx11-dev \
libssl-dev \
pkg-config \
zlib1g-dev
diff --git a/.gitignore b/.gitignore
index 93f29e8..de960a8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,6 +17,7 @@ mwb-lock-report-*.txt
mwb-socket-trace-*.txt
inputflow-windows-pair-*.ps1
AGENTS.md
+artifacts/
# Editor / OS noise
.vscode/
diff --git a/CMakeLists.txt b/CMakeLists.txt
index c9aa86a..7416169 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -36,13 +36,17 @@ endfunction()
# Find OpenSSL for AES decryption/encryption matching PowerToys MWB
find_package(OpenSSL REQUIRED)
find_package(ZLIB REQUIRED)
+find_package(X11 REQUIRED)
add_executable(mwb_client
+ src/AndroidRelay.cpp
src/AppConfig.cpp
src/AppState.cpp
src/ClientRuntime.cpp
src/Discovery.cpp
src/InputDispatcher.cpp
+ src/LibeiInputCaptureBridge.cpp
+ src/LocalAndroidInputBridge.cpp
src/main.cpp
src/PeerRecovery.cpp
src/SecretStore.cpp
@@ -56,6 +60,7 @@ add_executable(mwb_client
target_include_directories(mwb_client PRIVATE src)
target_include_directories(mwb_client PRIVATE ${OPENSSL_INCLUDE_DIR})
+target_include_directories(mwb_client PRIVATE ${X11_INCLUDE_DIR})
if (CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
target_compile_options(mwb_client PRIVATE
@@ -78,18 +83,39 @@ target_link_libraries(mwb_client PRIVATE
OpenSSL::SSL
OpenSSL::Crypto
ZLIB::ZLIB
+ ${X11_LIBRARIES}
pthread
)
include(CTest)
find_package(PkgConfig QUIET)
+if (PkgConfig_FOUND)
+ pkg_check_modules(LIBEI_INPUT_CAPTURE QUIET libei-1.0 gio-unix-2.0 gio-2.0 glib-2.0)
+ pkg_check_modules(LIBINPUT_GESTURES QUIET libinput)
+endif()
+
+if (LIBEI_INPUT_CAPTURE_FOUND)
+ target_compile_definitions(mwb_client PRIVATE MWB_HAVE_LIBEI_INPUT_CAPTURE=1)
+ target_include_directories(mwb_client PRIVATE ${LIBEI_INPUT_CAPTURE_INCLUDE_DIRS})
+ target_link_libraries(mwb_client PRIVATE ${LIBEI_INPUT_CAPTURE_LDFLAGS})
+endif()
+
+if (LIBINPUT_GESTURES_FOUND)
+ message(STATUS "Native libinput gesture monitor enabled")
+ target_compile_definitions(mwb_client PRIVATE MWB_HAVE_LIBINPUT_GESTURES=1)
+ target_include_directories(mwb_client PRIVATE ${LIBINPUT_GESTURES_INCLUDE_DIRS})
+ target_link_libraries(mwb_client PRIVATE ${LIBINPUT_GESTURES_LDFLAGS})
+else()
+ message(STATUS "Native libinput gesture monitor disabled; install libinput-devel to enable it")
+endif()
if (BUILD_TESTING)
find_program(PYTHON3_EXECUTABLE python3)
add_executable(mwb_client_unit_tests
tests/test_main.cpp
+ src/AndroidRelay.cpp
src/AppConfig.cpp
src/AppState.cpp
src/Discovery.cpp
@@ -97,7 +123,7 @@ if (BUILD_TESTING)
)
target_include_directories(mwb_client_unit_tests PRIVATE src)
target_compile_options(mwb_client_unit_tests PRIVATE -Wall -Wextra -Wpedantic)
- target_link_libraries(mwb_client_unit_tests PRIVATE pthread)
+ target_link_libraries(mwb_client_unit_tests PRIVATE OpenSSL::Crypto pthread)
mwb_apply_sanitizers(mwb_client_unit_tests)
add_executable(mwb_input_mapping_tests
@@ -222,6 +248,16 @@ endif()
if (PkgConfig_FOUND)
pkg_check_modules(MWB_TRAY_DEPS QUIET IMPORTED_TARGET gtk+-3.0 ayatana-appindicator3-0.1)
if (MWB_TRAY_DEPS_FOUND)
+ # Embed tray + GUI window into the main mwb_client binary
+ target_sources(mwb_client PRIVATE
+ src/TrayController.cpp
+ src/GuiMainWindow.cpp
+ src/MonitorLayoutWidget.cpp
+ )
+ target_compile_definitions(mwb_client PRIVATE MWB_HAVE_GTK_GUI=1)
+ target_link_libraries(mwb_client PRIVATE PkgConfig::MWB_TRAY_DEPS)
+
+ # Keep standalone mwb_tray as a fallback (no embedded runtime)
add_executable(mwb_tray src/TrayController.cpp)
target_link_libraries(mwb_tray PRIVATE PkgConfig::MWB_TRAY_DEPS)
mwb_apply_sanitizers(mwb_tray)
diff --git a/README.md b/README.md
index 9403b89..cade956 100644
--- a/README.md
+++ b/README.md
@@ -107,6 +107,7 @@ User-facing beta operations:
- [Connection quality and latency reporting](docs/beta-workflow.md#connection-quality)
- [Packaging verification](docs/beta-workflow.md#packaging-verification)
- [Topology config contract and layout wizard expectations](docs/topology.md)
+- [Android peer MVP](docs/android.md)
- [Migration from other keyboard/mouse sharing tools](docs/migration.md)
- [Compatibility matrix and platform caveats](docs/compatibility.md)
@@ -117,7 +118,7 @@ User-facing beta operations:
This repository started as a fork of [chrischip/mwb-client-linux](https://github.com/chrischip/mwb-client-linux) and has been substantially expanded with service management, rich clipboard support, and recovery tooling.
### Configuration (`config.ini`)
-Supports `key_file`, `key_secret_id` (keyring), `screen_width/height` overrides, `topology_enabled`, `topology_file`, and more. Default path: `~/.config/mwb-client/config.ini`.
+Supports `key_file`, `key_secret_id` (keyring), `screen_width/height` overrides, `topology_enabled`, `topology_file`, experimental `android_peers_enabled`, and more. Default path: `~/.config/mwb-client/config.ini`.
Display-level topology is a separate opt-in contract. The default runtime remains MWB-compatible machine placement unless topology is explicitly enabled; see [docs/topology.md](docs/topology.md) for examples, wrap policies, validation, and cross-machine handoff behavior.
diff --git a/android/.gitignore b/android/.gitignore
new file mode 100644
index 0000000..bf54c68
--- /dev/null
+++ b/android/.gitignore
@@ -0,0 +1,4 @@
+.gradle/
+build/
+local.properties
+app/build/
diff --git a/android/app/build.gradle b/android/app/build.gradle
new file mode 100644
index 0000000..ee45ce8
--- /dev/null
+++ b/android/app/build.gradle
@@ -0,0 +1,30 @@
+plugins {
+ id "com.android.application"
+ id "org.jetbrains.kotlin.android"
+}
+
+android {
+ namespace "com.inputflow.android"
+ compileSdk 35
+
+ defaultConfig {
+ applicationId "com.inputflow.android"
+ minSdk 26
+ targetSdk 35
+ versionCode 1
+ versionName "0.1.0"
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+}
+
+dependencies {
+ implementation "com.google.android.material:material:1.12.0"
+}
+
+kotlin {
+ jvmToolchain(8)
+}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..5f61d33
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/java/com/inputflow/android/InputFlowAccessibilityService.kt b/android/app/src/main/java/com/inputflow/android/InputFlowAccessibilityService.kt
new file mode 100644
index 0000000..13b366f
--- /dev/null
+++ b/android/app/src/main/java/com/inputflow/android/InputFlowAccessibilityService.kt
@@ -0,0 +1,693 @@
+package com.inputflow.android
+
+import android.accessibilityservice.AccessibilityService
+import android.accessibilityservice.GestureDescription
+import android.content.Context
+import android.graphics.Color
+import android.graphics.Path
+import android.graphics.PixelFormat
+import android.graphics.drawable.GradientDrawable
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.media.AudioManager
+import android.os.Build
+import android.os.PowerManager
+import android.util.Log
+import android.view.Gravity
+import android.view.View
+import android.view.WindowManager
+import android.view.accessibility.AccessibilityEvent
+import android.view.accessibility.AccessibilityNodeInfo
+import org.json.JSONObject
+import kotlin.math.abs
+import kotlin.math.max
+import kotlin.math.min
+
+class InputFlowAccessibilityService : AccessibilityService() {
+ private val mainHandler = Handler(Looper.getMainLooper())
+ private var pointerX = 0f
+ private var pointerY = 0f
+ private var cursorView: View? = null
+ private var cursorParams: WindowManager.LayoutParams? = null
+ private var windowManager: WindowManager? = null
+ private var shiftDown = false
+ private var ctrlDown = false
+ private var altDown = false
+ private var metaDown = false
+ private var pendingScrollDx = 0.0
+ private var pendingScrollDy = 0.0
+ private var scrollFlushScheduled = false
+ @Suppress("DEPRECATION")
+ private var wakeLock: PowerManager.WakeLock? = null
+
+ override fun onServiceConnected() {
+ instance = this
+ val metrics = resources.displayMetrics
+ pointerX = metrics.widthPixels / 2f
+ pointerY = metrics.heightPixels / 2f
+ @Suppress("DEPRECATION")
+ wakeLock = (getSystemService(PowerManager::class.java))
+ .newWakeLock(
+ PowerManager.FULL_WAKE_LOCK or PowerManager.ACQUIRE_CAUSES_WAKEUP or PowerManager.ON_AFTER_RELEASE,
+ "inputflow:input"
+ )
+ }
+
+ override fun onAccessibilityEvent(event: AccessibilityEvent?) = Unit
+
+ override fun onInterrupt() = Unit
+
+ override fun onDestroy() {
+ hideCursorOverlay()
+ wakeLock?.let { if (it.isHeld) it.release() }
+ if (instance === this) instance = null
+ super.onDestroy()
+ }
+
+ fun handleMouse(frame: JSONObject) {
+ ensureScreenOn()
+ val wParam = frame.optInt("wParam")
+ val x = frame.optInt("x")
+ val y = frame.optInt("y")
+ val mouseData = frame.optInt("mouseData")
+ when (wParam) {
+ WM_MOUSEMOVE -> moveToNormalized(x, y)
+ WM_LBUTTONUP -> tap(pointerX, pointerY)
+ WM_RBUTTONUP -> mainHandler.post { performGlobalAction(GLOBAL_ACTION_BACK) }
+ WM_MBUTTONUP -> mainHandler.post { performGlobalAction(GLOBAL_ACTION_HOME) }
+ WM_MOUSEWHEEL -> scroll(mouseData)
+ }
+ }
+
+ fun setRemoteControlActive(active: Boolean) {
+ mainHandler.post {
+ if (active) {
+ showCursorOverlay()
+ } else {
+ pendingScrollDx = 0.0
+ pendingScrollDy = 0.0
+ scrollFlushScheduled = false
+ shiftDown = false
+ ctrlDown = false
+ altDown = false
+ metaDown = false
+ hideCursorOverlay()
+ }
+ }
+ }
+
+ fun handleGesture(frame: JSONObject) {
+ val dx = frame.optDouble("dx")
+ val dy = frame.optDouble("dy")
+ ensureScreenOn()
+ when (frame.optString("kind")) {
+ "scroll" -> touchpadScroll(dx, dy)
+ "swipe2" -> handleSwipe2(dx, dy)
+ "swipe3" -> handleSwipe3(dx, dy)
+ "swipe4" -> handleSwipe4(dx, dy)
+ "pinch" -> handlePinch(dx)
+ "zoom" -> handlePinch(dx)
+ "tap2" -> handleTap2()
+ }
+ }
+
+ fun handleKeyboard(frame: JSONObject) {
+ ensureScreenOn()
+ mainHandler.post {
+ val vkCode = frame.optInt("vkCode")
+ val flags = frame.optInt("flags")
+ val keyUp = (flags and LLKHF_UP) != 0
+ updateModifier(vkCode, keyUp)
+ if (keyUp && handleCommandKeyUp(vkCode)) return@post
+ if (keyUp) return@post
+ if (checkKeyMap(vkCode)) return@post
+ if (handleCommandKey(vkCode)) return@post
+ when (vkCode) {
+ VK_BACK -> editFocusedText { it.dropLast(1) }
+ VK_ESCAPE -> performGlobalAction(GLOBAL_ACTION_BACK)
+ VK_RETURN -> editFocusedText { "$it\n" }
+ VK_SPACE -> editFocusedText { "$it " }
+ VK_LEFT -> navigateDirection(forward = false, vertical = false)
+ VK_UP -> navigateDirection(forward = false, vertical = true)
+ VK_RIGHT -> navigateDirection(forward = true, vertical = false)
+ VK_DOWN -> navigateDirection(forward = true, vertical = true)
+ VK_DELETE -> forwardDelete()
+ VK_TAB -> navigateFocus(!shiftDown)
+ VK_HOME -> moveCursorToLineEdge(toEnd = false)
+ VK_END -> moveCursorToLineEdge(toEnd = true)
+ VK_PRIOR -> pageScroll(false)
+ VK_NEXT -> pageScroll(true)
+ in VK_0..VK_9 -> appendText(keyChar(vkCode))
+ in VK_A..VK_Z -> appendText(keyChar(vkCode))
+ VK_OEM_COMMA, VK_OEM_PERIOD, VK_OEM_MINUS, VK_OEM_PLUS,
+ VK_OEM_1, VK_OEM_2, VK_OEM_3, VK_OEM_4, VK_OEM_5, VK_OEM_6, VK_OEM_7 ->
+ appendText(keyChar(vkCode))
+ }
+ }
+ }
+
+ fun handleMappedKeyboard(frame: JSONObject): Boolean {
+ val vkCode = frame.optInt("vkCode")
+ val flags = frame.optInt("flags")
+ if ((flags and LLKHF_UP) != 0 || isModifierKey(vkCode)) {
+ return false
+ }
+ val action = findMappedAction(vkCode) ?: return false
+ mainHandler.post {
+ executeMappedAction(action)
+ }
+ return true
+ }
+
+ private fun ensureScreenOn() {
+ val pm = getSystemService(PowerManager::class.java)
+ if (!pm.isInteractive) {
+ wakeLock?.acquire(5000L)
+ }
+ }
+
+ private fun updateModifier(vkCode: Int, keyUp: Boolean) {
+ val down = !keyUp
+ when (vkCode) {
+ VK_SHIFT -> shiftDown = down
+ VK_CONTROL -> ctrlDown = down
+ VK_MENU -> altDown = down
+ VK_LWIN, VK_RWIN -> metaDown = down
+ }
+ }
+
+ private fun isModifierKey(vkCode: Int): Boolean {
+ return vkCode == VK_SHIFT ||
+ vkCode == VK_CONTROL ||
+ vkCode == VK_MENU ||
+ vkCode == VK_LWIN ||
+ vkCode == VK_RWIN
+ }
+
+ private fun checkKeyMap(vkCode: Int): Boolean {
+ val action = findMappedAction(vkCode) ?: return false
+ executeMappedAction(action)
+ return true
+ }
+
+ private fun findMappedAction(vkCode: Int): KeyMap.Action? {
+ val prefs = applicationContext.getSharedPreferences(RelayForegroundService.PREFS, MODE_PRIVATE)
+ val mods = KeyMap.currentMods(shiftDown, ctrlDown, altDown, metaDown)
+ return KeyMap.ACTIONS.firstOrNull { action ->
+ KeyMap.getVk(prefs, action) == vkCode && KeyMap.getMods(prefs, action) == mods
+ }
+ }
+
+ private fun executeMappedAction(action: KeyMap.Action) {
+ when (action.id) {
+ "back" -> performGlobalActionLogged(GLOBAL_ACTION_BACK, "map:${action.id}")
+ "home" -> performGlobalActionLogged(GLOBAL_ACTION_HOME, "map:${action.id}")
+ "recents" -> performGlobalActionLogged(GLOBAL_ACTION_RECENTS, "map:${action.id}")
+ "notifications" -> performGlobalActionLogged(GLOBAL_ACTION_NOTIFICATIONS, "map:${action.id}")
+ "quick_settings" -> performGlobalActionLogged(GLOBAL_ACTION_QUICK_SETTINGS, "map:${action.id}")
+ "volume_up" -> adjustVolume(AudioManager.ADJUST_RAISE)
+ "volume_down" -> adjustVolume(AudioManager.ADJUST_LOWER)
+ "screenshot" -> takeScreenshotAction()
+ "swipe_left" -> dispatchDirectionalSwipe(-1f, 0f)
+ "swipe_right" -> dispatchDirectionalSwipe(1f, 0f)
+ "swipe_up" -> dispatchDirectionalSwipe(0f, -1f)
+ "swipe_down" -> dispatchDirectionalSwipe(0f, 1f)
+ "zoom_in" -> handlePinch(1.25)
+ "zoom_out" -> handlePinch(0.75)
+ }
+ }
+
+ private fun adjustVolume(direction: Int) {
+ getSystemService(AudioManager::class.java)
+ ?.adjustStreamVolume(AudioManager.STREAM_MUSIC, direction, AudioManager.FLAG_SHOW_UI)
+ }
+
+ private fun takeScreenshotAction() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ performGlobalActionLogged(GLOBAL_ACTION_TAKE_SCREENSHOT, "map:screenshot")
+ }
+ }
+
+ private fun dispatchDirectionalSwipe(dx: Float, dy: Float) {
+ val metrics = resources.displayMetrics
+ val distance = 260f * metrics.density
+ val fromX = pointerX.coerceIn(1f, metrics.widthPixels - 2f)
+ val fromY = pointerY.coerceIn(1f, metrics.heightPixels - 2f)
+ val toX = (fromX + dx * distance).coerceIn(1f, metrics.widthPixels - 2f)
+ val toY = (fromY + dy * distance).coerceIn(1f, metrics.heightPixels - 2f)
+ gesture(fromX, fromY, toX, toY, 180)
+ }
+
+ private fun handleCommandKey(vkCode: Int): Boolean {
+ if (vkCode == VK_LWIN || vkCode == VK_RWIN) {
+ performGlobalActionLogged(GLOBAL_ACTION_HOME, "meta-down")
+ return true
+ }
+ if (vkCode == VK_TAB && altDown) {
+ performGlobalActionLogged(GLOBAL_ACTION_RECENTS, "alt-tab")
+ return true
+ }
+ if (vkCode == VK_LEFT && altDown) {
+ performGlobalActionLogged(GLOBAL_ACTION_BACK, "alt-left")
+ return true
+ }
+ if (vkCode == VK_N && ctrlDown && altDown) {
+ performGlobalActionLogged(GLOBAL_ACTION_NOTIFICATIONS, "ctrl-alt-n")
+ return true
+ }
+ if (vkCode == VK_Q && ctrlDown && altDown) {
+ performGlobalActionLogged(GLOBAL_ACTION_QUICK_SETTINGS, "ctrl-alt-q")
+ return true
+ }
+ return false
+ }
+
+ private fun handleCommandKeyUp(vkCode: Int): Boolean {
+ if (vkCode == VK_LWIN || vkCode == VK_RWIN) {
+ performGlobalActionLogged(GLOBAL_ACTION_HOME, "meta-up")
+ return true
+ }
+ return false
+ }
+
+ private fun performGlobalActionLogged(action: Int, source: String): Boolean {
+ val handled = performGlobalAction(action)
+ Log.i(TAG, "global action source=$source action=$action handled=$handled")
+ return handled
+ }
+
+ // ── Gesture handlers ─────────────────────────────────────────────────────
+
+ private fun handleSwipe2(dx: Double, dy: Double) {
+ touchpadScroll(dx, dy)
+ }
+
+ private fun handleSwipe3(dx: Double, dy: Double) {
+ if (!isAccurateVerticalSwipe(dx, dy)) return
+ mainHandler.post {
+ if (dy < 0) {
+ performGlobalActionLogged(GLOBAL_ACTION_HOME, "gesture:3-up")
+ } else {
+ performGlobalActionLogged(GLOBAL_ACTION_RECENTS, "gesture:3-down")
+ }
+ }
+ }
+
+ private fun handleSwipe4(dx: Double, dy: Double) {
+ if (!isAccurateVerticalSwipe(dx, dy)) return
+ mainHandler.post {
+ if (dy < 0) {
+ openAppDrawerGesture()
+ } else {
+ performGlobalActionLogged(GLOBAL_ACTION_QUICK_SETTINGS, "gesture:4-down")
+ }
+ }
+ }
+
+ private fun isAccurateVerticalSwipe(dx: Double, dy: Double): Boolean {
+ val absX = abs(dx)
+ val absY = abs(dy)
+ return absY >= 32.0 && absY >= absX * 1.45
+ }
+
+ private fun openAppDrawerGesture() {
+ performGlobalActionLogged(GLOBAL_ACTION_HOME, "gesture:4-up-home")
+ mainHandler.postDelayed({
+ val metrics = resources.displayMetrics
+ val x = metrics.widthPixels / 2f
+ val fromY = metrics.heightPixels * 0.88f
+ val toY = metrics.heightPixels * 0.28f
+ gesture(x, fromY, x, toY, 260)
+ Log.i(TAG, "global action source=gesture:4-up action=app-drawer handled=true")
+ }, 220)
+ }
+
+ private fun handlePinch(scale: Double) {
+ val metrics = resources.displayMetrics
+ val cx = pointerX
+ val cy = pointerY
+ val baseR = 120f * metrics.density
+ val pinchIn = scale < 1.0
+ val startR = if (pinchIn) baseR else baseR * 0.4f
+ val endR = if (pinchIn) baseR * 0.4f else baseR
+ val path1 = Path().apply { moveTo(cx - startR, cy); lineTo(cx - endR, cy) }
+ val path2 = Path().apply { moveTo(cx + startR, cy); lineTo(cx + endR, cy) }
+ mainHandler.post {
+ dispatchGesture(
+ GestureDescription.Builder()
+ .addStroke(GestureDescription.StrokeDescription(path1, 0, 300))
+ .addStroke(GestureDescription.StrokeDescription(path2, 0, 300))
+ .build(),
+ null, null
+ )
+ }
+ }
+
+ private fun handleTap2() {
+ mainHandler.post {
+ val path = Path().apply { moveTo(pointerX, pointerY) }
+ dispatchGesture(
+ GestureDescription.Builder()
+ .addStroke(GestureDescription.StrokeDescription(path, 0, 600L))
+ .build(),
+ null, null
+ )
+ }
+ }
+
+ // ── Keyboard navigation helpers ──────────────────────────────────────────
+
+ private fun navigateDirection(forward: Boolean, vertical: Boolean) {
+ val root = rootInActiveWindow
+ val focused = root?.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)
+ ?.takeIf { it.isEditable }
+ ?: findFocusedNode(root)
+ if (focused != null) {
+ val granularity = if (vertical) AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE
+ else AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER
+ val action = if (forward) AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY
+ else AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY
+ focused.performAction(action, Bundle().apply {
+ putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT, granularity)
+ putBoolean(AccessibilityNodeInfo.ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN, shiftDown)
+ })
+ } else {
+ // No text field: directional swipe
+ val m = resources.displayMetrics
+ val d = 240f * m.density
+ val (dx, dy) = if (!vertical) Pair(if (forward) d else -d, 0f)
+ else Pair(0f, if (forward) d else -d)
+ val toX = (pointerX + dx).coerceIn(0f, m.widthPixels - 1f)
+ val toY = (pointerY + dy).coerceIn(0f, m.heightPixels - 1f)
+ gesture(pointerX, pointerY, toX, toY, 150)
+ }
+ }
+
+ private fun forwardDelete() {
+ val root = rootInActiveWindow
+ val focused = root?.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)
+ ?.takeIf { it.isEditable }
+ ?: findFocusedNode(root)
+ if (focused == null) return
+ val text = focused.text?.toString() ?: return
+ val selStart = focused.textSelectionStart
+ val selEnd = focused.textSelectionEnd
+ if (selStart < 0) return
+ val newText = if (selStart != selEnd) {
+ text.removeRange(minOf(selStart, selEnd), maxOf(selStart, selEnd))
+ } else {
+ if (selStart >= text.length) return
+ text.removeRange(selStart, selStart + 1)
+ }
+ val cursor = if (selStart != selEnd) minOf(selStart, selEnd) else selStart
+ focused.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, Bundle().apply {
+ putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, newText)
+ })
+ focused.performAction(AccessibilityNodeInfo.ACTION_SET_SELECTION, Bundle().apply {
+ putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT, cursor)
+ putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT, cursor)
+ })
+ }
+
+ private fun navigateFocus(forward: Boolean) {
+ val root = rootInActiveWindow ?: return
+ val focusables = mutableListOf()
+ collectFocusable(root, focusables)
+ if (focusables.isEmpty()) return
+ val current = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)
+ ?: root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY)
+ val idx = if (current != null) focusables.indexOfFirst { it == current } else -1
+ val next = if (forward) {
+ focusables.getOrNull(if (idx + 1 < focusables.size) idx + 1 else 0)
+ } else {
+ focusables.getOrNull(if (idx - 1 >= 0) idx - 1 else focusables.size - 1)
+ }
+ next?.performAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS)
+ }
+
+ private fun collectFocusable(node: AccessibilityNodeInfo?, out: MutableList) {
+ if (node == null) return
+ if (node.isFocusable && node.isVisibleToUser) out.add(node)
+ for (i in 0 until node.childCount) collectFocusable(node.getChild(i), out)
+ }
+
+ private fun moveCursorToLineEdge(toEnd: Boolean) {
+ val root = rootInActiveWindow
+ val focused = root?.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)
+ ?.takeIf { it.isEditable }
+ ?: findFocusedNode(root)
+ if (focused == null) return
+ val action = if (toEnd) AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY
+ else AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY
+ focused.performAction(action, Bundle().apply {
+ putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT,
+ AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE)
+ putBoolean(AccessibilityNodeInfo.ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN, shiftDown)
+ })
+ }
+
+ private fun pageScroll(down: Boolean) {
+ val metrics = resources.displayMetrics
+ val dist = metrics.heightPixels * 0.75f
+ val fromY = if (down) metrics.heightPixels * 0.75f else metrics.heightPixels * 0.25f
+ val toY = if (down) metrics.heightPixels * 0.25f else metrics.heightPixels * 0.75f
+ gesture(pointerX, fromY, pointerX, toY, 250)
+ }
+
+ // ── Text helpers ─────────────────────────────────────────────────────────
+
+ private fun appendText(value: String) {
+ if (value.isEmpty()) return
+ editFocusedText { current -> current + value }
+ }
+
+ private fun keyChar(vkCode: Int): String {
+ val shifted = shiftDown
+ if (vkCode in VK_A..VK_Z) {
+ val c = vkCode.toChar()
+ return if (shifted) c.toString() else c.lowercaseChar().toString()
+ }
+ if (vkCode in VK_0..VK_9) {
+ val normal = "0123456789"[vkCode - VK_0]
+ val shiftedChars = ")!@#$%^&*("[vkCode - VK_0]
+ return (if (shifted) shiftedChars else normal).toString()
+ }
+ return when (vkCode) {
+ VK_OEM_COMMA -> if (shifted) "<" else ","
+ VK_OEM_PERIOD -> if (shifted) ">" else "."
+ VK_OEM_MINUS -> if (shifted) "_" else "-"
+ VK_OEM_PLUS -> if (shifted) "+" else "="
+ VK_OEM_1 -> if (shifted) ":" else ";"
+ VK_OEM_2 -> if (shifted) "?" else "/"
+ VK_OEM_3 -> if (shifted) "~" else "`"
+ VK_OEM_4 -> if (shifted) "{" else "["
+ VK_OEM_5 -> if (shifted) "|" else "\\"
+ VK_OEM_6 -> if (shifted) "}" else "]"
+ VK_OEM_7 -> if (shifted) "\"" else "'"
+ else -> ""
+ }
+ }
+
+ // ── Pointer + cursor ─────────────────────────────────────────────────────
+
+ private fun moveToNormalized(x: Int, y: Int) {
+ val prefs = applicationContext.getSharedPreferences(RelayForegroundService.PREFS, MODE_PRIVATE)
+ val sens = prefs.getFloat(SettingsActivity.KEY_SENSITIVITY, 1.0f).coerceIn(0.5f, 3.0f)
+ val metrics = resources.displayMetrics
+ val baseX = x / 65535f * metrics.widthPixels
+ val baseY = y / 65535f * metrics.heightPixels
+ val cx = metrics.widthPixels / 2f
+ val cy = metrics.heightPixels / 2f
+ val nextX = min(metrics.widthPixels - 1f, max(0f, cx + (baseX - cx) * sens))
+ val nextY = min(metrics.heightPixels - 1f, max(0f, cy + (baseY - cy) * sens))
+ mainHandler.post {
+ pointerX = nextX
+ pointerY = nextY
+ updateCursorOverlay()
+ }
+ }
+
+ private fun tap(x: Float, y: Float) {
+ mainHandler.post { gesture(x, y, x, y, 40) }
+ }
+
+ private fun gesture(fromX: Float, fromY: Float, toX: Float, toY: Float, durationMs: Long) {
+ val path = Path().apply {
+ moveTo(fromX, fromY)
+ lineTo(toX, toY)
+ }
+ val stroke = GestureDescription.StrokeDescription(path, 0, durationMs)
+ dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), null, null)
+ }
+
+ private fun scroll(mouseData: Int) {
+ val dy = if (mouseData < 0) 1.0 else -1.0
+ touchpadScroll(0.0, dy)
+ }
+
+ private fun touchpadScroll(dx: Double, dy: Double) {
+ if (abs(dx) < 0.01 && abs(dy) < 0.01) return
+ mainHandler.post {
+ pendingScrollDx += dx
+ pendingScrollDy += dy
+ if (!scrollFlushScheduled) {
+ scrollFlushScheduled = true
+ mainHandler.postDelayed({ flushTouchpadScroll() }, 48)
+ }
+ }
+ }
+
+ private fun flushTouchpadScroll() {
+ scrollFlushScheduled = false
+ val dx = pendingScrollDx
+ val dy = pendingScrollDy
+ pendingScrollDx = 0.0
+ pendingScrollDy = 0.0
+ if (abs(dx) < 0.05 && abs(dy) < 0.05) return
+ val metrics = resources.displayMetrics
+ val scale = 34f * metrics.density
+ val maxStep = 170f * metrics.density
+ val stepX = (dx * scale).toFloat().coerceIn(-maxStep, maxStep)
+ val stepY = (dy * scale).toFloat().coerceIn(-maxStep, maxStep)
+ val toX = min(metrics.widthPixels - 1f, max(0f, pointerX - stepX))
+ val toY = min(metrics.heightPixels - 1f, max(0f, pointerY - stepY))
+ gesture(pointerX, pointerY, toX, toY, 62)
+ }
+
+ private fun showCursorOverlay() {
+ if (cursorView != null) return
+ val prefs = applicationContext.getSharedPreferences(RelayForegroundService.PREFS, MODE_PRIVATE)
+ val sizeDp = when (prefs.getInt(SettingsActivity.KEY_CURSOR_SIZE, 2)) {
+ 1 -> 14f
+ 3 -> 26f
+ else -> 18f
+ }
+ val cursorColor = when (prefs.getString(SettingsActivity.KEY_CURSOR_COLOR, "white")) {
+ "blue" -> Color.argb(220, 22, 100, 192)
+ "red" -> Color.argb(220, 244, 67, 54)
+ "black" -> Color.argb(220, 20, 20, 20)
+ else -> Color.argb(210, 255, 255, 255)
+ }
+ val strokeColor = if (prefs.getString(SettingsActivity.KEY_CURSOR_COLOR, "white") == "white")
+ Color.argb(180, 0, 0, 0) else Color.argb(180, 255, 255, 255)
+ val size = (sizeDp * resources.displayMetrics.density).toInt().coerceAtLeast(12)
+ val view = View(this).apply {
+ background = GradientDrawable().apply {
+ shape = GradientDrawable.OVAL
+ setColor(cursorColor)
+ setStroke((2 * resources.displayMetrics.density).toInt().coerceAtLeast(2), strokeColor)
+ }
+ elevation = 20f
+ }
+ val params = WindowManager.LayoutParams(
+ size, size,
+ WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
+ WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
+ WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
+ WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
+ PixelFormat.TRANSLUCENT
+ ).apply {
+ gravity = Gravity.TOP or Gravity.START
+ }
+ windowManager = getSystemService(WindowManager::class.java)
+ cursorView = view
+ cursorParams = params
+ windowManager?.addView(view, params)
+ updateCursorOverlay()
+ }
+
+ private fun updateCursorOverlay() {
+ val view = cursorView ?: return
+ val params = cursorParams ?: return
+ params.x = (pointerX - params.width / 2f).toInt()
+ params.y = (pointerY - params.height / 2f).toInt()
+ windowManager?.updateViewLayout(view, params)
+ }
+
+ private fun hideCursorOverlay() {
+ val view = cursorView ?: return
+ try { windowManager?.removeView(view) } catch (_: IllegalArgumentException) {}
+ cursorView = null
+ cursorParams = null
+ }
+
+ private fun editFocusedText(transform: (String) -> String) {
+ val root = rootInActiveWindow
+ val focused = root
+ ?.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)
+ ?.takeIf { it.isEditable }
+ ?: findFocusedNode(root)
+ if (focused == null) {
+ Log.i(TAG, "keyboard text ignored: no focused editable node")
+ return
+ }
+ val current = focused.text?.toString().orEmpty()
+ val args = Bundle().apply {
+ putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, transform(current))
+ }
+ if (!focused.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args)) {
+ Log.i(TAG, "keyboard text ignored: ACTION_SET_TEXT failed")
+ }
+ }
+
+ private fun findFocusedNode(node: AccessibilityNodeInfo?): AccessibilityNodeInfo? {
+ if (node == null) return null
+ if (node.isFocused && node.isEditable) return node
+ for (index in 0 until node.childCount) {
+ val found = findFocusedNode(node.getChild(index))
+ if (found != null) return found
+ }
+ return null
+ }
+
+ companion object {
+ private const val TAG = "InputFlowAccessibility"
+ private const val WM_MOUSEMOVE = 0x0200
+ private const val WM_LBUTTONUP = 0x0202
+ private const val WM_RBUTTONUP = 0x0205
+ private const val WM_MBUTTONUP = 0x0208
+ private const val WM_MOUSEWHEEL = 0x020A
+ private const val LLKHF_UP = 0x80
+ private const val VK_BACK = 0x08
+ private const val VK_TAB = 0x09
+ private const val VK_RETURN = 0x0D
+ private const val VK_SHIFT = 0x10
+ private const val VK_CONTROL = 0x11
+ private const val VK_MENU = 0x12
+ private const val VK_ESCAPE = 0x1B
+ private const val VK_PRIOR = 0x21 // PageUp
+ private const val VK_NEXT = 0x22 // PageDown
+ private const val VK_END = 0x23
+ private const val VK_HOME = 0x24
+ private const val VK_LEFT = 0x25
+ private const val VK_UP = 0x26
+ private const val VK_RIGHT = 0x27
+ private const val VK_DOWN = 0x28
+ private const val VK_SPACE = 0x20
+ private const val VK_DELETE = 0x2E
+ private const val VK_0 = 0x30
+ private const val VK_9 = 0x39
+ private const val VK_A = 0x41
+ private const val VK_N = 0x4E
+ private const val VK_Q = 0x51
+ private const val VK_Z = 0x5A
+ private const val VK_LWIN = 0x5B
+ private const val VK_RWIN = 0x5C
+ private const val VK_OEM_1 = 0xBA
+ private const val VK_OEM_PLUS = 0xBB
+ private const val VK_OEM_COMMA = 0xBC
+ private const val VK_OEM_MINUS = 0xBD
+ private const val VK_OEM_PERIOD = 0xBE
+ private const val VK_OEM_2 = 0xBF
+ private const val VK_OEM_3 = 0xC0
+ private const val VK_OEM_4 = 0xDB
+ private const val VK_OEM_5 = 0xDC
+ private const val VK_OEM_6 = 0xDD
+ private const val VK_OEM_7 = 0xDE
+
+ @Volatile
+ var instance: InputFlowAccessibilityService? = null
+ private set
+ }
+}
diff --git a/android/app/src/main/java/com/inputflow/android/InputFlowImeService.kt b/android/app/src/main/java/com/inputflow/android/InputFlowImeService.kt
new file mode 100644
index 0000000..391ffd2
--- /dev/null
+++ b/android/app/src/main/java/com/inputflow/android/InputFlowImeService.kt
@@ -0,0 +1,295 @@
+package com.inputflow.android
+
+import android.inputmethodservice.InputMethodService
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import android.os.SystemClock
+import android.util.Log
+import android.view.KeyEvent
+import android.view.View
+import org.json.JSONObject
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+
+class InputFlowImeService : InputMethodService() {
+ private val mainHandler = Handler(Looper.getMainLooper())
+ private var shiftDown = false
+ private var ctrlDown = false
+ private var altDown = false
+ private var metaDown = false
+
+ override fun onCreate() {
+ super.onCreate()
+ instance = this
+ }
+
+ override fun onDestroy() {
+ if (instance === this) {
+ instance = null
+ }
+ super.onDestroy()
+ }
+
+ override fun onEvaluateInputViewShown(): Boolean = false
+
+ override fun onCreateInputView(): View {
+ return View(this)
+ }
+
+ fun handleKeyboard(frame: JSONObject): Boolean {
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ return handleKeyboardOnMain(frame)
+ }
+
+ val handled = AtomicBoolean(false)
+ val latch = CountDownLatch(1)
+ mainHandler.post {
+ handled.set(handleKeyboardOnMain(frame))
+ latch.countDown()
+ }
+ latch.await(250, TimeUnit.MILLISECONDS)
+ return handled.get()
+ }
+
+ private fun handleKeyboardOnMain(frame: JSONObject): Boolean {
+ val vkCode = frame.optInt("vkCode")
+ val flags = frame.optInt("flags")
+ val keyUp = (flags and LLKHF_UP) != 0
+ updateModifier(vkCode, keyUp)
+
+ if (keyUp || altDown || metaDown || isCommandKey(vkCode)) {
+ return false
+ }
+
+ val action = inputAction(vkCode) ?: return false
+ val connection = currentInputConnection
+ if (connection == null) {
+ Log.i(TAG, "keyboard ignored: no current input connection")
+ return false
+ }
+ val handled = when (action) {
+ is InputAction.Commit -> connection.commitText(action.text, 1)
+ InputAction.Backspace -> connection.deleteSurroundingText(1, 0)
+ InputAction.Enter -> {
+ connection.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER)) &&
+ connection.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER))
+ }
+ is InputAction.Key -> {
+ connection.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, action.keyCode)) &&
+ connection.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, action.keyCode))
+ }
+ is InputAction.ModifiedKey -> sendModifiedKey(connection, action.keyCode, action.metaState)
+ is InputAction.Menu -> connection.performContextMenuAction(action.id)
+ }
+ Log.i(TAG, "keyboard action=${action.name} vk=$vkCode handled=$handled")
+ return handled
+ }
+
+ private fun updateModifier(vkCode: Int, keyUp: Boolean) {
+ val down = !keyUp
+ when (vkCode) {
+ VK_SHIFT -> shiftDown = down
+ VK_CONTROL -> ctrlDown = down
+ VK_MENU -> altDown = down
+ VK_LWIN, VK_RWIN -> metaDown = down
+ }
+ }
+
+ private fun isCommandKey(vkCode: Int): Boolean {
+ return vkCode == VK_ESCAPE ||
+ vkCode == VK_LWIN ||
+ vkCode == VK_RWIN
+ }
+
+ private fun inputAction(vkCode: Int): InputAction? {
+ if (ctrlDown) {
+ return when (vkCode) {
+ VK_A -> InputAction.Menu(android.R.id.selectAll)
+ VK_C -> InputAction.Menu(android.R.id.copy)
+ VK_V -> InputAction.Menu(android.R.id.paste)
+ VK_X -> InputAction.Menu(android.R.id.cut)
+ VK_Z -> InputAction.ModifiedKey(KeyEvent.KEYCODE_Z, KeyEvent.META_CTRL_ON)
+ VK_Y -> InputAction.ModifiedKey(KeyEvent.KEYCODE_Y, KeyEvent.META_CTRL_ON)
+ else -> keyEventAction(vkCode)?.let { InputAction.ModifiedKey(it, KeyEvent.META_CTRL_ON) }
+ }
+ }
+ return when (vkCode) {
+ VK_BACK -> InputAction.Backspace
+ VK_DELETE -> InputAction.Key(KeyEvent.KEYCODE_FORWARD_DEL)
+ VK_RETURN -> InputAction.Enter
+ VK_TAB -> InputAction.Commit("\t")
+ VK_SPACE -> InputAction.Commit(" ")
+ VK_LEFT -> InputAction.Key(KeyEvent.KEYCODE_DPAD_LEFT)
+ VK_UP -> InputAction.Key(KeyEvent.KEYCODE_DPAD_UP)
+ VK_RIGHT -> InputAction.Key(KeyEvent.KEYCODE_DPAD_RIGHT)
+ VK_DOWN -> InputAction.Key(KeyEvent.KEYCODE_DPAD_DOWN)
+ VK_HOME -> InputAction.Key(KeyEvent.KEYCODE_MOVE_HOME)
+ VK_END -> InputAction.Key(KeyEvent.KEYCODE_MOVE_END)
+ VK_PRIOR -> InputAction.Key(KeyEvent.KEYCODE_PAGE_UP)
+ VK_NEXT -> InputAction.Key(KeyEvent.KEYCODE_PAGE_DOWN)
+ in VK_0..VK_9 -> InputAction.Commit(keyChar(vkCode))
+ in VK_A..VK_Z -> InputAction.Commit(keyChar(vkCode))
+ in VK_NUMPAD0..VK_NUMPAD9 -> InputAction.Commit((vkCode - VK_NUMPAD0).toString())
+ VK_MULTIPLY, VK_ADD, VK_SUBTRACT, VK_DECIMAL, VK_DIVIDE -> InputAction.Commit(keyChar(vkCode))
+ VK_OEM_COMMA, VK_OEM_PERIOD, VK_OEM_MINUS, VK_OEM_PLUS,
+ VK_OEM_1, VK_OEM_2, VK_OEM_3, VK_OEM_4, VK_OEM_5, VK_OEM_6, VK_OEM_7 ->
+ InputAction.Commit(keyChar(vkCode))
+ else -> null
+ }
+ }
+
+ private fun keyEventAction(vkCode: Int): Int? {
+ return when (vkCode) {
+ VK_TAB -> KeyEvent.KEYCODE_TAB
+ VK_LEFT -> KeyEvent.KEYCODE_DPAD_LEFT
+ VK_UP -> KeyEvent.KEYCODE_DPAD_UP
+ VK_RIGHT -> KeyEvent.KEYCODE_DPAD_RIGHT
+ VK_DOWN -> KeyEvent.KEYCODE_DPAD_DOWN
+ VK_HOME -> KeyEvent.KEYCODE_MOVE_HOME
+ VK_END -> KeyEvent.KEYCODE_MOVE_END
+ VK_PRIOR -> KeyEvent.KEYCODE_PAGE_UP
+ VK_NEXT -> KeyEvent.KEYCODE_PAGE_DOWN
+ in VK_A..VK_Z -> KeyEvent.KEYCODE_A + (vkCode - VK_A)
+ else -> null
+ }
+ }
+
+ private fun sendModifiedKey(connection: android.view.inputmethod.InputConnection, keyCode: Int, metaState: Int): Boolean {
+ val now = SystemClock.uptimeMillis()
+ return connection.sendKeyEvent(KeyEvent(now, now, KeyEvent.ACTION_DOWN, keyCode, 0, metaState)) &&
+ connection.sendKeyEvent(KeyEvent(now, now, KeyEvent.ACTION_UP, keyCode, 0, metaState))
+ }
+
+ private fun keyChar(vkCode: Int): String {
+ val shifted = shiftDown
+ if (vkCode in VK_A..VK_Z) {
+ val c = vkCode.toChar()
+ return if (shifted) c.toString() else c.lowercaseChar().toString()
+ }
+ if (vkCode in VK_0..VK_9) {
+ val normal = "0123456789"[vkCode - VK_0]
+ val shiftedChars = ")!@#$%^&*("[vkCode - VK_0]
+ return (if (shifted) shiftedChars else normal).toString()
+ }
+ if (vkCode in VK_NUMPAD0..VK_NUMPAD9) {
+ return (vkCode - VK_NUMPAD0).toString()
+ }
+ return when (vkCode) {
+ VK_MULTIPLY -> "*"
+ VK_ADD -> "+"
+ VK_SUBTRACT -> "-"
+ VK_DECIMAL -> "."
+ VK_DIVIDE -> "/"
+ VK_OEM_COMMA -> if (shifted) "<" else ","
+ VK_OEM_PERIOD -> if (shifted) ">" else "."
+ VK_OEM_MINUS -> if (shifted) "_" else "-"
+ VK_OEM_PLUS -> if (shifted) "+" else "="
+ VK_OEM_1 -> if (shifted) ":" else ";"
+ VK_OEM_2 -> if (shifted) "?" else "/"
+ VK_OEM_3 -> if (shifted) "~" else "`"
+ VK_OEM_4 -> if (shifted) "{" else "["
+ VK_OEM_5 -> if (shifted) "|" else "\\"
+ VK_OEM_6 -> if (shifted) "}" else "]"
+ VK_OEM_7 -> if (shifted) "\"" else "'"
+ else -> ""
+ }
+ }
+
+ private sealed class InputAction {
+ abstract val name: String
+
+ data class Commit(val text: String) : InputAction() {
+ override val name = "commit"
+ }
+
+ object Backspace : InputAction() {
+ override val name = "backspace"
+ }
+
+ object Enter : InputAction() {
+ override val name = "enter"
+ }
+
+ data class Key(val keyCode: Int) : InputAction() {
+ override val name = "key"
+ }
+
+ data class ModifiedKey(val keyCode: Int, val metaState: Int) : InputAction() {
+ override val name = "modified-key"
+ }
+
+ data class Menu(val id: Int) : InputAction() {
+ override val name = "menu"
+ }
+ }
+
+ companion object {
+ private const val TAG = "InputFlowIme"
+ private const val LLKHF_UP = 0x80
+ private const val VK_BACK = 0x08
+ private const val VK_TAB = 0x09
+ private const val VK_RETURN = 0x0D
+ private const val VK_SHIFT = 0x10
+ private const val VK_CONTROL = 0x11
+ private const val VK_MENU = 0x12
+ private const val VK_ESCAPE = 0x1B
+ private const val VK_SPACE = 0x20
+ private const val VK_PRIOR = 0x21
+ private const val VK_NEXT = 0x22
+ private const val VK_END = 0x23
+ private const val VK_HOME = 0x24
+ private const val VK_LEFT = 0x25
+ private const val VK_UP = 0x26
+ private const val VK_RIGHT = 0x27
+ private const val VK_DOWN = 0x28
+ private const val VK_DELETE = 0x2E
+ private const val VK_0 = 0x30
+ private const val VK_9 = 0x39
+ private const val VK_A = 0x41
+ private const val VK_C = 0x43
+ private const val VK_V = 0x56
+ private const val VK_X = 0x58
+ private const val VK_Y = 0x59
+ private const val VK_Z = 0x5A
+ private const val VK_LWIN = 0x5B
+ private const val VK_RWIN = 0x5C
+ private const val VK_NUMPAD0 = 0x60
+ private const val VK_NUMPAD9 = 0x69
+ private const val VK_MULTIPLY = 0x6A
+ private const val VK_ADD = 0x6B
+ private const val VK_SUBTRACT = 0x6D
+ private const val VK_DECIMAL = 0x6E
+ private const val VK_DIVIDE = 0x6F
+ private const val VK_OEM_1 = 0xBA
+ private const val VK_OEM_PLUS = 0xBB
+ private const val VK_OEM_COMMA = 0xBC
+ private const val VK_OEM_MINUS = 0xBD
+ private const val VK_OEM_PERIOD = 0xBE
+ private const val VK_OEM_2 = 0xBF
+ private const val VK_OEM_3 = 0xC0
+ private const val VK_OEM_4 = 0xDB
+ private const val VK_OEM_5 = 0xDC
+ private const val VK_OEM_6 = 0xDD
+ private const val VK_OEM_7 = 0xDE
+
+ @Volatile
+ var instance: InputFlowImeService? = null
+ private set
+
+ fun restorePreviousKeyboard(): Boolean {
+ val service = instance ?: return false
+ service.mainHandler.post {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ val handled = service.switchToPreviousInputMethod()
+ Log.i(TAG, "restore previous keyboard handled=$handled")
+ } else {
+ service.requestHideSelf(0)
+ Log.i(TAG, "restore previous keyboard unavailable on this Android version")
+ }
+ }
+ return true
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/inputflow/android/KeyMap.kt b/android/app/src/main/java/com/inputflow/android/KeyMap.kt
new file mode 100644
index 0000000..d99104f
--- /dev/null
+++ b/android/app/src/main/java/com/inputflow/android/KeyMap.kt
@@ -0,0 +1,79 @@
+package com.inputflow.android
+
+import android.content.SharedPreferences
+
+object KeyMap {
+ const val MOD_SHIFT = 1
+ const val MOD_CTRL = 2
+ const val MOD_ALT = 4
+ const val MOD_WIN = 8
+
+ data class Action(val id: String, val label: String, val defaultVk: Int, val defaultMods: Int)
+
+ val ACTIONS = listOf(
+ Action("back", "Back", 0x1B, 0),
+ Action("home", "Home", 0x5B, 0),
+ Action("recents", "Recent Apps", 0x09, MOD_ALT),
+ Action("notifications", "Notifications", 0x4E, MOD_CTRL or MOD_ALT),
+ Action("quick_settings", "Quick Settings", 0x51, MOD_CTRL or MOD_ALT),
+ Action("volume_up", "Volume Up", 0xAF, 0),
+ Action("volume_down", "Volume Down", 0xAE, 0),
+ Action("screenshot", "Screenshot", 0x2C, 0),
+ Action("swipe_left", "Swipe Left (Back)", 0, 0),
+ Action("swipe_right", "Swipe Right", 0, 0),
+ Action("swipe_up", "Swipe Up / Home", 0, 0),
+ Action("swipe_down", "Swipe Down", 0, 0),
+ Action("zoom_in", "Zoom In", 0, 0),
+ Action("zoom_out", "Zoom Out", 0, 0),
+ )
+
+ fun getVk(prefs: SharedPreferences, action: Action): Int =
+ prefs.getInt("keymap_${action.id}_vk", action.defaultVk)
+
+ fun getMods(prefs: SharedPreferences, action: Action): Int =
+ prefs.getInt("keymap_${action.id}_mods", action.defaultMods)
+
+ fun save(prefs: SharedPreferences, actionId: String, vk: Int, mods: Int) {
+ prefs.edit()
+ .putInt("keymap_${actionId}_vk", vk)
+ .putInt("keymap_${actionId}_mods", mods)
+ .apply()
+ }
+
+ fun bindingText(vk: Int, mods: Int): String {
+ if (vk == 0) return "None"
+ return buildString {
+ if (mods and MOD_CTRL != 0) append("Ctrl+")
+ if (mods and MOD_ALT != 0) append("Alt+")
+ if (mods and MOD_SHIFT != 0) append("Shift+")
+ if (mods and MOD_WIN != 0) append("Win+")
+ append(vkName(vk))
+ }
+ }
+
+ fun vkName(vk: Int): String = when (vk) {
+ 0x08 -> "Bksp"
+ 0x09 -> "Tab"
+ 0x0D -> "Enter"
+ 0x1B -> "Esc"
+ 0x20 -> "Space"
+ 0x2C -> "PrtSc"
+ 0x2E -> "Del"
+ 0x5B, 0x5C -> "Win"
+ 0xAE -> "Vol-"
+ 0xAF -> "Vol+"
+ in 0x41..0x5A -> ('A' + vk - 0x41).toChar().toString()
+ in 0x30..0x39 -> ('0' + vk - 0x30).toChar().toString()
+ in 0x70..0x7B -> "F${vk - 0x70 + 1}"
+ else -> "0x${vk.toString(16).uppercase()}"
+ }
+
+ fun currentMods(shift: Boolean, ctrl: Boolean, alt: Boolean, win: Boolean): Int {
+ var m = 0
+ if (shift) m = m or MOD_SHIFT
+ if (ctrl) m = m or MOD_CTRL
+ if (alt) m = m or MOD_ALT
+ if (win) m = m or MOD_WIN
+ return m
+ }
+}
diff --git a/android/app/src/main/java/com/inputflow/android/KeyMapperActivity.kt b/android/app/src/main/java/com/inputflow/android/KeyMapperActivity.kt
new file mode 100644
index 0000000..208da81
--- /dev/null
+++ b/android/app/src/main/java/com/inputflow/android/KeyMapperActivity.kt
@@ -0,0 +1,125 @@
+package com.inputflow.android
+
+import android.content.Context
+import android.os.Bundle
+import android.view.Gravity
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.appcompat.app.AppCompatActivity
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.appbar.MaterialToolbar
+import com.google.android.material.chip.Chip
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+
+class KeyMapperActivity : AppCompatActivity() {
+
+ private lateinit var adapter: ShortcutAdapter
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_key_mapper)
+
+ val toolbar = findViewById(R.id.toolbar)
+ setSupportActionBar(toolbar)
+ supportActionBar?.setDisplayShowTitleEnabled(false)
+ toolbar.setNavigationOnClickListener { finish() }
+
+ val prefs = getSharedPreferences(RelayForegroundService.PREFS, Context.MODE_PRIVATE)
+ adapter = ShortcutAdapter(prefs) { action, vk, mods ->
+ KeyMap.save(prefs, action.id, vk, mods)
+ }
+
+ val recycler = findViewById(R.id.shortcutsList)
+ recycler.layoutManager = LinearLayoutManager(this)
+ recycler.adapter = adapter
+ }
+
+ override fun onDestroy() {
+ RelayForegroundService.keyCaptureCallback = null
+ super.onDestroy()
+ }
+
+ private fun openBindingDialog(action: KeyMap.Action, onSaved: (Int, Int) -> Unit) {
+ RelayForegroundService.keyCaptureCallback = null
+
+ val tvHint = TextView(this).apply {
+ text = getString(R.string.shortcut_listen_hint)
+ textSize = 18f
+ gravity = Gravity.CENTER
+ setPadding(64, 48, 64, 16)
+ }
+
+ var capturedVk = 0
+ var currentMods = 0
+
+ val dialog = MaterialAlertDialogBuilder(this)
+ .setTitle(action.label)
+ .setView(tvHint)
+ .setNegativeButton(android.R.string.cancel) { _, _ ->
+ RelayForegroundService.keyCaptureCallback = null
+ }
+ .setNeutralButton(R.string.shortcut_clear) { _, _ ->
+ RelayForegroundService.keyCaptureCallback = null
+ onSaved(0, 0)
+ }
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ RelayForegroundService.keyCaptureCallback = null
+ if (capturedVk != 0) onSaved(capturedVk, currentMods)
+ }
+ .create()
+
+ dialog.setOnDismissListener {
+ RelayForegroundService.keyCaptureCallback = null
+ }
+
+ RelayForegroundService.keyCaptureCallback = { vk, flags ->
+ val down = (flags and 0x80) == 0
+ when (vk) {
+ 0x10 -> currentMods = if (down) currentMods or KeyMap.MOD_SHIFT else currentMods and KeyMap.MOD_SHIFT.inv()
+ 0x11 -> currentMods = if (down) currentMods or KeyMap.MOD_CTRL else currentMods and KeyMap.MOD_CTRL.inv()
+ 0x12 -> currentMods = if (down) currentMods or KeyMap.MOD_ALT else currentMods and KeyMap.MOD_ALT.inv()
+ 0x5B, 0x5C -> currentMods = if (down) currentMods or KeyMap.MOD_WIN else currentMods and KeyMap.MOD_WIN.inv()
+ else -> if (down) {
+ capturedVk = vk
+ runOnUiThread { tvHint.text = KeyMap.bindingText(vk, currentMods) }
+ }
+ }
+ }
+
+ dialog.show()
+ }
+
+ inner class ShortcutAdapter(
+ private val prefs: android.content.SharedPreferences,
+ private val onSaved: (KeyMap.Action, Int, Int) -> Unit
+ ) : RecyclerView.Adapter() {
+
+ inner class VH(view: View) : RecyclerView.ViewHolder(view) {
+ val tvAction: TextView = view.findViewById(R.id.tvAction)
+ val chipBinding: Chip = view.findViewById(R.id.chipBinding)
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH =
+ VH(LayoutInflater.from(parent.context).inflate(R.layout.row_shortcut, parent, false))
+
+ override fun getItemCount() = KeyMap.ACTIONS.size
+
+ override fun onBindViewHolder(holder: VH, position: Int) {
+ val action = KeyMap.ACTIONS[position]
+ holder.tvAction.text = action.label
+ holder.chipBinding.text = KeyMap.bindingText(
+ KeyMap.getVk(prefs, action),
+ KeyMap.getMods(prefs, action)
+ )
+ holder.chipBinding.setOnClickListener {
+ openBindingDialog(action) { vk, mods ->
+ onSaved(action, vk, mods)
+ notifyItemChanged(position)
+ }
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/inputflow/android/LayoutEditorActivity.kt b/android/app/src/main/java/com/inputflow/android/LayoutEditorActivity.kt
new file mode 100644
index 0000000..eb70597
--- /dev/null
+++ b/android/app/src/main/java/com/inputflow/android/LayoutEditorActivity.kt
@@ -0,0 +1,115 @@
+package com.inputflow.android
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.Build
+import android.os.Bundle
+import android.view.View
+import android.widget.TextView
+import androidx.appcompat.app.AppCompatActivity
+import com.google.android.material.appbar.MaterialToolbar
+import com.google.android.material.button.MaterialButton
+import org.json.JSONArray
+import org.json.JSONObject
+
+class LayoutEditorActivity : AppCompatActivity() {
+
+ private lateinit var editorView: LayoutEditorView
+ private lateinit var notConnectedHint: TextView
+ private lateinit var btnApply: MaterialButton
+
+ private val devicesReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ val json = intent.getStringExtra(RelayForegroundService.EXTRA_DEVICES_JSON) ?: return
+ applyDevicesJson(json)
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_layout_editor)
+
+ val toolbar = findViewById(R.id.toolbar)
+ setSupportActionBar(toolbar)
+ supportActionBar?.setDisplayShowTitleEnabled(false)
+ toolbar.setNavigationOnClickListener { finish() }
+
+ editorView = findViewById(R.id.editorView)
+ notConnectedHint = findViewById(R.id.notConnectedHint)
+ btnApply = findViewById(R.id.btnApplyLayout)
+
+ val cached = RelayForegroundService.cachedDevicesJson
+ if (cached != null) {
+ applyDevicesJson(cached)
+ } else {
+ showNotConnected()
+ }
+
+ btnApply.setOnClickListener {
+ val layout = editorView.getLayoutJson()
+ RelayForegroundService.instance?.sendTopologyUpdate(layout)
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ val filter = IntentFilter(RelayForegroundService.ACTION_DEVICES_BROADCAST)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ registerReceiver(devicesReceiver, filter, RECEIVER_NOT_EXPORTED)
+ } else {
+ @Suppress("UnspecifiedRegisterReceiverFlag")
+ registerReceiver(devicesReceiver, filter)
+ }
+ }
+
+ override fun onPause() {
+ super.onPause()
+ try { unregisterReceiver(devicesReceiver) } catch (_: IllegalArgumentException) {}
+ }
+
+ private fun applyDevicesJson(json: String) {
+ try {
+ val obj = JSONObject(json)
+ val devArr = obj.getJSONArray("devices")
+ val layoutArr = obj.optJSONArray("layout") ?: JSONArray()
+ val layoutMap = mutableMapOf>()
+ for (i in 0 until layoutArr.length()) {
+ val item = layoutArr.getJSONObject(i)
+ layoutMap[item.getString("id")] = item.getInt("x") to item.getInt("y")
+ }
+
+ val cell = (64 * resources.displayMetrics.density).toInt().coerceAtLeast(1)
+ val localId = "android"
+ val rects = mutableListOf()
+
+ for (i in 0 until devArr.length()) {
+ val dev = devArr.getJSONObject(i)
+ val id = dev.getString("id")
+ val name = dev.getString("name")
+ val screenW = dev.optInt("width", 1920).coerceAtLeast(1)
+ val screenH = dev.optInt("height", 1080).coerceAtLeast(1)
+ val gridW = (screenW / cell).coerceAtLeast(4)
+ val gridH = (screenH / cell).coerceAtLeast(3)
+ val pos = layoutMap[id]
+ val gridX = ((pos?.first ?: 0) / cell)
+ val gridY = ((pos?.second ?: 0) / cell)
+ rects.add(DeviceRect(id, name, gridX, gridY, gridW, gridH, id == localId))
+ }
+
+ editorView.devices = rects
+ editorView.visibility = View.VISIBLE
+ notConnectedHint.visibility = View.GONE
+ btnApply.isEnabled = true
+ } catch (_: Exception) {
+ showNotConnected()
+ }
+ }
+
+ private fun showNotConnected() {
+ editorView.visibility = View.INVISIBLE
+ notConnectedHint.visibility = View.VISIBLE
+ btnApply.isEnabled = false
+ }
+}
diff --git a/android/app/src/main/java/com/inputflow/android/LayoutEditorView.kt b/android/app/src/main/java/com/inputflow/android/LayoutEditorView.kt
new file mode 100644
index 0000000..bfbc6b0
--- /dev/null
+++ b/android/app/src/main/java/com/inputflow/android/LayoutEditorView.kt
@@ -0,0 +1,194 @@
+package com.inputflow.android
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.RectF
+import android.graphics.Typeface
+import android.util.AttributeSet
+import android.view.MotionEvent
+import android.view.View
+import kotlin.math.roundToInt
+
+data class DeviceRect(
+ val id: String,
+ val name: String,
+ var gridX: Int,
+ var gridY: Int,
+ val gridW: Int,
+ val gridH: Int,
+ val isLocal: Boolean
+)
+
+class LayoutEditorView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null
+) : View(context, attrs) {
+
+ var devices: List = emptyList()
+ set(value) {
+ field = value
+ centerDevices()
+ invalidate()
+ }
+
+ private val gridCellPx get() = (64 * resources.displayMetrics.density).toInt()
+
+ private val bgPaint = Paint().apply {
+ color = Color.parseColor("#0A1628")
+ style = Paint.Style.FILL
+ }
+ private val gridPaint = Paint().apply {
+ color = Color.parseColor("#1A2840")
+ style = Paint.Style.STROKE
+ strokeWidth = 1f
+ }
+ private val deviceFillPaint = Paint().apply {
+ style = Paint.Style.FILL
+ isAntiAlias = true
+ }
+ private val deviceStrokePaint = Paint().apply {
+ style = Paint.Style.STROKE
+ strokeWidth = 2f
+ isAntiAlias = true
+ }
+ private val labelPaint = Paint().apply {
+ color = Color.WHITE
+ textSize = 14f * resources.displayMetrics.density
+ isAntiAlias = true
+ typeface = Typeface.DEFAULT_BOLD
+ textAlign = Paint.Align.CENTER
+ }
+ private val badgePaint = Paint().apply {
+ color = Color.parseColor("#1A2840")
+ textSize = 10f * resources.displayMetrics.density
+ isAntiAlias = true
+ textAlign = Paint.Align.CENTER
+ }
+
+ private var draggingId: String? = null
+ private var touchOffsetX = 0f
+ private var touchOffsetY = 0f
+ private var viewOffsetX = 0
+ private var viewOffsetY = 0
+
+ private fun centerDevices() {
+ if (devices.isEmpty() || width == 0 || height == 0) return
+ val minGx = devices.minOf { it.gridX }
+ val minGy = devices.minOf { it.gridY }
+ val maxGx = devices.maxOf { it.gridX + it.gridW }
+ val maxGy = devices.maxOf { it.gridY + it.gridH }
+ val totalW = (maxGx - minGx) * gridCellPx
+ val totalH = (maxGy - minGy) * gridCellPx
+ viewOffsetX = (width - totalW) / 2 - minGx * gridCellPx
+ viewOffsetY = (height - totalH) / 2 - minGy * gridCellPx
+ }
+
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
+ super.onSizeChanged(w, h, oldw, oldh)
+ centerDevices()
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ // Background
+ canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), bgPaint)
+
+ // Grid
+ val cell = gridCellPx.toFloat()
+ var gx = (viewOffsetX % cell + cell) % cell
+ while (gx < width) {
+ canvas.drawLine(gx, 0f, gx, height.toFloat(), gridPaint)
+ gx += cell
+ }
+ var gy = (viewOffsetY % cell + cell) % cell
+ while (gy < height) {
+ canvas.drawLine(0f, gy, width.toFloat(), gy, gridPaint)
+ gy += cell
+ }
+
+ // Devices
+ for (device in devices) {
+ val px = device.gridX * gridCellPx + viewOffsetX
+ val py = device.gridY * gridCellPx + viewOffsetY
+ val pw = device.gridW * gridCellPx
+ val ph = device.gridH * gridCellPx
+ val rect = RectF(px.toFloat(), py.toFloat(), (px + pw).toFloat(), (py + ph).toFloat())
+ val radius = 12f * resources.displayMetrics.density
+
+ deviceFillPaint.color = if (device.isLocal)
+ Color.parseColor("#1B5E20")
+ else
+ Color.parseColor("#0D47A1")
+ deviceFillPaint.alpha = 200
+ canvas.drawRoundRect(rect, radius, radius, deviceFillPaint)
+
+ deviceStrokePaint.color = if (device.isLocal)
+ Color.parseColor("#66BB6A")
+ else
+ Color.parseColor("#42A5F5")
+ canvas.drawRoundRect(rect, radius, radius, deviceStrokePaint)
+
+ // Label
+ val cx = rect.centerX()
+ val cy = rect.centerY()
+ val lineH = labelPaint.textSize
+ if (device.isLocal) {
+ canvas.drawText(device.name, cx, cy - lineH * 0.2f, labelPaint)
+ canvas.drawText(context.getString(R.string.editor_this_device), cx, cy + lineH * 0.9f, badgePaint)
+ } else {
+ canvas.drawText(device.name, cx, cy + lineH * 0.35f, labelPaint)
+ }
+ }
+ }
+
+ override fun onTouchEvent(event: MotionEvent): Boolean {
+ when (event.actionMasked) {
+ MotionEvent.ACTION_DOWN -> {
+ val cell = gridCellPx
+ for (device in devices.reversed()) {
+ if (device.isLocal) continue
+ val px = device.gridX * cell + viewOffsetX
+ val py = device.gridY * cell + viewOffsetY
+ val pw = device.gridW * cell
+ val ph = device.gridH * cell
+ if (event.x >= px && event.x <= px + pw && event.y >= py && event.y <= py + ph) {
+ draggingId = device.id
+ touchOffsetX = event.x - px
+ touchOffsetY = event.y - py
+ return true
+ }
+ }
+ }
+ MotionEvent.ACTION_MOVE -> {
+ val id = draggingId ?: return false
+ val device = devices.find { it.id == id } ?: return false
+ val cell = gridCellPx
+ val newPx = event.x - touchOffsetX - viewOffsetX
+ val newPy = event.y - touchOffsetY - viewOffsetY
+ device.gridX = (newPx / cell).roundToInt()
+ device.gridY = (newPy / cell).roundToInt()
+ invalidate()
+ return true
+ }
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
+ draggingId = null
+ invalidate()
+ }
+ }
+ return super.onTouchEvent(event)
+ }
+
+ fun getLayoutJson(): org.json.JSONArray {
+ val arr = org.json.JSONArray()
+ for (device in devices) {
+ arr.put(
+ org.json.JSONObject()
+ .put("id", device.id)
+ .put("x", device.gridX * gridCellPx)
+ .put("y", device.gridY * gridCellPx)
+ )
+ }
+ return arr
+ }
+}
diff --git a/android/app/src/main/java/com/inputflow/android/MainActivity.kt b/android/app/src/main/java/com/inputflow/android/MainActivity.kt
new file mode 100644
index 0000000..75fa783
--- /dev/null
+++ b/android/app/src/main/java/com/inputflow/android/MainActivity.kt
@@ -0,0 +1,218 @@
+package com.inputflow.android
+
+import android.Manifest
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.provider.Settings
+import android.view.Menu
+import android.view.MenuItem
+import android.view.inputmethod.InputMethodManager
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.appcompat.app.AppCompatActivity
+import com.google.android.material.appbar.MaterialToolbar
+import com.google.android.material.button.MaterialButton
+import com.google.android.material.card.MaterialCardView
+import com.google.android.material.textfield.TextInputEditText
+
+class MainActivity : AppCompatActivity() {
+
+ private lateinit var hostField: TextInputEditText
+ private lateinit var portField: TextInputEditText
+ private lateinit var secretField: TextInputEditText
+ private lateinit var statusCard: MaterialCardView
+ private lateinit var statusDot: android.view.View
+ private lateinit var statusText: TextView
+ private lateinit var statusDetail: TextView
+
+ private val statusReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ val state = intent.getStringExtra(RelayForegroundService.EXTRA_STATE) ?: return
+ val detail = intent.getStringExtra(RelayForegroundService.EXTRA_DETAIL)
+ updateStatus(state, detail)
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_main)
+
+ val toolbar = findViewById(R.id.toolbar)
+ setSupportActionBar(toolbar)
+ supportActionBar?.setDisplayShowTitleEnabled(false)
+ toolbar.setOnMenuItemClickListener { item ->
+ if (item.itemId == R.id.action_settings) {
+ startActivity(Intent(this, SettingsActivity::class.java))
+ true
+ } else {
+ false
+ }
+ }
+
+ hostField = findViewById(R.id.hostField)
+ portField = findViewById(R.id.portField)
+ secretField = findViewById(R.id.secretField)
+ statusCard = findViewById(R.id.statusCard)
+ statusDot = findViewById(R.id.statusDot)
+ statusText = findViewById(R.id.statusText)
+ statusDetail = findViewById(R.id.statusDetail)
+
+ val prefs = getSharedPreferences(RelayForegroundService.PREFS, Context.MODE_PRIVATE)
+ applyPairingUri(intent)
+ hostField.setText(prefs.getString(RelayForegroundService.KEY_HOST, ""))
+ portField.setText(prefs.getInt(RelayForegroundService.KEY_PORT, 15102).toString())
+ secretField.setText(prefs.getString(RelayForegroundService.KEY_SECRET, ""))
+
+ findViewById(R.id.btnConnect).setOnClickListener {
+ prefs.edit()
+ .putString(RelayForegroundService.KEY_HOST, hostField.text.toString().trim())
+ .putInt(RelayForegroundService.KEY_PORT, portField.text.toString().toIntOrNull() ?: 15102)
+ .putString(RelayForegroundService.KEY_SECRET, secretField.text.toString().trim())
+ .apply()
+ requestNotificationPermission()
+ startRelayService(Intent(this, RelayForegroundService::class.java))
+ }
+
+ findViewById(R.id.btnRelease).setOnClickListener {
+ startRelayService(
+ Intent(this, RelayForegroundService::class.java)
+ .setAction(RelayForegroundService.ACTION_RELEASE)
+ )
+ }
+
+ findViewById(R.id.btnAccessibility).setOnClickListener {
+ startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
+ }
+
+ findViewById(R.id.btnKeyboard).setOnClickListener {
+ (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager).showInputMethodPicker()
+ }
+
+ findViewById(R.id.btnNotifications).setOnClickListener {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ startActivity(
+ Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName"))
+ )
+ }
+ }
+
+ refreshStatus()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ val filter = IntentFilter(RelayForegroundService.ACTION_STATUS_BROADCAST)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ registerReceiver(statusReceiver, filter, RECEIVER_NOT_EXPORTED)
+ } else {
+ @Suppress("UnspecifiedRegisterReceiverFlag")
+ registerReceiver(statusReceiver, filter)
+ }
+ refreshStatus()
+ }
+
+ override fun onPause() {
+ super.onPause()
+ try { unregisterReceiver(statusReceiver) } catch (_: IllegalArgumentException) {}
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ if (item.itemId == R.id.action_settings) {
+ startActivity(Intent(this, SettingsActivity::class.java))
+ return true
+ }
+ return super.onOptionsItemSelected(item)
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.main_menu, menu)
+ return true
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ applyPairingUri(intent)
+ val prefs = getSharedPreferences(RelayForegroundService.PREFS, Context.MODE_PRIVATE)
+ hostField.setText(prefs.getString(RelayForegroundService.KEY_HOST, ""))
+ portField.setText(prefs.getInt(RelayForegroundService.KEY_PORT, 15102).toString())
+ secretField.setText(prefs.getString(RelayForegroundService.KEY_SECRET, ""))
+ }
+
+ private fun refreshStatus() {
+ val state = if (InputFlowAccessibilityService.instance != null)
+ RelayForegroundService.STATE_CONNECTED
+ else
+ RelayForegroundService.STATE_DISCONNECTED
+ updateStatus(state, null)
+ }
+
+ private fun updateStatus(state: String, detail: String?) {
+ val cardBgRes: Int
+ val accentColorRes: Int
+ val labelRes: Int
+ when (state) {
+ RelayForegroundService.STATE_CONNECTED -> {
+ cardBgRes = R.color.colorConnectedBg
+ accentColorRes = R.color.colorConnected
+ labelRes = R.string.status_connected
+ }
+ RelayForegroundService.STATE_CONNECTING -> {
+ cardBgRes = R.color.colorConnectingBg
+ accentColorRes = R.color.colorConnecting
+ labelRes = R.string.status_connecting
+ }
+ else -> {
+ cardBgRes = R.color.colorDisconnectedBg
+ accentColorRes = R.color.colorDisconnected
+ labelRes = R.string.status_disconnected
+ }
+ }
+ val accentColor = getColor(accentColorRes)
+ statusCard.setCardBackgroundColor(getColor(cardBgRes))
+ statusDot.background.setTint(accentColor)
+ statusText.setTextColor(accentColor)
+ statusText.setText(labelRes)
+ if (detail != null) {
+ statusDetail.visibility = android.view.View.VISIBLE
+ statusDetail.text = detail
+ statusDetail.setTextColor(accentColor)
+ } else {
+ statusDetail.visibility = android.view.View.GONE
+ }
+ }
+
+ private fun requestNotificationPermission() {
+ if (Build.VERSION.SDK_INT >= 33) {
+ requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 20)
+ }
+ }
+
+ private fun applyPairingUri(intent: Intent?) {
+ val data = intent?.data ?: return
+ if (data.scheme != "inputflow" || data.host != "android-peer") return
+ val host = data.getQueryParameter("host").orEmpty()
+ val port = data.getQueryParameter("port")?.toIntOrNull() ?: 15102
+ val secret = data.getQueryParameter("secret").orEmpty().trim()
+ if (host.isBlank() || secret.isBlank()) return
+ getSharedPreferences(RelayForegroundService.PREFS, Context.MODE_PRIVATE)
+ .edit()
+ .putString(RelayForegroundService.KEY_HOST, host)
+ .putInt(RelayForegroundService.KEY_PORT, port)
+ .putString(RelayForegroundService.KEY_SECRET, secret)
+ .apply()
+ }
+
+ private fun startRelayService(intent: Intent) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ startForegroundService(intent)
+ } else {
+ startService(intent)
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt b/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt
new file mode 100644
index 0000000..d3fbbc2
--- /dev/null
+++ b/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt
@@ -0,0 +1,219 @@
+package com.inputflow.android
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.IBinder
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.DataOutputStream
+import java.net.Socket
+import java.util.concurrent.atomic.AtomicBoolean
+
+class RelayForegroundService : Service() {
+ private val running = AtomicBoolean(false)
+ private var worker: Thread? = null
+
+ @Volatile
+ private var output: DataOutputStream? = null
+ @Volatile
+ private var remoteControlActive = false
+
+ override fun onCreate() {
+ super.onCreate()
+ instance = this
+ createNotificationChannel()
+ }
+
+ override fun onDestroy() {
+ if (instance === this) instance = null
+ running.set(false)
+ worker?.interrupt()
+ output = null
+ deactivateRemoteControl()
+ super.onDestroy()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ when (intent?.action) {
+ ACTION_RELEASE -> {
+ if (!running.get()) {
+ startForeground(NOTIFICATION_ID, notification("Release requested"))
+ deactivateRemoteControl()
+ stopSelf()
+ } else {
+ sendRelease()
+ deactivateRemoteControl()
+ }
+ }
+ ACTION_STOP -> {
+ if (!running.get()) {
+ startForeground(NOTIFICATION_ID, notification("Stopping"))
+ }
+ deactivateRemoteControl()
+ stopSelf()
+ }
+ else -> startRelay()
+ }
+ return START_STICKY
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ fun sendTopologyUpdate(layout: JSONArray) {
+ try {
+ output?.let {
+ RelayProtocol.writeFrame(it, JSONObject().put("type", "topology_update").put("layout", layout))
+ }
+ } catch (_: Exception) {}
+ }
+
+ private fun startRelay() {
+ if (!running.compareAndSet(false, true)) return
+ startForeground(NOTIFICATION_ID, notification("Connecting"))
+ broadcastStatus(STATE_CONNECTING, null)
+ worker = Thread({ relayLoop() }, "inputflow-relay").also { it.start() }
+ }
+
+ private fun relayLoop() {
+ val prefs = getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ while (running.get()) {
+ val host = prefs.getString(KEY_HOST, "").orEmpty()
+ val secret = prefs.getString(KEY_SECRET, "").orEmpty()
+ val port = prefs.getInt(KEY_PORT, 15102)
+ if (host.isBlank() || secret.isBlank()) {
+ startForeground(NOTIFICATION_ID, notification("Missing pairing settings"))
+ broadcastStatus(STATE_DISCONNECTED, "Missing host or secret")
+ sleepQuietly(2000)
+ continue
+ }
+
+ try {
+ Socket(host, port).use { socket ->
+ socket.tcpNoDelay = true
+ val device = "${Build.MANUFACTURER} ${Build.MODEL}".trim()
+ val streams = RelayProtocol.authenticate(socket, secret, device)
+ output = streams.second
+ deactivateRemoteControl()
+ startForeground(NOTIFICATION_ID, notification("Connected to $host:$port"))
+ broadcastStatus(STATE_CONNECTED, "$host:$port")
+ while (running.get()) {
+ val frame = RelayProtocol.readFrame(streams.first)
+ when (frame.optString("type")) {
+ "control" -> setRemoteControlActive(frame.optBoolean("active", false))
+ "mouse" -> if (remoteControlActive) {
+ InputFlowAccessibilityService.instance?.handleMouse(frame)
+ }
+ "keyboard" -> {
+ val cb = keyCaptureCallback
+ if (cb != null) {
+ cb(frame.optInt("vkCode"), frame.optInt("flags"))
+ } else if (remoteControlActive) {
+ val accessibility = InputFlowAccessibilityService.instance
+ if (accessibility?.handleMappedKeyboard(frame) != true) {
+ val laptopTypingEnabled = prefs.getBoolean(KEY_LAPTOP_TYPING_ENABLED, false)
+ if (!laptopTypingEnabled || InputFlowImeService.instance?.handleKeyboard(frame) != true) {
+ accessibility?.handleKeyboard(frame)
+ }
+ }
+ }
+ }
+ "gesture" -> if (remoteControlActive) {
+ InputFlowAccessibilityService.instance?.handleGesture(frame)
+ }
+ "devices_info" -> handleDevicesInfo(frame)
+ }
+ }
+ deactivateRemoteControl()
+ }
+ } catch (_: Exception) {
+ output = null
+ deactivateRemoteControl()
+ startForeground(NOTIFICATION_ID, notification("Disconnected; retrying"))
+ broadcastStatus(STATE_DISCONNECTED, "Retrying…")
+ sleepQuietly(1500)
+ }
+ }
+ }
+
+ private fun handleDevicesInfo(frame: JSONObject) {
+ val json = frame.toString()
+ cachedDevicesJson = json
+ sendBroadcast(Intent(ACTION_DEVICES_BROADCAST).putExtra(EXTRA_DEVICES_JSON, json))
+ }
+
+ private fun sendRelease() {
+ try {
+ output?.let {
+ RelayProtocol.writeFrame(it, JSONObject().put("type", "release"))
+ }
+ } catch (_: Exception) {}
+ }
+
+ private fun setRemoteControlActive(active: Boolean) {
+ remoteControlActive = active
+ InputFlowAccessibilityService.instance?.setRemoteControlActive(active)
+ if (!active) {
+ InputFlowImeService.restorePreviousKeyboard()
+ }
+ }
+
+ private fun deactivateRemoteControl() {
+ setRemoteControlActive(false)
+ }
+
+ private fun broadcastStatus(state: String, detail: String?) {
+ sendBroadcast(
+ Intent(ACTION_STATUS_BROADCAST)
+ .putExtra(EXTRA_STATE, state)
+ .putExtra(EXTRA_DETAIL, detail)
+ )
+ }
+
+ private fun notification(text: String) =
+ Notification.Builder(this, CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_inputflow)
+ .setContentTitle(getString(R.string.app_name))
+ .setContentText(text)
+ .setOngoing(true)
+ .build()
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ val manager = getSystemService(NotificationManager::class.java)
+ val channel = NotificationChannel(CHANNEL_ID, "InputFlow relay", NotificationManager.IMPORTANCE_LOW)
+ manager.createNotificationChannel(channel)
+ }
+
+ private fun sleepQuietly(ms: Long) {
+ try { Thread.sleep(ms) } catch (_: InterruptedException) {}
+ }
+
+ companion object {
+ const val PREFS = "inputflow"
+ const val KEY_HOST = "host"
+ const val KEY_PORT = "port"
+ const val KEY_SECRET = "secret"
+ const val KEY_LAPTOP_TYPING_ENABLED = "laptop_typing_enabled"
+ const val ACTION_RELEASE = "com.inputflow.android.RELEASE"
+ const val ACTION_STOP = "com.inputflow.android.STOP"
+ const val ACTION_STATUS_BROADCAST = "com.inputflow.android.STATUS"
+ const val ACTION_DEVICES_BROADCAST = "com.inputflow.android.DEVICES"
+ const val EXTRA_STATE = "state"
+ const val EXTRA_DETAIL = "detail"
+ const val EXTRA_DEVICES_JSON = "devices_json"
+ const val STATE_CONNECTED = "connected"
+ const val STATE_CONNECTING = "connecting"
+ const val STATE_DISCONNECTED = "disconnected"
+ private const val CHANNEL_ID = "inputflow-relay"
+ private const val NOTIFICATION_ID = 10
+
+ @Volatile var instance: RelayForegroundService? = null
+ @Volatile var cachedDevicesJson: String? = null
+ @Volatile var keyCaptureCallback: ((Int, Int) -> Unit)? = null
+ }
+}
diff --git a/android/app/src/main/java/com/inputflow/android/RelayProtocol.kt b/android/app/src/main/java/com/inputflow/android/RelayProtocol.kt
new file mode 100644
index 0000000..4fd59a4
--- /dev/null
+++ b/android/app/src/main/java/com/inputflow/android/RelayProtocol.kt
@@ -0,0 +1,50 @@
+package com.inputflow.android
+
+import org.json.JSONObject
+import java.io.DataInputStream
+import java.io.DataOutputStream
+import java.net.Socket
+import javax.crypto.Mac
+import javax.crypto.spec.SecretKeySpec
+
+object RelayProtocol {
+ private const val MAX_FRAME_BYTES = 64 * 1024
+
+ fun readFrame(input: DataInputStream): JSONObject {
+ val length = input.readInt()
+ require(length in 1..MAX_FRAME_BYTES) { "Invalid frame length $length" }
+ val bytes = ByteArray(length)
+ input.readFully(bytes)
+ return JSONObject(bytes.toString(Charsets.UTF_8))
+ }
+
+ fun writeFrame(output: DataOutputStream, json: JSONObject) {
+ val bytes = json.toString().toByteArray(Charsets.UTF_8)
+ require(bytes.size in 1..MAX_FRAME_BYTES) { "Invalid frame length ${bytes.size}" }
+ output.writeInt(bytes.size)
+ output.write(bytes)
+ output.flush()
+ }
+
+ fun authenticate(socket: Socket, secret: String, deviceName: String): Pair {
+ val input = DataInputStream(socket.getInputStream().buffered())
+ val output = DataOutputStream(socket.getOutputStream().buffered())
+ val hello = readFrame(input)
+ require(hello.optString("type") == "hello") { "Expected hello frame" }
+ val nonce = hello.getString("nonce")
+ val auth = JSONObject()
+ .put("type", "auth")
+ .put("device", deviceName)
+ .put("hmac", hmacSha256Hex(secret, nonce))
+ writeFrame(output, auth)
+ val ready = readFrame(input)
+ require(ready.optString("type") == "ready") { "Expected ready frame" }
+ return input to output
+ }
+
+ private fun hmacSha256Hex(secret: String, message: String): String {
+ val mac = Mac.getInstance("HmacSHA256")
+ mac.init(SecretKeySpec(secret.toByteArray(Charsets.UTF_8), "HmacSHA256"))
+ return mac.doFinal(message.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) }
+ }
+}
diff --git a/android/app/src/main/java/com/inputflow/android/SettingsActivity.kt b/android/app/src/main/java/com/inputflow/android/SettingsActivity.kt
new file mode 100644
index 0000000..68771d3
--- /dev/null
+++ b/android/app/src/main/java/com/inputflow/android/SettingsActivity.kt
@@ -0,0 +1,167 @@
+package com.inputflow.android
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.view.inputmethod.InputMethodManager
+import android.widget.RadioGroup
+import android.widget.TextView
+import androidx.appcompat.app.AppCompatActivity
+import com.google.android.material.appbar.MaterialToolbar
+import com.google.android.material.button.MaterialButton
+import com.google.android.material.button.MaterialButtonToggleGroup
+import com.google.android.material.chip.ChipGroup
+import com.google.android.material.materialswitch.MaterialSwitch
+import com.google.android.material.slider.Slider
+
+class SettingsActivity : AppCompatActivity() {
+
+ private lateinit var cursorSizeGroup: MaterialButtonToggleGroup
+ private lateinit var cursorColorGroup: ChipGroup
+ private lateinit var sensitivitySlider: Slider
+ private lateinit var laptopTypingSwitch: MaterialSwitch
+ private lateinit var keyboardModeGroup: MaterialButtonToggleGroup
+ private lateinit var connectionModeGroup: RadioGroup
+ private lateinit var modeHelpText: TextView
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_settings)
+
+ val toolbar = findViewById(R.id.toolbar)
+ setSupportActionBar(toolbar)
+ supportActionBar?.setDisplayShowTitleEnabled(false)
+ toolbar.setNavigationOnClickListener { finish() }
+
+ val prefs = getSharedPreferences(RelayForegroundService.PREFS, Context.MODE_PRIVATE)
+
+ cursorSizeGroup = findViewById(R.id.cursorSizeGroup)
+ cursorColorGroup = findViewById(R.id.cursorColorGroup)
+ sensitivitySlider = findViewById(R.id.sensitivitySlider)
+ laptopTypingSwitch = findViewById(R.id.laptopTypingSwitch)
+ keyboardModeGroup = findViewById(R.id.keyboardModeGroup)
+ connectionModeGroup = findViewById(R.id.connectionModeGroup)
+ modeHelpText = findViewById(R.id.modeHelpText)
+
+ // Restore cursor size
+ when (prefs.getInt(KEY_CURSOR_SIZE, 2)) {
+ 1 -> cursorSizeGroup.check(R.id.cursorSizeSmall)
+ 3 -> cursorSizeGroup.check(R.id.cursorSizeLarge)
+ else -> cursorSizeGroup.check(R.id.cursorSizeMedium)
+ }
+
+ // Restore cursor color
+ when (prefs.getString(KEY_CURSOR_COLOR, "white")) {
+ "blue" -> cursorColorGroup.check(R.id.colorBlue)
+ "red" -> cursorColorGroup.check(R.id.colorRed)
+ "black" -> cursorColorGroup.check(R.id.colorBlack)
+ else -> cursorColorGroup.check(R.id.colorWhite)
+ }
+
+ // Restore sensitivity
+ sensitivitySlider.value = prefs.getFloat(KEY_SENSITIVITY, 1.0f).coerceIn(0.5f, 3.0f)
+
+ laptopTypingSwitch.isChecked = prefs.getBoolean(RelayForegroundService.KEY_LAPTOP_TYPING_ENABLED, false)
+
+ // Restore keyboard mode
+ when (prefs.getString(KEY_KEYBOARD_MODE, "accessibility")) {
+ "ime" -> keyboardModeGroup.check(R.id.kbIme)
+ else -> keyboardModeGroup.check(R.id.kbAccessibility)
+ }
+
+ // Restore connection mode
+ when (prefs.getString(KEY_CONNECTION_MODE, "powertoys")) {
+ "direct" -> connectionModeGroup.check(R.id.modeDirect)
+ else -> connectionModeGroup.check(R.id.modePowerToys)
+ }
+ updateModeHelp(prefs.getString(KEY_CONNECTION_MODE, "powertoys") ?: "powertoys")
+
+ connectionModeGroup.setOnCheckedChangeListener { _, checkedId ->
+ val mode = if (checkedId == R.id.modeDirect) "direct" else "powertoys"
+ updateModeHelp(mode)
+ }
+
+ laptopTypingSwitch.setOnCheckedChangeListener { _, checked ->
+ saveSettings()
+ if (checked) {
+ showKeyboardPicker()
+ } else {
+ restoreTabletKeyboard()
+ }
+ }
+
+ findViewById(R.id.btnEditLayout).setOnClickListener {
+ startActivity(Intent(this, LayoutEditorActivity::class.java))
+ }
+
+ findViewById(R.id.btnEditShortcuts).setOnClickListener {
+ startActivity(Intent(this, KeyMapperActivity::class.java))
+ }
+
+ findViewById(R.id.btnChooseKeyboard).setOnClickListener {
+ saveSettings()
+ showKeyboardPicker()
+ }
+
+ findViewById(R.id.btnRestoreKeyboard).setOnClickListener {
+ laptopTypingSwitch.isChecked = false
+ saveSettings()
+ restoreTabletKeyboard()
+ }
+ }
+
+ override fun onStop() {
+ super.onStop()
+ saveSettings()
+ }
+
+ private fun saveSettings() {
+ val prefs = getSharedPreferences(RelayForegroundService.PREFS, Context.MODE_PRIVATE)
+ val cursorSize = when (cursorSizeGroup.checkedButtonId) {
+ R.id.cursorSizeSmall -> 1
+ R.id.cursorSizeLarge -> 3
+ else -> 2
+ }
+ val cursorColor = when (cursorColorGroup.checkedChipId) {
+ R.id.colorBlue -> "blue"
+ R.id.colorRed -> "red"
+ R.id.colorBlack -> "black"
+ else -> "white"
+ }
+ val keyboardMode = if (keyboardModeGroup.checkedButtonId == R.id.kbIme) "ime" else "accessibility"
+ val connectionMode = if (connectionModeGroup.checkedRadioButtonId == R.id.modeDirect) "direct" else "powertoys"
+
+ prefs.edit()
+ .putInt(KEY_CURSOR_SIZE, cursorSize)
+ .putString(KEY_CURSOR_COLOR, cursorColor)
+ .putFloat(KEY_SENSITIVITY, sensitivitySlider.value)
+ .putBoolean(RelayForegroundService.KEY_LAPTOP_TYPING_ENABLED, laptopTypingSwitch.isChecked)
+ .putString(KEY_KEYBOARD_MODE, keyboardMode)
+ .putString(KEY_CONNECTION_MODE, connectionMode)
+ .apply()
+ }
+
+ private fun showKeyboardPicker() {
+ (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager).showInputMethodPicker()
+ }
+
+ private fun restoreTabletKeyboard() {
+ if (!InputFlowImeService.restorePreviousKeyboard()) {
+ showKeyboardPicker()
+ }
+ }
+
+ private fun updateModeHelp(mode: String) {
+ modeHelpText.setText(
+ if (mode == "direct") R.string.mode_help_direct else R.string.mode_help_powertoys
+ )
+ }
+
+ companion object {
+ const val KEY_CURSOR_SIZE = "cursor_size"
+ const val KEY_CURSOR_COLOR = "cursor_color"
+ const val KEY_SENSITIVITY = "cursor_sensitivity"
+ const val KEY_KEYBOARD_MODE = "keyboard_mode"
+ const val KEY_CONNECTION_MODE = "connection_mode"
+ }
+}
diff --git a/android/app/src/main/res/drawable/ic_accessibility.xml b/android/app/src/main/res/drawable/ic_accessibility.xml
new file mode 100644
index 0000000..02b5a49
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_accessibility.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_arrow_right.xml b/android/app/src/main/res/drawable/ic_arrow_right.xml
new file mode 100644
index 0000000..929ef17
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_arrow_right.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_back.xml b/android/app/src/main/res/drawable/ic_back.xml
new file mode 100644
index 0000000..b8e120b
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_back.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_host.xml b/android/app/src/main/res/drawable/ic_host.xml
new file mode 100644
index 0000000..8864be4
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_host.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_inputflow.xml b/android/app/src/main/res/drawable/ic_inputflow.xml
new file mode 100644
index 0000000..3950a8c
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_inputflow.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_key.xml b/android/app/src/main/res/drawable/ic_key.xml
new file mode 100644
index 0000000..548970f
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_key.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_keyboard.xml b/android/app/src/main/res/drawable/ic_keyboard.xml
new file mode 100644
index 0000000..8f96243
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_keyboard.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_notification.xml b/android/app/src/main/res/drawable/ic_notification.xml
new file mode 100644
index 0000000..43e6823
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_notification.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_port.xml b/android/app/src/main/res/drawable/ic_port.xml
new file mode 100644
index 0000000..95f90bd
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_port.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_settings.xml b/android/app/src/main/res/drawable/ic_settings.xml
new file mode 100644
index 0000000..30695ae
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_settings.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/android/app/src/main/res/drawable/status_dot.xml b/android/app/src/main/res/drawable/status_dot.xml
new file mode 100644
index 0000000..fab5f97
--- /dev/null
+++ b/android/app/src/main/res/drawable/status_dot.xml
@@ -0,0 +1,4 @@
+
+
+
diff --git a/android/app/src/main/res/layout/activity_key_mapper.xml b/android/app/src/main/res/layout/activity_key_mapper.xml
new file mode 100644
index 0000000..07c2305
--- /dev/null
+++ b/android/app/src/main/res/layout/activity_key_mapper.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/activity_layout_editor.xml b/android/app/src/main/res/layout/activity_layout_editor.xml
new file mode 100644
index 0000000..fcc94fc
--- /dev/null
+++ b/android/app/src/main/res/layout/activity_layout_editor.xml
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..33ec11c
--- /dev/null
+++ b/android/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,334 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/activity_settings.xml b/android/app/src/main/res/layout/activity_settings.xml
new file mode 100644
index 0000000..ccd7aa4
--- /dev/null
+++ b/android/app/src/main/res/layout/activity_settings.xml
@@ -0,0 +1,470 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/row_shortcut.xml b/android/app/src/main/res/layout/row_shortcut.xml
new file mode 100644
index 0000000..2ffe4e0
--- /dev/null
+++ b/android/app/src/main/res/layout/row_shortcut.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/menu/main_menu.xml b/android/app/src/main/res/menu/main_menu.xml
new file mode 100644
index 0000000..631b51e
--- /dev/null
+++ b/android/app/src/main/res/menu/main_menu.xml
@@ -0,0 +1,12 @@
+
+
diff --git a/android/app/src/main/res/values-night/colors.xml b/android/app/src/main/res/values-night/colors.xml
new file mode 100644
index 0000000..08ebed4
--- /dev/null
+++ b/android/app/src/main/res/values-night/colors.xml
@@ -0,0 +1,19 @@
+
+ #4C8FD6
+ #FFFFFF
+ #101418
+ #202832
+ #0B1014
+ #6FCF89
+ #173322
+ #EF9A9A
+ #3B1515
+ #FFCC80
+ #3D2600
+ #0A1628
+ #1A2840
+ #4A90D9
+ #4CAF50
+ #FFFFFF
+ #34404D
+
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..1d55475
--- /dev/null
+++ b/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,19 @@
+
+ #1664C0
+ #FFFFFF
+ #F8FAFF
+ #E8F0FE
+ #0A1628
+ #2E7D32
+ #E8F5E9
+ #C62828
+ #FFEBEE
+ #E65100
+ #FFF3E0
+ #0A1628
+ #1A2840
+ #1664C0
+ #2E7D32
+ #FFFFFF
+ #D0DEF5
+
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..044aabe
--- /dev/null
+++ b/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,65 @@
+
+ InputFlow
+ InputFlow controlled peer
+ InputFlow keyboard
+
+
+ Connected
+ Not connected
+ Connecting…
+ Host address
+ Port
+ Secret
+ Save & Connect
+ Release to Desktop
+ Open Accessibility Settings
+ Choose Keyboard
+ App Notification Settings
+ Setup steps
+ Complete these once to activate input control
+
+
+ Settings
+ Cursor
+ Cursor size
+ S
+ M
+ L
+ Cursor color
+ White
+ Blue
+ Red
+ Black
+ Pointer sensitivity
+ Keyboard
+ Laptop typing mode
+ Use the hidden InputFlow keyboard only while you want laptop keys to type into Android apps.
+ Fallback input mode
+ Accessibility
+ Hidden IME
+ Choose Keyboard
+ Restore Tablet Keyboard
+ Connection mode
+ Via PowerToys (Windows host)
+ Direct InputFlow (Linux host)
+ Windows runs PowerToys Mouse Without Borders. InputFlow on Linux captures that stream and relays input to this device.
+ InputFlow on Linux is the primary source. No Windows or PowerToys needed. The Linux machine relays keyboard and mouse directly to this device.
+ Device layout
+ Edit Device Layout
+ Drag devices to set their position relative to each other. The green device is this tablet.
+
+
+ Keyboard Shortcuts
+ Map keyboard keys to gestures and system actions. Tap a shortcut chip to reassign it using your laptop keyboard.
+ Edit Shortcuts
+ Keyboard Shortcuts
+ Tap any shortcut to reassign it. Hold Ctrl/Alt/Shift while pressing the new key to create combos. Works with mouse or trackpad.
+ Press a key on your keyboard…
+ Clear
+
+
+ Device Layout
+ Apply Layout
+ This Device
+ Connect to InputFlow first to load device list
+
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..23f9639
--- /dev/null
+++ b/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/xml/accessibility_service_config.xml b/android/app/src/main/res/xml/accessibility_service_config.xml
new file mode 100644
index 0000000..b7de596
--- /dev/null
+++ b/android/app/src/main/res/xml/accessibility_service_config.xml
@@ -0,0 +1,8 @@
+
diff --git a/android/app/src/main/res/xml/input_method.xml b/android/app/src/main/res/xml/input_method.xml
new file mode 100644
index 0000000..e9e6e3f
--- /dev/null
+++ b/android/app/src/main/res/xml/input_method.xml
@@ -0,0 +1,3 @@
+
diff --git a/android/build.gradle b/android/build.gradle
new file mode 100644
index 0000000..52a6554
--- /dev/null
+++ b/android/build.gradle
@@ -0,0 +1,4 @@
+plugins {
+ id "com.android.application" version "8.7.3" apply false
+ id "org.jetbrains.kotlin.android" version "2.0.21" apply false
+}
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 0000000..646c51b
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,2 @@
+android.useAndroidX=true
+android.enableJetifier=true
diff --git a/android/settings.gradle b/android/settings.gradle
new file mode 100644
index 0000000..b092c69
--- /dev/null
+++ b/android/settings.gradle
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "InputFlowAndroid"
+include ":app"
diff --git a/docs/android.md b/docs/android.md
new file mode 100644
index 0000000..52062a2
--- /dev/null
+++ b/docs/android.md
@@ -0,0 +1,64 @@
+# Android peer MVP
+
+InputFlow can expose an experimental Android controlled-peer relay. The Linux client remains the hub: Windows sends input to InputFlow through the existing MWB session, and InputFlow forwards input to a paired Android app when topology hands off to the configured Android machine.
+
+## Linux configuration
+
+Add these keys to `~/.config/mwb-client/config.ini`:
+
+```ini
+android_peers_enabled=true
+android_relay_port=15102
+android_relay_secret=replace-with-a-long-random-secret
+android_peer_name=pixel-8
+android_capture_backend=none
+```
+
+Then enable topology and add a machine/display whose machine id matches `android_peer_name`. When a cross-machine topology edge targets that machine, InputFlow forwards mouse events to Android. Keyboard events follow while the Android relay is active.
+
+The relay is disabled by default. If `android_relay_secret` is empty, the relay does not start.
+
+`android_capture_backend` controls Linux-local physical mouse capture:
+
+- `none`: default. Android relay stays available for already-forwarded topology input without trying to capture the Fedora pointer.
+- `evdev`: prototype fallback only. It can mirror/stick the KDE Wayland cursor and should not be used as a production monitor-like path.
+- `libei`: planned KDE/Wayland backend. This is the target for real compositor-mediated capture/release behavior.
+
+## Android app
+
+The Android project lives in `android/`.
+
+The app is intentionally no-root:
+
+- `RelayForegroundService` connects to the Linux relay over LAN.
+- `InputFlowAccessibilityService` shows a cursor overlay, dispatches click/scroll gestures, and performs basic focused-node text actions.
+- `InputFlowImeService` is included as an optional keyboard surface for later richer text handling.
+
+Generate a pairing payload with:
+
+```bash
+./build/mwb_client android-pair --config ~/.config/mwb-client/config.ini
+```
+
+Use the printed `inputflow://android-peer?...` URI as QR content, open it on Android, or enter the same host, port, and secret manually in the app.
+
+## Current limitations
+
+- Android input injection uses a no-root overlay plus Accessibility gestures/focused-node text edits, so it is less complete than a real HID or privileged input path.
+- Linux physical keyboard/mouse capture needs the KDE/Wayland `libei`/EIS path for monitor-like behavior. The evdev fallback is intentionally opt-in and diagnostic only.
+- Android can request control release from the app; richer edge-based return behavior is future work.
+
+## Controls
+
+With `android_capture_backend=libei` on KDE/Wayland:
+
+- Enter Android: push through the configured Fedora edge.
+- Return to Fedora: move left until the Android cursor reaches the left edge and keep moving left.
+- Left click: tap/click at the Android cursor.
+- Right click or `Esc`: Android Back.
+- Middle click or `Meta`: Android Home.
+- `Alt+Tab`: Android Recents.
+- `Ctrl+Alt+N`: notification shade.
+- `Ctrl+Alt+Q`: quick settings.
+- Two-finger scroll / wheel: scroll focused Android content.
+- Basic text input: letters, numbers, punctuation, space, enter, and backspace edit the focused Android text field.
diff --git a/packaging/rpm/inputflow.spec b/packaging/rpm/inputflow.spec
index e8c917d..df4c264 100644
--- a/packaging/rpm/inputflow.spec
+++ b/packaging/rpm/inputflow.spec
@@ -16,6 +16,7 @@ BuildRequires: systemd-rpm-macros
BuildRequires: zlib-devel
BuildRequires: gtk3-devel
BuildRequires: libayatana-appindicator3-devel
+BuildRequires: libX11-devel
Requires: systemd
%{?systemd_requires}
diff --git a/src/AndroidRelay.cpp b/src/AndroidRelay.cpp
new file mode 100644
index 0000000..3df60f3
--- /dev/null
+++ b/src/AndroidRelay.cpp
@@ -0,0 +1,474 @@
+#include "AndroidRelay.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+
+namespace mwb {
+namespace {
+
+constexpr std::size_t kMaxFrameBytes = 64 * 1024;
+
+void CloseFd(int& fd) {
+ if (fd >= 0) {
+ shutdown(fd, SHUT_RDWR);
+ close(fd);
+ fd = -1;
+ }
+}
+
+bool ReadExact(int fd, void* data, std::size_t size) {
+ auto* out = static_cast(data);
+ std::size_t offset = 0;
+ while (offset < size) {
+ const ssize_t count = read(fd, out + offset, size - offset);
+ if (count < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ return false;
+ }
+ if (count == 0) {
+ return false;
+ }
+ offset += static_cast(count);
+ }
+ return true;
+}
+
+bool WriteExact(int fd, const void* data, std::size_t size) {
+ const auto* in = static_cast(data);
+ std::size_t offset = 0;
+ while (offset < size) {
+ const ssize_t count = write(fd, in + offset, size - offset);
+ if (count < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ return false;
+ }
+ if (count == 0) {
+ return false;
+ }
+ offset += static_cast(count);
+ }
+ return true;
+}
+
+std::optional ReadFrame(int fd) {
+ uint32_t networkLength = 0;
+ if (!ReadExact(fd, &networkLength, sizeof(networkLength))) {
+ return std::nullopt;
+ }
+ const uint32_t length = ntohl(networkLength);
+ if (length == 0 || length > kMaxFrameBytes) {
+ return std::nullopt;
+ }
+
+ std::string payload(length, '\0');
+ if (!ReadExact(fd, payload.data(), payload.size())) {
+ return std::nullopt;
+ }
+ return payload;
+}
+
+std::string HexEncode(const uint8_t* data, std::size_t size) {
+ std::ostringstream out;
+ out << std::hex << std::setfill('0');
+ for (std::size_t index = 0; index < size; ++index) {
+ out << std::setw(2) << static_cast(data[index]);
+ }
+ return out.str();
+}
+
+std::string RandomNonceHex() {
+ std::array bytes{};
+ if (RAND_bytes(bytes.data(), static_cast(bytes.size())) != 1) {
+ return {};
+ }
+ return HexEncode(bytes.data(), bytes.size());
+}
+
+std::string HmacSha256Hex(const std::string& secret, const std::string& message) {
+ std::array digest{};
+ unsigned int digestLength = 0;
+ HMAC(
+ EVP_sha256(),
+ secret.data(),
+ static_cast(secret.size()),
+ reinterpret_cast(message.data()),
+ message.size(),
+ digest.data(),
+ &digestLength);
+ return HexEncode(digest.data(), digestLength);
+}
+
+std::optional JsonStringValue(const std::string& json, const std::string& key) {
+ const std::string marker = "\"" + key + "\":\"";
+ const std::size_t start = json.find(marker);
+ if (start == std::string::npos) {
+ return std::nullopt;
+ }
+ const std::size_t valueStart = start + marker.size();
+ const std::size_t valueEnd = json.find('"', valueStart);
+ if (valueEnd == std::string::npos) {
+ return std::nullopt;
+ }
+ return json.substr(valueStart, valueEnd - valueStart);
+}
+
+std::string EscapeJson(std::string_view value) {
+ std::string escaped;
+ escaped.reserve(value.size());
+ for (char ch : value) {
+ switch (ch) {
+ case '\\':
+ escaped += "\\\\";
+ break;
+ case '"':
+ escaped += "\\\"";
+ break;
+ case '\n':
+ escaped += "\\n";
+ break;
+ case '\r':
+ escaped += "\\r";
+ break;
+ case '\t':
+ escaped += "\\t";
+ break;
+ default:
+ escaped.push_back(ch);
+ break;
+ }
+ }
+ return escaped;
+}
+
+} // namespace
+
+AndroidRelayServer::AndroidRelayServer(AndroidRelayOptions options)
+ : m_options(std::move(options)) {
+}
+
+AndroidRelayServer::~AndroidRelayServer() {
+ Stop();
+}
+
+bool AndroidRelayServer::Start() {
+ if (!m_options.enabled) {
+ return true;
+ }
+ if (m_options.secret.empty()) {
+ std::cerr << "WARN: Android relay enabled but android_relay_secret is empty; relay disabled." << std::endl;
+ return true;
+ }
+ if (m_options.port <= 0 || m_options.port > 65535) {
+ std::cerr << "WARN: Android relay port is invalid; relay disabled." << std::endl;
+ return true;
+ }
+ if (m_running.exchange(true)) {
+ return true;
+ }
+
+ m_listenFd = socket(AF_INET, SOCK_STREAM, 0);
+ if (m_listenFd < 0) {
+ std::cerr << "WARN: Android relay socket failed: " << std::strerror(errno) << std::endl;
+ m_running = false;
+ return false;
+ }
+
+ int enabled = 1;
+ setsockopt(m_listenFd, SOL_SOCKET, SO_REUSEADDR, &enabled, sizeof(enabled));
+
+ sockaddr_in address{};
+ address.sin_family = AF_INET;
+ address.sin_addr.s_addr = htonl(INADDR_ANY);
+ address.sin_port = htons(static_cast(m_options.port));
+ if (bind(m_listenFd, reinterpret_cast(&address), sizeof(address)) != 0 ||
+ listen(m_listenFd, 4) != 0) {
+ std::cerr << "WARN: Android relay listen failed on port " << m_options.port
+ << ": " << std::strerror(errno) << std::endl;
+ CloseFd(m_listenFd);
+ m_running = false;
+ return false;
+ }
+
+ m_acceptThread = std::thread([this]() { AcceptLoop(); });
+ std::cout << "[ANDROID] Relay listening on port " << m_options.port
+ << " for peer '" << m_options.peerName << "'." << std::endl;
+ return true;
+}
+
+void AndroidRelayServer::Stop() {
+ if (!m_running.exchange(false)) {
+ return;
+ }
+
+ CloseFd(m_listenFd);
+ {
+ std::lock_guard lock(m_clientsMutex);
+ for (auto& client : m_clients) {
+ CloseFd(client->fd);
+ }
+ }
+ if (m_acceptThread.joinable()) {
+ m_acceptThread.join();
+ }
+ for (auto& thread : m_clientThreads) {
+ if (thread.joinable()) {
+ thread.join();
+ }
+ }
+ m_clientThreads.clear();
+ {
+ std::lock_guard lock(m_clientsMutex);
+ m_clients.clear();
+ }
+}
+
+bool AndroidRelayServer::HasAuthenticatedClient() const {
+ std::lock_guard lock(m_clientsMutex);
+ return std::any_of(m_clients.begin(), m_clients.end(), [](const auto& client) {
+ return client->authenticated;
+ });
+}
+
+bool AndroidRelayServer::SendMouse(const MouseData& mouse) {
+ return BroadcastFrame(BuildAndroidMouseFrame(mouse));
+}
+
+bool AndroidRelayServer::SendKeyboard(const KeyboardData& keyboard) {
+ return BroadcastFrame(BuildAndroidKeyboardFrame(keyboard));
+}
+
+bool AndroidRelayServer::SendGesture(const std::string& kind, double dx, double dy) {
+ return BroadcastFrame(BuildAndroidGestureFrame(kind, dx, dy));
+}
+
+bool AndroidRelayServer::SendControl(bool active) {
+ return BroadcastFrame(BuildAndroidControlFrame(active));
+}
+
+void AndroidRelayServer::SetOnReleaseRequested(std::function callback) {
+ std::lock_guard lock(m_callbackMutex);
+ m_onReleaseRequested = std::move(callback);
+}
+
+void AndroidRelayServer::SetOnTopologyUpdate(std::function callback) {
+ std::lock_guard lock(m_callbackMutex);
+ m_onTopologyUpdate = std::move(callback);
+}
+
+void AndroidRelayServer::SetLocalDeviceInfo(const std::string& machineName, int screenWidth, int screenHeight) {
+ m_localMachineName = machineName;
+ m_localScreenWidth = screenWidth;
+ m_localScreenHeight = screenHeight;
+}
+
+void AndroidRelayServer::AcceptLoop() {
+ while (m_running) {
+ int clientFd = accept(m_listenFd, nullptr, nullptr);
+ if (clientFd < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ if (m_running) {
+ std::cerr << "WARN: Android relay accept failed: " << std::strerror(errno) << std::endl;
+ }
+ break;
+ }
+
+ auto session = std::make_shared();
+ session->fd = clientFd;
+ {
+ std::lock_guard lock(m_clientsMutex);
+ m_clients.push_back(session);
+ m_clientThreads.emplace_back([this, session]() { HandleClient(session); });
+ }
+ }
+}
+
+void AndroidRelayServer::HandleClient(std::shared_ptr session) {
+ if (!AuthenticateClient(session)) {
+ RemoveClient(session);
+ return;
+ }
+
+ while (m_running) {
+ const auto frame = ReadFrame(session->fd);
+ if (!frame.has_value()) {
+ break;
+ }
+ const auto type = JsonStringValue(*frame, "type");
+ if (!type.has_value()) {
+ continue;
+ }
+ if (*type == "release") {
+ std::function callback;
+ {
+ std::lock_guard lock(m_callbackMutex);
+ callback = m_onReleaseRequested;
+ }
+ if (callback) {
+ callback();
+ }
+ } else if (*type == "topology_update") {
+ std::function callback;
+ {
+ std::lock_guard lock(m_callbackMutex);
+ callback = m_onTopologyUpdate;
+ }
+ if (callback) {
+ callback(*frame);
+ }
+ }
+ }
+
+ RemoveClient(session);
+}
+
+bool AndroidRelayServer::AuthenticateClient(const std::shared_ptr& session) {
+ const std::string nonce = RandomNonceHex();
+ if (nonce.empty()) {
+ return false;
+ }
+ const std::string hello = "{\"type\":\"hello\",\"version\":1,\"nonce\":\"" + nonce +
+ "\",\"peer\":\"" + EscapeJson(m_options.peerName) + "\"}";
+ if (!SendFrame(session, hello)) {
+ return false;
+ }
+
+ const auto auth = ReadFrame(session->fd);
+ if (!auth.has_value()) {
+ return false;
+ }
+ const auto type = JsonStringValue(*auth, "type");
+ const auto hmac = JsonStringValue(*auth, "hmac");
+ if (!type.has_value() || *type != "auth" || !hmac.has_value()) {
+ return false;
+ }
+
+ const std::string expected = HmacSha256Hex(m_options.secret, nonce);
+ if (*hmac != expected) {
+ std::cerr << "WARN: Android relay rejected client with invalid auth." << std::endl;
+ return false;
+ }
+
+ const std::string deviceName = JsonStringValue(*auth, "device").value_or("android");
+ {
+ std::lock_guard lock(m_clientsMutex);
+ session->deviceName = deviceName;
+ session->authenticated = true;
+ }
+ std::cout << "[ANDROID] Relay client authenticated: " << deviceName << std::endl;
+ if (!SendFrame(session, "{\"type\":\"ready\"}")) {
+ return false;
+ }
+
+ // Send device layout info so the Android client can show the layout editor.
+ if (!m_localMachineName.empty()) {
+ std::ostringstream devInfo;
+ devInfo << "{\"type\":\"devices_info\","
+ << "\"devices\":["
+ << "{\"id\":\"linux\",\"name\":\"" << EscapeJson(m_localMachineName) << "\","
+ << "\"width\":" << m_localScreenWidth << ",\"height\":" << m_localScreenHeight << "},"
+ << "{\"id\":\"android\",\"name\":\"" << EscapeJson(deviceName) << "\","
+ << "\"width\":0,\"height\":0}"
+ << "],"
+ << "\"layout\":["
+ << "{\"id\":\"linux\",\"x\":0,\"y\":0},"
+ << "{\"id\":\"android\",\"x\":" << m_localScreenWidth << ",\"y\":0}"
+ << "]}";
+ SendFrame(session, devInfo.str());
+ }
+ return true;
+}
+
+bool AndroidRelayServer::SendFrame(const std::shared_ptr& session, const std::string& payload) {
+ if (session->fd < 0 || payload.empty() || payload.size() > kMaxFrameBytes) {
+ return false;
+ }
+
+ std::lock_guard lock(session->writeMutex);
+ const uint32_t length = htonl(static_cast(payload.size()));
+ return WriteExact(session->fd, &length, sizeof(length)) &&
+ WriteExact(session->fd, payload.data(), payload.size());
+}
+
+bool AndroidRelayServer::BroadcastFrame(const std::string& payload) {
+ std::vector> clients;
+ {
+ std::lock_guard lock(m_clientsMutex);
+ for (const auto& client : m_clients) {
+ if (client->authenticated) {
+ clients.push_back(client);
+ }
+ }
+ }
+ if (clients.empty()) {
+ return false;
+ }
+
+ bool delivered = false;
+ for (const auto& client : clients) {
+ delivered = SendFrame(client, payload) || delivered;
+ }
+ return delivered;
+}
+
+void AndroidRelayServer::RemoveClient(const std::shared_ptr& session) {
+ CloseFd(session->fd);
+ std::lock_guard lock(m_clientsMutex);
+ m_clients.erase(
+ std::remove_if(m_clients.begin(), m_clients.end(), [&](const auto& current) {
+ return current == session;
+ }),
+ m_clients.end());
+}
+
+std::string BuildAndroidMouseFrame(const MouseData& mouse) {
+ std::ostringstream out;
+ out << "{\"type\":\"mouse\",\"x\":" << mouse.x
+ << ",\"y\":" << mouse.y
+ << ",\"mouseData\":" << mouse.mouseData
+ << ",\"wParam\":" << mouse.wParam
+ << "}";
+ return out.str();
+}
+
+std::string BuildAndroidKeyboardFrame(const KeyboardData& keyboard) {
+ std::ostringstream out;
+ out << "{\"type\":\"keyboard\",\"vkCode\":" << keyboard.vkCode
+ << ",\"flags\":" << keyboard.flags
+ << "}";
+ return out.str();
+}
+
+std::string BuildAndroidGestureFrame(const std::string& kind, double dx, double dy) {
+ std::ostringstream out;
+ out << "{\"type\":\"gesture\",\"kind\":\"" << EscapeJson(kind)
+ << "\",\"dx\":" << dx
+ << ",\"dy\":" << dy
+ << "}";
+ return out.str();
+}
+
+std::string BuildAndroidControlFrame(bool active) {
+ return std::string("{\"type\":\"control\",\"active\":") + (active ? "true" : "false") + "}";
+}
+
+} // namespace mwb
diff --git a/src/AndroidRelay.h b/src/AndroidRelay.h
new file mode 100644
index 0000000..e89dbc1
--- /dev/null
+++ b/src/AndroidRelay.h
@@ -0,0 +1,84 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "Protocol.h"
+
+namespace mwb {
+
+struct AndroidRelayOptions {
+ bool enabled{false};
+ int port{15102};
+ std::string secret;
+ std::string peerName{"android"};
+ bool layoutEditorEnabled{true};
+ int androidDeviceWidth{1920};
+ int androidDeviceHeight{1200};
+};
+
+class AndroidRelayServer {
+public:
+ explicit AndroidRelayServer(AndroidRelayOptions options);
+ ~AndroidRelayServer();
+
+ bool Start();
+ void Stop();
+ bool HasAuthenticatedClient() const;
+ bool SendMouse(const MouseData& mouse);
+ bool SendKeyboard(const KeyboardData& keyboard);
+ bool SendGesture(const std::string& kind, double dx, double dy);
+ bool SendControl(bool active);
+ void SetOnReleaseRequested(std::function callback);
+ void SetOnTopologyUpdate(std::function callback);
+
+ // Called after ready to populate the devices_info frame sent to Android.
+ void SetLocalDeviceInfo(const std::string& machineName, int screenWidth, int screenHeight);
+
+ const AndroidRelayOptions& Options() const {
+ return m_options;
+ }
+
+private:
+ struct ClientSession {
+ int fd{-1};
+ bool authenticated{false};
+ std::string deviceName;
+ std::mutex writeMutex;
+ };
+
+ void AcceptLoop();
+ void HandleClient(std::shared_ptr session);
+ bool AuthenticateClient(const std::shared_ptr& session);
+ bool SendFrame(const std::shared_ptr& session, const std::string& payload);
+ bool BroadcastFrame(const std::string& payload);
+ void RemoveClient(const std::shared_ptr& session);
+
+ AndroidRelayOptions m_options;
+ std::atomic m_running{false};
+ int m_listenFd{-1};
+ mutable std::mutex m_clientsMutex;
+ std::vector> m_clients;
+ std::vector m_clientThreads;
+ std::thread m_acceptThread;
+ std::function m_onReleaseRequested;
+ std::function m_onTopologyUpdate;
+ mutable std::mutex m_callbackMutex;
+
+ std::string m_localMachineName;
+ int m_localScreenWidth{0};
+ int m_localScreenHeight{0};
+};
+
+std::string BuildAndroidMouseFrame(const MouseData& mouse);
+std::string BuildAndroidKeyboardFrame(const KeyboardData& keyboard);
+std::string BuildAndroidGestureFrame(const std::string& kind, double dx, double dy);
+std::string BuildAndroidControlFrame(bool active);
+
+} // namespace mwb
diff --git a/src/AppConfig.cpp b/src/AppConfig.cpp
index 3bac4b5..8e23f7e 100644
--- a/src/AppConfig.cpp
+++ b/src/AppConfig.cpp
@@ -367,6 +367,70 @@ bool ParseAppConfig(std::string_view text, AppConfig& outConfig, std::string* er
continue;
}
+ if (key == "android_peers_enabled" || key == "android_relay_enabled") {
+ const auto parsed = ParseConfigBool(value);
+ if (!parsed.has_value()) {
+ SetError(errorMessage, "Config key 'android_peers_enabled' expects true/false.");
+ return false;
+ }
+ outConfig.androidPeersEnabled = *parsed;
+ continue;
+ }
+
+ if (key == "android_relay_port") {
+ if (!ParseAndAssignRequiredInt(value, 1, 65535, outConfig.androidRelayPort)) {
+ SetError(errorMessage, "Config key 'android_relay_port' expects an integer between 1 and 65535.");
+ return false;
+ }
+ continue;
+ }
+
+ if (key == "android_relay_secret") {
+ outConfig.androidRelaySecret = std::string(value);
+ continue;
+ }
+
+ if (key == "android_peer_name") {
+ outConfig.androidPeerName = std::string(value);
+ continue;
+ }
+
+ if (key == "android_capture_backend") {
+ const std::string backend = ToLower(std::string(value));
+ if (backend != "none" && backend != "evdev" && backend != "libei") {
+ SetError(errorMessage, "Config key 'android_capture_backend' expects none, evdev, or libei.");
+ return false;
+ }
+ outConfig.androidCaptureBackend = backend;
+ continue;
+ }
+
+ if (key == "android_layout_editor_enabled") {
+ const auto parsed = ParseConfigBool(value);
+ if (!parsed.has_value()) {
+ SetError(errorMessage, "Config key 'android_layout_editor_enabled' expects true/false.");
+ return false;
+ }
+ outConfig.androidLayoutEditorEnabled = *parsed;
+ continue;
+ }
+
+ if (key == "android_device_width") {
+ if (!ParseAndAssignRequiredInt(value, 1, 65535, outConfig.androidDeviceWidth)) {
+ SetError(errorMessage, "Config key 'android_device_width' expects an integer between 1 and 65535.");
+ return false;
+ }
+ continue;
+ }
+
+ if (key == "android_device_height") {
+ if (!ParseAndAssignRequiredInt(value, 1, 65535, outConfig.androidDeviceHeight)) {
+ SetError(errorMessage, "Config key 'android_device_height' expects an integer between 1 and 65535.");
+ return false;
+ }
+ continue;
+ }
+
SetError(errorMessage, "Unknown config key '" + std::string(key) + "' on line " + std::to_string(lineNumber) + ".");
return false;
}
@@ -433,6 +497,14 @@ std::string RenderAppConfig(const AppConfig& config) {
out << "latency_report=" << RenderBool(config.latencyReport) << '\n';
out << "topology_enabled=" << RenderBool(config.topologyRuntimeEnabled) << '\n';
out << "topology_file=" << config.topologyFile << '\n';
+ out << "android_peers_enabled=" << RenderBool(config.androidPeersEnabled) << '\n';
+ out << "android_relay_port=" << config.androidRelayPort << '\n';
+ out << "android_relay_secret=" << config.androidRelaySecret << '\n';
+ out << "android_peer_name=" << config.androidPeerName << '\n';
+ out << "android_capture_backend=" << config.androidCaptureBackend << '\n';
+ out << "android_layout_editor_enabled=" << RenderBool(config.androidLayoutEditorEnabled) << '\n';
+ out << "android_device_width=" << config.androidDeviceWidth << '\n';
+ out << "android_device_height=" << config.androidDeviceHeight << '\n';
return out.str();
}
@@ -452,6 +524,8 @@ std::string RenderSampleAppConfig() {
out << "# Set auto_connect_enabled=false to keep the service idle until you re-enable it.\n";
out << "# Set screen_width and screen_height to your local desktop size when needed.\n";
out << "# Set topology_enabled=true and topology_file=... to enable runtime topology handoff.\n";
+ out << "# Set android_peers_enabled=true with android_relay_secret=... to relay input to Android peers.\n";
+ out << "# android_capture_backend=none keeps Android relay-only; evdev is prototype-only; libei is the planned KDE/Wayland backend.\n";
out << RenderAppConfig(sample);
return out.str();
}
diff --git a/src/AppConfig.h b/src/AppConfig.h
index 6ce4f34..1e4cb9f 100644
--- a/src/AppConfig.h
+++ b/src/AppConfig.h
@@ -29,6 +29,14 @@ struct AppConfig {
bool latencyReport{false};
bool topologyRuntimeEnabled{false};
std::string topologyFile;
+ bool androidPeersEnabled{false};
+ int androidRelayPort{15102};
+ std::string androidRelaySecret;
+ std::string androidPeerName{"android"};
+ std::string androidCaptureBackend{"none"};
+ bool androidLayoutEditorEnabled{true};
+ int androidDeviceWidth{1920};
+ int androidDeviceHeight{1200};
};
AppConfig LoadDefaultAppConfig();
diff --git a/src/ClientRuntime.cpp b/src/ClientRuntime.cpp
index a85212c..850aa08 100644
--- a/src/ClientRuntime.cpp
+++ b/src/ClientRuntime.cpp
@@ -3,6 +3,7 @@
#include "ScreenGeometry.h"
#include
+#include
#include
#include
#include
@@ -329,7 +330,13 @@ void ClientRuntime::ConfigureTopologyPreview(const ScreenSize& screenSize) {
true,
[this](const MouseData& mouse,
const TopologyPointerTransition&,
- const std::string&) {
+ const std::string& targetMachineId) {
+ if (m_androidRelay &&
+ targetMachineId == m_options.androidRelay.peerName &&
+ TrySetAndroidControlActive(true) &&
+ TrySendAndroidMouse(mouse)) {
+ return true;
+ }
return m_network && m_network->SendMouse(mouse);
});
std::cout << "[TOPOLOGY] Loaded topology from "
@@ -394,6 +401,25 @@ int ClientRuntime::Run() {
m_clipboard = ClipboardManager::CreateDefault();
}
+ if (m_options.androidRelay.enabled) {
+ m_androidRelay = std::make_unique(m_options.androidRelay);
+ m_androidRelay->SetOnReleaseRequested([this]() {
+ TrySetAndroidControlActive(false);
+ std::cout << "[ANDROID] Relay release requested; returning input to local desktop." << std::endl;
+ });
+ m_androidRelay->SetOnTopologyUpdate([this](const std::string& layoutJson) {
+ ApplyAndroidTopologyUpdate(layoutJson);
+ });
+ if (screenSize.width > 0 && !m_options.localMachineName.empty()) {
+ m_androidRelay->SetLocalDeviceInfo(m_options.localMachineName, screenSize.width, screenSize.height);
+ }
+ if (!m_androidRelay->Start()) {
+ std::cerr << "WARN: Android relay failed to start; Android peer handoff is disabled." << std::endl;
+ m_androidRelay.reset();
+ }
+ }
+ StartLocalAndroidInputBridge(screenSize);
+
if (m_clipboard) {
m_clipboardBackendName = m_clipboard->BackendName();
std::cout << "[INFO] Clipboard backend: " << m_clipboardBackendName << std::endl;
@@ -446,6 +472,10 @@ int ClientRuntime::Run() {
<< " mouseData=" << md.mouseData
<< std::endl;
}
+ if (m_androidRelayActive && TrySendAndroidMouse(md)) {
+ return;
+ }
+ m_androidRelayActive = false;
m_dispatcher.SubmitMouse(md);
});
@@ -454,12 +484,19 @@ int ClientRuntime::Run() {
std::cout << "[INPUT] Keyboard: vk=0x" << std::hex << kd.vkCode
<< " flags=0x" << kd.flags << std::dec << std::endl;
}
+ if (m_androidRelayActive && TrySendAndroidKeyboard(kd)) {
+ return;
+ }
m_dispatcher.SubmitKeyboard(kd);
});
if (!m_network->Connect()) {
std::cerr << "Terminating: Network failure." << std::endl;
m_dispatcher.Stop();
+ if (m_androidRelay) {
+ m_androidRelay->Stop();
+ }
+ StopLocalAndroidInputBridge();
m_network.reset();
return 1;
}
@@ -544,7 +581,11 @@ void ClientRuntime::Stop() {
return;
}
+ StopLocalAndroidInputBridge();
StopClipboardWatcher();
+ if (m_androidRelay) {
+ m_androidRelay->Stop();
+ }
if (m_network) {
m_network->Stop();
}
@@ -556,6 +597,227 @@ void ClientRuntime::Stop() {
std::cout << InputManager::FormatMouseTrace(m_input.MouseTraceSnapshot());
}
m_network.reset();
+ m_androidRelay.reset();
+}
+
+void ClientRuntime::StartLocalAndroidInputBridge(const ScreenSize& screenSize) {
+ if (!m_androidRelay || !m_topology || !m_options.topologyRuntimeEnabled) {
+ return;
+ }
+
+ if (m_options.androidCaptureBackend.empty() || m_options.androidCaptureBackend == "none") {
+ std::cout << "[ANDROID] Local capture backend disabled; relay will accept remote/topology forwarded input only." << std::endl;
+ return;
+ }
+
+ if (m_options.androidCaptureBackend == "libei") {
+ LibeiInputCaptureBridgeOptions options;
+ options.desktopWidth = screenSize.width;
+ options.desktopHeight = screenSize.height;
+ options.sendMouse = [this](const MouseData& mouse) {
+ return TrySendAndroidMouse(mouse);
+ };
+ options.sendKeyboard = [this](const KeyboardData& keyboard) {
+ return TrySendAndroidKeyboard(keyboard);
+ };
+ options.sendGesture = [this](const std::string& kind, double dx, double dy) {
+ return TrySendAndroidGesture(kind, dx, dy);
+ };
+ options.sendControl = [this](bool active) {
+ return TrySetAndroidControlActive(active);
+ };
+ m_libeiInputCaptureBridge = std::make_unique(std::move(options));
+ m_libeiInputCaptureBridge->Start();
+ return;
+ }
+
+ if (m_options.androidCaptureBackend != "evdev") {
+ std::cerr << "WARN: Unknown android_capture_backend='" << m_options.androidCaptureBackend
+ << "'; local Android handoff disabled." << std::endl;
+ return;
+ }
+
+ std::cerr << "WARN: android_capture_backend=evdev is a prototype fallback. It can mirror Fedora cursor movement and is not monitor-grade on KDE Wayland." << std::endl;
+
+ LocalAndroidInputBridgeOptions options;
+ options.topology = m_topology;
+ options.localMachineName = m_options.localMachineName;
+ options.androidPeerName = m_options.androidRelay.peerName;
+ options.desktopWidth = screenSize.width;
+ options.desktopHeight = screenSize.height;
+ options.sendMouse = [this](const MouseData& mouse) {
+ return TrySendAndroidMouse(mouse);
+ };
+
+ m_localAndroidInputBridge = std::make_unique(std::move(options));
+ m_localAndroidInputBridge->Start();
+}
+
+void ClientRuntime::StopLocalAndroidInputBridge() {
+ if (m_libeiInputCaptureBridge) {
+ m_libeiInputCaptureBridge->Stop();
+ m_libeiInputCaptureBridge.reset();
+ }
+
+ if (!m_localAndroidInputBridge) {
+ return;
+ }
+ m_localAndroidInputBridge->Stop();
+ m_localAndroidInputBridge.reset();
+}
+
+bool ClientRuntime::TrySendAndroidMouse(const MouseData& mouse) {
+ return m_androidRelay && m_androidRelay->SendMouse(mouse);
+}
+
+bool ClientRuntime::TrySendAndroidKeyboard(const KeyboardData& keyboard) {
+ return m_androidRelayActive && m_androidRelay && m_androidRelay->SendKeyboard(keyboard);
+}
+
+bool ClientRuntime::TrySendAndroidGesture(const std::string& kind, double dx, double dy) {
+ return m_androidRelayActive && m_androidRelay && m_androidRelay->SendGesture(kind, dx, dy);
+}
+
+bool ClientRuntime::TrySetAndroidControlActive(bool active) {
+ const bool wasActive = m_androidRelayActive.exchange(active);
+ if (!m_androidRelay || wasActive == active) {
+ return m_androidRelay != nullptr;
+ }
+ return m_androidRelay->SendControl(active);
+}
+
+void ClientRuntime::ApplyAndroidTopologyUpdate(const std::string& frameJson) {
+ if (!m_options.androidRelay.layoutEditorEnabled) {
+ return;
+ }
+
+ // Minimal JSON parse: extract "layout" array positions for "linux" and "android"
+ int lx = 0, ly = 0, ax = 0, ay = 0;
+ bool gotLinux = false, gotAndroid = false;
+ const std::string layoutMarker = "\"layout\":[";
+ auto layoutStart = frameJson.find(layoutMarker);
+ if (layoutStart == std::string::npos) {
+ return;
+ }
+ std::string remaining = frameJson.substr(layoutStart + layoutMarker.size());
+ while (!remaining.empty() && remaining.front() != ']') {
+ auto idPos = remaining.find("\"id\":\"");
+ auto xPos = remaining.find("\"x\":");
+ auto yPos = remaining.find("\"y\":");
+ if (idPos == std::string::npos || xPos == std::string::npos || yPos == std::string::npos) {
+ break;
+ }
+ auto idValStart = idPos + 6;
+ auto idValEnd = remaining.find('"', idValStart);
+ if (idValEnd == std::string::npos) break;
+ const std::string id = remaining.substr(idValStart, idValEnd - idValStart);
+ const int x = std::stoi(remaining.substr(xPos + 4));
+ const int y = std::stoi(remaining.substr(yPos + 4));
+ if (id == "linux") { lx = x; ly = y; gotLinux = true; }
+ else if (id == "android") { ax = x; ay = y; gotAndroid = true; }
+ auto nextItem = remaining.find('{', idValEnd);
+ if (nextItem == std::string::npos) break;
+ remaining = remaining.substr(nextItem);
+ }
+ if (!gotLinux || !gotAndroid) {
+ std::cerr << "[ANDROID] topology_update: missing linux or android position." << std::endl;
+ return;
+ }
+
+ const int linuxW = m_options.screenWidth.value_or(1920);
+ const int linuxH = m_options.screenHeight.value_or(1080);
+ const int androidW = m_options.androidRelay.androidDeviceWidth;
+ const int androidH = m_options.androidRelay.androidDeviceHeight;
+ const std::string linuxMachine = m_options.localMachineName.empty() ? "linux" : m_options.localMachineName;
+ const std::string androidMachine = m_options.androidRelay.peerName;
+
+ // Determine relative placement direction
+ const int dx = ax - lx;
+ const int dy = ay - ly;
+ std::string exitEdge, entryEdge;
+ int androidAbsX = 0, androidAbsY = 0;
+ if (std::abs(dx) >= std::abs(dy)) {
+ if (dx >= 0) {
+ exitEdge = "right"; entryEdge = "left";
+ androidAbsX = linuxW;
+ androidAbsY = 0;
+ } else {
+ exitEdge = "left"; entryEdge = "right";
+ androidAbsX = -androidW;
+ androidAbsY = 0;
+ }
+ } else {
+ if (dy >= 0) {
+ exitEdge = "down"; entryEdge = "up";
+ androidAbsX = 0;
+ androidAbsY = linuxH;
+ } else {
+ exitEdge = "up"; entryEdge = "down";
+ androidAbsX = 0;
+ androidAbsY = -androidH;
+ }
+ }
+
+ std::ostringstream cfg;
+ cfg << "# auto-generated from Android layout editor\n"
+ << "wrap=none\n"
+ << "machine=" << linuxMachine << "\n"
+ << "machine=" << androidMachine << "\n"
+ << "display=" << linuxMachine << "_d," << linuxMachine << ",0,0," << linuxW << "," << linuxH << "\n"
+ << "display=" << androidMachine << "_d," << androidMachine << ","
+ << androidAbsX << "," << androidAbsY << "," << androidW << "," << androidH << "\n"
+ << "link=" << linuxMachine << "_d," << exitEdge << "," << androidMachine << "_d," << entryEdge << "\n"
+ << "link=" << androidMachine << "_d," << entryEdge << "," << linuxMachine << "_d," << exitEdge << "\n";
+
+ TopologyModel newModel;
+ std::string error;
+ if (!ParseTopologyConfig(cfg.str(), newModel, &error)) {
+ std::cerr << "[ANDROID] Failed to parse generated topology: " << error << std::endl;
+ return;
+ }
+ const auto issues = newModel.validate();
+ if (!issues.empty()) {
+ std::cerr << "[ANDROID] Generated topology invalid: " << issues.front().message << std::endl;
+ return;
+ }
+
+ const ScreenSize screenSize{linuxW, linuxH, ScreenSize::Source::Explicit};
+ const std::string sourceDisplayId = SelectTopologySourceDisplay(newModel, linuxMachine, screenSize);
+ if (sourceDisplayId.empty()) {
+ std::cerr << "[ANDROID] Generated topology has no display for local machine." << std::endl;
+ return;
+ }
+
+ m_topology = std::make_shared(std::move(newModel));
+ m_dispatcher.SetTopologyPreview(m_topology, sourceDisplayId, true);
+ m_dispatcher.SetTopologyHandoff(
+ linuxMachine,
+ linuxW,
+ linuxH,
+ true,
+ [this](const MouseData& mouse,
+ const TopologyPointerTransition&,
+ const std::string& targetMachineId) {
+ if (m_androidRelay &&
+ targetMachineId == m_options.androidRelay.peerName &&
+ TrySetAndroidControlActive(true) &&
+ TrySendAndroidMouse(mouse)) {
+ return true;
+ }
+ return m_network && m_network->SendMouse(mouse);
+ });
+
+ std::cout << "[ANDROID] Applied topology update: linux " << exitEdge
+ << " → android." << std::endl;
+
+ // Optionally persist to topology_file
+ if (!m_options.topologyFilePath.empty()) {
+ std::ofstream out(m_options.topologyFilePath);
+ if (out) {
+ out << cfg.str();
+ std::cout << "[ANDROID] Topology saved to " << m_options.topologyFilePath << std::endl;
+ }
+ }
}
} // namespace mwb
diff --git a/src/ClientRuntime.h b/src/ClientRuntime.h
index 7d62d24..304f1cc 100644
--- a/src/ClientRuntime.h
+++ b/src/ClientRuntime.h
@@ -9,10 +9,13 @@
#include
#include
+#include "AndroidRelay.h"
#include "ClipboardManager.h"
#include "InputDispatcher.h"
#include "InputLatencyStats.h"
#include "InputManager.h"
+#include "LibeiInputCaptureBridge.h"
+#include "LocalAndroidInputBridge.h"
#include "NetworkManager.h"
#include "TopologyModel.h"
@@ -42,6 +45,8 @@ struct RuntimeOptions {
bool latencyReport{false};
bool topologyRuntimeEnabled{false};
std::filesystem::path topologyFilePath;
+ std::string androidCaptureBackend{"none"};
+ AndroidRelayOptions androidRelay;
std::function onSessionEstablished;
std::function onSessionDisconnected;
};
@@ -72,14 +77,25 @@ class ClientRuntime {
void ConfigureTopologyPreview(const ScreenSize& screenSize);
void StartClipboardWatcher();
void StopClipboardWatcher();
+ void StartLocalAndroidInputBridge(const ScreenSize& screenSize);
+ void StopLocalAndroidInputBridge();
+ bool TrySendAndroidMouse(const MouseData& mouse);
+ bool TrySendAndroidKeyboard(const KeyboardData& keyboard);
+ bool TrySendAndroidGesture(const std::string& kind, double dx, double dy);
+ bool TrySetAndroidControlActive(bool active);
+ void ApplyAndroidTopologyUpdate(const std::string& layoutJson);
RuntimeOptions m_options;
std::atomic m_stopRequested{false};
+ std::atomic m_androidRelayActive{false};
InputManager m_input;
std::shared_ptr m_latencyStats;
InputDispatcher m_dispatcher;
std::shared_ptr m_topology;
std::unique_ptr m_network;
+ std::unique_ptr m_androidRelay;
+ std::unique_ptr m_libeiInputCaptureBridge;
+ std::unique_ptr m_localAndroidInputBridge;
std::unique_ptr m_clipboard;
std::atomic m_clipboardWatcherRunning{false};
std::thread m_clipboardWatcher;
diff --git a/src/GuiMainWindow.cpp b/src/GuiMainWindow.cpp
new file mode 100644
index 0000000..f3aa6ee
--- /dev/null
+++ b/src/GuiMainWindow.cpp
@@ -0,0 +1,485 @@
+#include "GuiMainWindow.h"
+#include "AppConfig.h"
+#include "MonitorLayoutWidget.h"
+
+#include
+#include
+#include
+
+namespace mwb {
+
+namespace {
+
+constexpr const char* kServiceName = "mwb-client.service";
+constexpr const char* kSystemctlPath = "/usr/bin/systemctl";
+
+// ---- colour helpers --------------------------------------------------------
+
+struct Rgb { double r, g, b; };
+
+Rgb StateColor(const std::string& state) {
+ if (state == "active") return {0.30, 0.80, 0.40}; // green
+ if (state == "activating" || state == "reloading") return {1.0, 0.65, 0.0}; // amber
+ if (state == "failed") return {0.90, 0.25, 0.20}; // red
+ if (state == "inactive") return {0.55, 0.55, 0.55}; // grey
+ return {0.40, 0.40, 0.40};
+}
+
+// ---- status dot drawing ----------------------------------------------------
+
+gboolean OnDotDraw(GtkWidget* widget, cairo_t* cr, gpointer data) {
+ auto* win = static_cast(data);
+ int w = gtk_widget_get_allocated_width(widget);
+ int h = gtk_widget_get_allocated_height(widget);
+ auto [r, g, b] = StateColor(win->connectionState);
+ cairo_arc(cr, w / 2.0, h / 2.0, w / 2.0 - 1, 0, 2 * G_PI);
+ cairo_set_source_rgb(cr, r, g, b);
+ cairo_fill(cr);
+ return FALSE;
+}
+
+// ---- service control -------------------------------------------------------
+
+void RunServiceAction(const std::string& action) {
+ if (access(kSystemctlPath, X_OK) != 0) return;
+ std::string cmd = std::string(kSystemctlPath) + " --user " + action + " " + kServiceName + " &";
+ (void)std::system(cmd.c_str());
+}
+
+void OnStartService(GtkButton*, gpointer) { RunServiceAction("start"); }
+void OnStopService(GtkButton*, gpointer) { RunServiceAction("stop"); }
+void OnRestartService(GtkButton*, gpointer) { RunServiceAction("restart"); }
+
+// ---- settings save ---------------------------------------------------------
+
+void OnSaveSettings(GtkButton*, gpointer data) {
+ auto* win = static_cast(data);
+
+ AppConfig cfg;
+ cfg.host = gtk_entry_get_text(GTK_ENTRY(win->hostEntry));
+ cfg.port = static_cast(gtk_spin_button_get_value(GTK_SPIN_BUTTON(win->portSpin)));
+ cfg.machineName = gtk_entry_get_text(GTK_ENTRY(win->nameEntry));
+ cfg.key = gtk_entry_get_text(GTK_ENTRY(win->keyEntry));
+ cfg.autoConnectEnabled = gtk_switch_get_active(GTK_SWITCH(win->autoConnectSwitch));
+ cfg.clipboardEnabled = gtk_switch_get_active(GTK_SWITCH(win->clipboardSwitch));
+ cfg.mprisMediaKeysEnabled = gtk_switch_get_active(GTK_SWITCH(win->mprisSwitch));
+ cfg.mprisPlayer = gtk_entry_get_text(GTK_ENTRY(win->mprisPlayerEntry));
+ cfg.latencyReport = gtk_switch_get_active(GTK_SWITCH(win->latencySwitch));
+ cfg.topologyRuntimeEnabled = gtk_switch_get_active(GTK_SWITCH(win->topologySwitch));
+
+ // Android
+ cfg.androidPeersEnabled = gtk_switch_get_active(GTK_SWITCH(win->androidSwitch));
+ cfg.androidRelayPort = static_cast(gtk_spin_button_get_value(GTK_SPIN_BUTTON(win->androidPortSpin)));
+ cfg.androidRelaySecret = gtk_entry_get_text(GTK_ENTRY(win->androidSecretEntry));
+ cfg.androidPeerName = gtk_entry_get_text(GTK_ENTRY(win->androidNameEntry));
+ cfg.androidDeviceWidth = static_cast(gtk_spin_button_get_value(GTK_SPIN_BUTTON(win->androidWidthSpin)));
+ cfg.androidDeviceHeight = static_cast(gtk_spin_button_get_value(GTK_SPIN_BUTTON(win->androidHeightSpin)));
+ const gchar* backendText = gtk_combo_box_text_get_active_text(GTK_COMBO_BOX_TEXT(win->androidBackendCombo));
+ if (backendText) cfg.androidCaptureBackend = backendText;
+
+ std::string err;
+ WriteAppConfig(win->configPath, cfg, &err);
+
+ if (win->onSettingsSaved) win->onSettingsSaved(cfg);
+
+ // Unlock monitor tab if topology just enabled
+ if (cfg.topologyRuntimeEnabled && win->layoutStack) {
+ gtk_stack_set_visible_child_name(GTK_STACK(win->layoutStack), "canvas");
+ }
+}
+
+// ---- topology enable button ------------------------------------------------
+
+void OnEnableTopology(GtkButton*, gpointer data) {
+ auto* win = static_cast(data);
+ if (win->topologySwitch) {
+ gtk_switch_set_active(GTK_SWITCH(win->topologySwitch), TRUE);
+ }
+ OnSaveSettings(nullptr, win);
+}
+
+// ---- layout helpers --------------------------------------------------------
+
+GtkWidget* MakeLabel(const char* text, bool secondary = false) {
+ GtkWidget* lbl = gtk_label_new(text);
+ gtk_label_set_xalign(GTK_LABEL(lbl), 0.0f);
+ if (secondary) {
+ gtk_style_context_add_class(gtk_widget_get_style_context(lbl), "dim-label");
+ }
+ return lbl;
+}
+
+GtkWidget* MakeSectionHeader(const char* text) {
+ GtkWidget* lbl = gtk_label_new(nullptr);
+ char* markup = g_markup_printf_escaped("%s", text);
+ gtk_label_set_markup(GTK_LABEL(lbl), markup);
+ g_free(markup);
+ gtk_label_set_xalign(GTK_LABEL(lbl), 0.0f);
+ gtk_widget_set_margin_top(lbl, 12);
+ gtk_widget_set_margin_bottom(lbl, 4);
+ return lbl;
+}
+
+void GridAttach(GtkWidget* grid, GtkWidget* label, GtkWidget* widget, int row) {
+ gtk_widget_set_hexpand(widget, TRUE);
+ gtk_grid_attach(GTK_GRID(grid), label, 0, row, 1, 1);
+ gtk_grid_attach(GTK_GRID(grid), widget, 1, row, 1, 1);
+}
+
+// ---- build tabs ------------------------------------------------------------
+
+GtkWidget* BuildStatusTab(GuiMainWindow* win) {
+ GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
+ gtk_container_set_border_width(GTK_CONTAINER(box), 16);
+
+ // Dot + state row
+ GtkWidget* stateRow = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
+ win->dotArea = gtk_drawing_area_new();
+ gtk_widget_set_size_request(win->dotArea, 18, 18);
+ g_signal_connect(win->dotArea, "draw", G_CALLBACK(OnDotDraw), win);
+ gtk_box_pack_start(GTK_BOX(stateRow), win->dotArea, FALSE, FALSE, 0);
+ win->stateLabel = gtk_label_new("Checking...");
+ gtk_box_pack_start(GTK_BOX(stateRow), win->stateLabel, FALSE, FALSE, 0);
+ gtk_box_pack_start(GTK_BOX(box), stateRow, FALSE, FALSE, 0);
+
+ win->detailLabel = gtk_label_new("");
+ gtk_label_set_xalign(GTK_LABEL(win->detailLabel), 0.0f);
+ gtk_style_context_add_class(gtk_widget_get_style_context(win->detailLabel), "dim-label");
+ gtk_box_pack_start(GTK_BOX(box), win->detailLabel, FALSE, FALSE, 0);
+
+ // Buttons
+ GtkWidget* btnRow = gtk_button_box_new(GTK_ORIENTATION_HORIZONTAL);
+ gtk_button_box_set_layout(GTK_BUTTON_BOX(btnRow), GTK_BUTTONBOX_START);
+ gtk_box_set_spacing(GTK_BOX(btnRow), 6);
+
+ GtkWidget* btnStart = gtk_button_new_with_label("Start");
+ GtkWidget* btnStop = gtk_button_new_with_label("Stop");
+ GtkWidget* btnRestart = gtk_button_new_with_label("Restart");
+ g_signal_connect(btnStart, "clicked", G_CALLBACK(OnStartService), win);
+ g_signal_connect(btnStop, "clicked", G_CALLBACK(OnStopService), win);
+ g_signal_connect(btnRestart, "clicked", G_CALLBACK(OnRestartService), win);
+ gtk_container_add(GTK_CONTAINER(btnRow), btnStart);
+ gtk_container_add(GTK_CONTAINER(btnRow), btnStop);
+ gtk_container_add(GTK_CONTAINER(btnRow), btnRestart);
+ gtk_box_pack_start(GTK_BOX(box), btnRow, FALSE, FALSE, 4);
+
+ // Log
+ gtk_box_pack_start(GTK_BOX(box), MakeLabel("Recent events:"), FALSE, FALSE, 0);
+ win->logBuf = gtk_text_buffer_new(nullptr);
+ win->logView = gtk_text_view_new_with_buffer(win->logBuf);
+ gtk_text_view_set_editable(GTK_TEXT_VIEW(win->logView), FALSE);
+ gtk_text_view_set_monospace(GTK_TEXT_VIEW(win->logView), TRUE);
+ gtk_text_view_set_wrap_mode(GTK_TEXT_VIEW(win->logView), GTK_WRAP_CHAR);
+ GtkWidget* scroll = gtk_scrolled_window_new(nullptr, nullptr);
+ gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll),
+ GTK_POLICY_AUTOMATIC, GTK_POLICY_AUTOMATIC);
+ gtk_widget_set_size_request(scroll, -1, 150);
+ gtk_container_add(GTK_CONTAINER(scroll), win->logView);
+ gtk_box_pack_start(GTK_BOX(box), scroll, TRUE, TRUE, 0);
+
+ return box;
+}
+
+GtkWidget* BuildSettingsTab(GuiMainWindow* win, const AppConfig& cfg) {
+ GtkWidget* outerScroll = gtk_scrolled_window_new(nullptr, nullptr);
+ gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(outerScroll),
+ GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
+ GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
+ gtk_container_set_border_width(GTK_CONTAINER(box), 16);
+ gtk_container_add(GTK_CONTAINER(outerScroll), box);
+
+ int row = 0;
+ GtkWidget* grid = gtk_grid_new();
+ gtk_grid_set_column_spacing(GTK_GRID(grid), 12);
+ gtk_grid_set_row_spacing(GTK_GRID(grid), 6);
+
+ // Section: Connection
+ gtk_box_pack_start(GTK_BOX(box), MakeSectionHeader("Connection"), FALSE, FALSE, 0);
+
+ win->hostEntry = gtk_entry_new();
+ gtk_entry_set_text(GTK_ENTRY(win->hostEntry), cfg.host.c_str());
+ gtk_entry_set_placeholder_text(GTK_ENTRY(win->hostEntry), "hostname or IP");
+ GridAttach(grid, MakeLabel("Host"), win->hostEntry, row++);
+
+ win->portSpin = gtk_spin_button_new_with_range(1, 65535, 1);
+ gtk_spin_button_set_value(GTK_SPIN_BUTTON(win->portSpin), cfg.port);
+ GridAttach(grid, MakeLabel("Port"), win->portSpin, row++);
+
+ win->nameEntry = gtk_entry_new();
+ gtk_entry_set_text(GTK_ENTRY(win->nameEntry), cfg.machineName.c_str());
+ gtk_entry_set_placeholder_text(GTK_ENTRY(win->nameEntry), "this machine's name");
+ GridAttach(grid, MakeLabel("Machine name"), win->nameEntry, row++);
+
+ win->keyEntry = gtk_entry_new();
+ gtk_entry_set_text(GTK_ENTRY(win->keyEntry), cfg.key.c_str());
+ gtk_entry_set_visibility(GTK_ENTRY(win->keyEntry), FALSE);
+ gtk_entry_set_placeholder_text(GTK_ENTRY(win->keyEntry), "security key");
+ GridAttach(grid, MakeLabel("Key"), win->keyEntry, row++);
+
+ gtk_box_pack_start(GTK_BOX(box), grid, FALSE, FALSE, 0);
+
+ // Section: Behaviour
+ GtkWidget* grid2 = gtk_grid_new();
+ gtk_grid_set_column_spacing(GTK_GRID(grid2), 12);
+ gtk_grid_set_row_spacing(GTK_GRID(grid2), 6);
+ int r2 = 0;
+
+ gtk_box_pack_start(GTK_BOX(box), MakeSectionHeader("Behaviour"), FALSE, FALSE, 0);
+
+ win->autoConnectSwitch = gtk_switch_new();
+ gtk_switch_set_active(GTK_SWITCH(win->autoConnectSwitch), cfg.autoConnectEnabled);
+ GridAttach(grid2, MakeLabel("Auto-connect"), win->autoConnectSwitch, r2++);
+
+ win->clipboardSwitch = gtk_switch_new();
+ gtk_switch_set_active(GTK_SWITCH(win->clipboardSwitch), cfg.clipboardEnabled);
+ GridAttach(grid2, MakeLabel("Clipboard sync"), win->clipboardSwitch, r2++);
+
+ win->mprisSwitch = gtk_switch_new();
+ gtk_switch_set_active(GTK_SWITCH(win->mprisSwitch), cfg.mprisMediaKeysEnabled);
+ GridAttach(grid2, MakeLabel("Media keys (MPRIS)"), win->mprisSwitch, r2++);
+
+ win->mprisPlayerEntry = gtk_entry_new();
+ gtk_entry_set_text(GTK_ENTRY(win->mprisPlayerEntry), cfg.mprisPlayer.c_str());
+ gtk_entry_set_placeholder_text(GTK_ENTRY(win->mprisPlayerEntry), "e.g. spotify (blank = any)");
+ GridAttach(grid2, MakeLabel("MPRIS player"), win->mprisPlayerEntry, r2++);
+
+ win->latencySwitch = gtk_switch_new();
+ gtk_switch_set_active(GTK_SWITCH(win->latencySwitch), cfg.latencyReport);
+ GridAttach(grid2, MakeLabel("Latency report"), win->latencySwitch, r2++);
+
+ gtk_box_pack_start(GTK_BOX(box), grid2, FALSE, FALSE, 0);
+
+ // Section: Topology
+ GtkWidget* grid3 = gtk_grid_new();
+ gtk_grid_set_column_spacing(GTK_GRID(grid3), 12);
+ gtk_grid_set_row_spacing(GTK_GRID(grid3), 6);
+
+ gtk_box_pack_start(GTK_BOX(box), MakeSectionHeader("Monitor Layout"), FALSE, FALSE, 0);
+
+ win->topologySwitch = gtk_switch_new();
+ gtk_switch_set_active(GTK_SWITCH(win->topologySwitch), cfg.topologyRuntimeEnabled);
+ GridAttach(grid3, MakeLabel("Enable topology mode"), win->topologySwitch, 0);
+
+ GtkWidget* topoHint = MakeLabel("Required for the monitor configurator and cross-device edge transitions.", true);
+ gtk_label_set_line_wrap(GTK_LABEL(topoHint), TRUE);
+ gtk_grid_attach(GTK_GRID(grid3), topoHint, 0, 1, 2, 1);
+
+ gtk_box_pack_start(GTK_BOX(box), grid3, FALSE, FALSE, 0);
+
+ // Save button
+ gtk_box_pack_start(GTK_BOX(box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL), FALSE, FALSE, 8);
+ GtkWidget* saveBtn = gtk_button_new_with_label("Save Settings");
+ gtk_style_context_add_class(gtk_widget_get_style_context(saveBtn), "suggested-action");
+ gtk_widget_set_halign(saveBtn, GTK_ALIGN_START);
+ g_signal_connect(saveBtn, "clicked", G_CALLBACK(OnSaveSettings), win);
+ gtk_box_pack_start(GTK_BOX(box), saveBtn, FALSE, FALSE, 0);
+
+ return outerScroll;
+}
+
+GtkWidget* BuildMonitorTab(GuiMainWindow* win, const AppConfig& cfg) {
+ GtkWidget* outerBox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
+
+ win->layoutStack = gtk_stack_new();
+
+ // Locked page
+ GtkWidget* lockedBox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12);
+ gtk_widget_set_valign(lockedBox, GTK_ALIGN_CENTER);
+ gtk_widget_set_halign(lockedBox, GTK_ALIGN_CENTER);
+
+ GtkWidget* lockLabel = gtk_label_new(nullptr);
+ gtk_label_set_markup(GTK_LABEL(lockLabel),
+ "🔒\n"
+ "Monitor configurator is locked\n"
+ "Enable topology mode in Settings to arrange displays\nand configure edge transitions between machines.");
+ gtk_label_set_justify(GTK_LABEL(lockLabel), GTK_JUSTIFY_CENTER);
+ gtk_label_set_line_wrap(GTK_LABEL(lockLabel), TRUE);
+ gtk_box_pack_start(GTK_BOX(lockedBox), lockLabel, FALSE, FALSE, 0);
+
+ GtkWidget* enableBtn = gtk_button_new_with_label("Enable Topology Mode");
+ gtk_widget_set_halign(enableBtn, GTK_ALIGN_CENTER);
+ g_signal_connect(enableBtn, "clicked", G_CALLBACK(OnEnableTopology), win);
+ gtk_box_pack_start(GTK_BOX(lockedBox), enableBtn, FALSE, FALSE, 0);
+
+ gtk_stack_add_named(GTK_STACK(win->layoutStack), lockedBox, "locked");
+
+ // Canvas page
+ GtkWidget* canvasBox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
+ MonitorLayoutWidget* mlw = CreateMonitorLayoutWidget(cfg);
+ win->layoutCanvas = mlw->widget;
+ gtk_widget_set_size_request(win->layoutCanvas, -1, 320);
+ gtk_box_pack_start(GTK_BOX(canvasBox), win->layoutCanvas, TRUE, TRUE, 0);
+
+ GtkWidget* btnBar = gtk_button_box_new(GTK_ORIENTATION_HORIZONTAL);
+ gtk_button_box_set_layout(GTK_BUTTON_BOX(btnBar), GTK_BUTTONBOX_END);
+ gtk_box_set_spacing(GTK_BOX(btnBar), 6);
+ gtk_container_set_border_width(GTK_CONTAINER(btnBar), 8);
+
+ GtkWidget* resetBtn = gtk_button_new_with_label("Reset");
+ g_signal_connect(resetBtn, "clicked", G_CALLBACK(MonitorLayoutWidgetReset), mlw);
+ GtkWidget* applyBtn = gtk_button_new_with_label("Apply Layout");
+ gtk_style_context_add_class(gtk_widget_get_style_context(applyBtn), "suggested-action");
+ g_signal_connect(applyBtn, "clicked", G_CALLBACK(MonitorLayoutWidgetApply), mlw);
+
+ gtk_container_add(GTK_CONTAINER(btnBar), resetBtn);
+ gtk_container_add(GTK_CONTAINER(btnBar), applyBtn);
+ gtk_box_pack_start(GTK_BOX(canvasBox), btnBar, FALSE, FALSE, 0);
+
+ gtk_stack_add_named(GTK_STACK(win->layoutStack), canvasBox, "canvas");
+
+ gtk_stack_set_visible_child_name(GTK_STACK(win->layoutStack),
+ cfg.topologyRuntimeEnabled ? "canvas" : "locked");
+
+ gtk_box_pack_start(GTK_BOX(outerBox), win->layoutStack, TRUE, TRUE, 0);
+ return outerBox;
+}
+
+GtkWidget* BuildAndroidTab(GuiMainWindow* win, const AppConfig& cfg) {
+ GtkWidget* outerScroll = gtk_scrolled_window_new(nullptr, nullptr);
+ gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(outerScroll),
+ GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
+ GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
+ gtk_container_set_border_width(GTK_CONTAINER(box), 16);
+ gtk_container_add(GTK_CONTAINER(outerScroll), box);
+
+ gtk_box_pack_start(GTK_BOX(box), MakeSectionHeader("Android Relay"), FALSE, FALSE, 0);
+
+ GtkWidget* grid = gtk_grid_new();
+ gtk_grid_set_column_spacing(GTK_GRID(grid), 12);
+ gtk_grid_set_row_spacing(GTK_GRID(grid), 6);
+ int row = 0;
+
+ win->androidSwitch = gtk_switch_new();
+ gtk_switch_set_active(GTK_SWITCH(win->androidSwitch), cfg.androidPeersEnabled);
+ GridAttach(grid, MakeLabel("Enable Android peers"), win->androidSwitch, row++);
+
+ win->androidPortSpin = gtk_spin_button_new_with_range(1, 65535, 1);
+ gtk_spin_button_set_value(GTK_SPIN_BUTTON(win->androidPortSpin), cfg.androidRelayPort);
+ GridAttach(grid, MakeLabel("Relay port"), win->androidPortSpin, row++);
+
+ win->androidSecretEntry = gtk_entry_new();
+ gtk_entry_set_text(GTK_ENTRY(win->androidSecretEntry), cfg.androidRelaySecret.c_str());
+ gtk_entry_set_visibility(GTK_ENTRY(win->androidSecretEntry), FALSE);
+ gtk_entry_set_placeholder_text(GTK_ENTRY(win->androidSecretEntry), "shared secret");
+ GridAttach(grid, MakeLabel("Secret"), win->androidSecretEntry, row++);
+
+ win->androidNameEntry = gtk_entry_new();
+ gtk_entry_set_text(GTK_ENTRY(win->androidNameEntry), cfg.androidPeerName.c_str());
+ gtk_entry_set_placeholder_text(GTK_ENTRY(win->androidNameEntry), "android");
+ GridAttach(grid, MakeLabel("Peer name"), win->androidNameEntry, row++);
+
+ win->androidBackendCombo = gtk_combo_box_text_new();
+ gtk_combo_box_text_append(GTK_COMBO_BOX_TEXT(win->androidBackendCombo), "none", "none");
+ gtk_combo_box_text_append(GTK_COMBO_BOX_TEXT(win->androidBackendCombo), "libei", "libei");
+ gtk_combo_box_text_append(GTK_COMBO_BOX_TEXT(win->androidBackendCombo), "local", "local");
+ gtk_combo_box_set_active_id(GTK_COMBO_BOX(win->androidBackendCombo),
+ cfg.androidCaptureBackend.empty() ? "none" : cfg.androidCaptureBackend.c_str());
+ GridAttach(grid, MakeLabel("Capture backend"), win->androidBackendCombo, row++);
+
+ gtk_box_pack_start(GTK_BOX(box), MakeSectionHeader("Device Dimensions"), FALSE, FALSE, 0);
+
+ win->androidWidthSpin = gtk_spin_button_new_with_range(0, 7680, 1);
+ win->androidHeightSpin = gtk_spin_button_new_with_range(0, 4320, 1);
+ gtk_spin_button_set_value(GTK_SPIN_BUTTON(win->androidWidthSpin), cfg.androidDeviceWidth);
+ gtk_spin_button_set_value(GTK_SPIN_BUTTON(win->androidHeightSpin), cfg.androidDeviceHeight);
+ GridAttach(grid, MakeLabel("Device width"), win->androidWidthSpin, row++);
+ GridAttach(grid, MakeLabel("Device height"), win->androidHeightSpin, row++);
+
+ gtk_box_pack_start(GTK_BOX(box), grid, FALSE, FALSE, 0);
+
+ gtk_box_pack_start(GTK_BOX(box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL), FALSE, FALSE, 8);
+ GtkWidget* saveBtn = gtk_button_new_with_label("Save Settings");
+ gtk_style_context_add_class(gtk_widget_get_style_context(saveBtn), "suggested-action");
+ gtk_widget_set_halign(saveBtn, GTK_ALIGN_START);
+ g_signal_connect(saveBtn, "clicked", G_CALLBACK(OnSaveSettings), win);
+ gtk_box_pack_start(GTK_BOX(box), saveBtn, FALSE, FALSE, 0);
+
+ return outerScroll;
+}
+
+} // namespace
+
+// ---- public API ------------------------------------------------------------
+
+GuiMainWindow* CreateMainWindow(const AppConfig& config,
+ const std::string& cfgPath,
+ OnSettingsSaved onSettingsSaved) {
+ auto* win = new GuiMainWindow();
+ win->configPath = cfgPath;
+ win->onSettingsSaved = std::move(onSettingsSaved);
+
+ win->window = gtk_window_new(GTK_WINDOW_TOPLEVEL);
+ gtk_window_set_title(GTK_WINDOW(win->window), "InputFlow");
+ gtk_window_set_default_size(GTK_WINDOW(win->window), 560, 500);
+ gtk_window_set_resizable(GTK_WINDOW(win->window), TRUE);
+
+ g_signal_connect(win->window, "delete-event",
+ G_CALLBACK(+[](GtkWidget* w, GdkEvent*, gpointer) -> gboolean {
+ gtk_widget_hide(w);
+ return TRUE; // don't destroy, just hide
+ }), nullptr);
+
+ win->notebook = gtk_notebook_new();
+ gtk_container_add(GTK_CONTAINER(win->window), win->notebook);
+
+ gtk_notebook_append_page(GTK_NOTEBOOK(win->notebook),
+ BuildStatusTab(win), gtk_label_new("Status"));
+ gtk_notebook_append_page(GTK_NOTEBOOK(win->notebook),
+ BuildSettingsTab(win, config), gtk_label_new("Settings"));
+ gtk_notebook_append_page(GTK_NOTEBOOK(win->notebook),
+ BuildMonitorTab(win, config), gtk_label_new("Monitor Layout"));
+ gtk_notebook_append_page(GTK_NOTEBOOK(win->notebook),
+ BuildAndroidTab(win, config), gtk_label_new("Android"));
+
+ gtk_widget_show_all(win->window);
+ gtk_widget_hide(win->window); // start hidden; shown from tray
+ return win;
+}
+
+void GuiMainWindow::UpdateStatus(const std::string& state, const std::string& detail) {
+ connectionState = state;
+ connectionDetail = detail;
+
+ if (stateLabel) {
+ std::string text;
+ if (state == "active") text = "Running";
+ else if (state == "activating") text = "Connecting…";
+ else if (state == "inactive") text = "Stopped";
+ else if (state == "failed") text = "Error";
+ else text = state.empty() ? "Unknown" : state;
+ gtk_label_set_text(GTK_LABEL(stateLabel), text.c_str());
+ }
+ if (detailLabel && !detail.empty()) {
+ gtk_label_set_text(GTK_LABEL(detailLabel), detail.c_str());
+ }
+ if (dotArea) {
+ gtk_widget_queue_draw(dotArea);
+ }
+}
+
+void GuiMainWindow::ShowTab(int index) {
+ gtk_widget_show(window);
+ gtk_window_present(GTK_WINDOW(window));
+ gtk_notebook_set_current_page(GTK_NOTEBOOK(notebook), index);
+}
+
+void GuiMainWindow::AppendLog(const std::string& line) {
+ if (!logBuf) return;
+ GtkTextIter end;
+ gtk_text_buffer_get_end_iter(logBuf, &end);
+ gtk_text_buffer_insert(logBuf, &end, (line + "\n").c_str(), -1);
+ // keep last 200 lines
+ int lineCount = gtk_text_buffer_get_line_count(logBuf);
+ if (lineCount > 200) {
+ GtkTextIter start, cut;
+ gtk_text_buffer_get_start_iter(logBuf, &start);
+ gtk_text_buffer_get_iter_at_line(logBuf, &cut, lineCount - 200);
+ gtk_text_buffer_delete(logBuf, &start, &cut);
+ }
+ // scroll to bottom
+ GtkTextIter endIter;
+ gtk_text_buffer_get_end_iter(logBuf, &endIter);
+ gtk_text_view_scroll_to_iter(GTK_TEXT_VIEW(logView), &endIter, 0.0, FALSE, 0.0, 1.0);
+}
+
+} // namespace mwb
diff --git a/src/GuiMainWindow.h b/src/GuiMainWindow.h
new file mode 100644
index 0000000..833406a
--- /dev/null
+++ b/src/GuiMainWindow.h
@@ -0,0 +1,70 @@
+#pragma once
+
+#include
+#include
+#include
+
+namespace mwb {
+
+struct AppConfig;
+
+// Callback invoked on the GTK main thread when the user saves settings.
+using OnSettingsSaved = std::function;
+
+struct GuiMainWindow {
+ GtkWidget* window{nullptr};
+ // Tabs
+ GtkWidget* notebook{nullptr};
+
+ // Status tab widgets
+ GtkWidget* dotArea{nullptr}; // GtkDrawingArea for connection dot
+ GtkWidget* stateLabel{nullptr};
+ GtkWidget* detailLabel{nullptr};
+ GtkWidget* logView{nullptr};
+ GtkTextBuffer* logBuf{nullptr};
+
+ // Settings tab — connection
+ GtkWidget* hostEntry{nullptr};
+ GtkWidget* portSpin{nullptr};
+ GtkWidget* nameEntry{nullptr};
+ GtkWidget* keyEntry{nullptr};
+
+ // Settings tab — behavior
+ GtkWidget* autoConnectSwitch{nullptr};
+ GtkWidget* clipboardSwitch{nullptr};
+ GtkWidget* mprisSwitch{nullptr};
+ GtkWidget* mprisPlayerEntry{nullptr};
+ GtkWidget* latencySwitch{nullptr};
+
+ // Settings tab — topology toggle
+ GtkWidget* topologySwitch{nullptr};
+
+ // Monitor layout tab
+ GtkWidget* layoutStack{nullptr}; // GtkStack: locked vs. canvas
+ GtkWidget* layoutCanvas{nullptr}; // GtkDrawingArea (MonitorLayoutWidget)
+
+ // Android tab
+ GtkWidget* androidSwitch{nullptr};
+ GtkWidget* androidPortSpin{nullptr};
+ GtkWidget* androidSecretEntry{nullptr};
+ GtkWidget* androidNameEntry{nullptr};
+ GtkWidget* androidBackendCombo{nullptr};
+ GtkWidget* androidWidthSpin{nullptr};
+ GtkWidget* androidHeightSpin{nullptr};
+
+ // State
+ std::string connectionState; // "active", "inactive", "failed", etc.
+ std::string connectionDetail;
+ std::string configPath;
+ OnSettingsSaved onSettingsSaved;
+
+ void UpdateStatus(const std::string& state, const std::string& detail);
+ void ShowTab(int index);
+ void AppendLog(const std::string& line);
+};
+
+GuiMainWindow* CreateMainWindow(const AppConfig& config,
+ const std::string& configPath,
+ OnSettingsSaved onSettingsSaved);
+
+} // namespace mwb
diff --git a/src/LibeiInputCaptureBridge.cpp b/src/LibeiInputCaptureBridge.cpp
new file mode 100644
index 0000000..7e0d574
--- /dev/null
+++ b/src/LibeiInputCaptureBridge.cpp
@@ -0,0 +1,1096 @@
+#include "LibeiInputCaptureBridge.h"
+
+#include
+
+#if defined(MWB_HAVE_LIBEI_INPUT_CAPTURE)
+#include
+#include
+#include
+#endif
+
+#if defined(MWB_HAVE_LIBINPUT_GESTURES)
+#include
+#include
+#endif
+
+#if defined(MWB_HAVE_LIBEI_INPUT_CAPTURE) || defined(MWB_HAVE_LIBINPUT_GESTURES)
+#include
+#include
+#endif
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace mwb {
+namespace {
+
+constexpr uint32_t WM_MOUSEMOVE = 0x0200;
+constexpr uint32_t WM_LBUTTONDOWN = 0x0201;
+constexpr uint32_t WM_LBUTTONUP = 0x0202;
+constexpr uint32_t WM_RBUTTONDOWN = 0x0204;
+constexpr uint32_t WM_RBUTTONUP = 0x0205;
+constexpr uint32_t WM_MBUTTONDOWN = 0x0207;
+constexpr uint32_t WM_MBUTTONUP = 0x0208;
+constexpr uint32_t WM_MOUSEWHEEL = 0x020A;
+constexpr uint32_t LLKHF_UP = 0x80;
+
+int ClampNormalized(int value) {
+ return std::max(0, std::min(65535, value));
+}
+
+std::optional LinuxKeyToVirtualKey(uint32_t key) {
+ // Linux letter keycodes are in QWERTY layout order, not alphabetical — must map each explicitly.
+ switch (key) {
+ case KEY_Q: return 'Q';
+ case KEY_W: return 'W';
+ case KEY_E: return 'E';
+ case KEY_R: return 'R';
+ case KEY_T: return 'T';
+ case KEY_Y: return 'Y';
+ case KEY_U: return 'U';
+ case KEY_I: return 'I';
+ case KEY_O: return 'O';
+ case KEY_P: return 'P';
+ case KEY_A: return 'A';
+ case KEY_S: return 'S';
+ case KEY_D: return 'D';
+ case KEY_F: return 'F';
+ case KEY_G: return 'G';
+ case KEY_H: return 'H';
+ case KEY_J: return 'J';
+ case KEY_K: return 'K';
+ case KEY_L: return 'L';
+ case KEY_Z: return 'Z';
+ case KEY_X: return 'X';
+ case KEY_C: return 'C';
+ case KEY_V: return 'V';
+ case KEY_B: return 'B';
+ case KEY_N: return 'N';
+ case KEY_M: return 'M';
+ default: break;
+ }
+
+ if (key >= KEY_1 && key <= KEY_9) {
+ return static_cast('1' + (key - KEY_1));
+ }
+ if (key == KEY_0) {
+ return static_cast('0');
+ }
+ if (key >= KEY_F1 && key <= KEY_F12) {
+ return 0x70 + static_cast(key - KEY_F1);
+ }
+
+ switch (key) {
+ case KEY_BACKSPACE:
+ return 0x08;
+ case KEY_TAB:
+ return 0x09;
+ case KEY_ENTER:
+ return 0x0D;
+ case KEY_ESC:
+ return 0x1B;
+ case KEY_SPACE:
+ return 0x20;
+ case KEY_PAGEUP:
+ return 0x21;
+ case KEY_PAGEDOWN:
+ return 0x22;
+ case KEY_END:
+ return 0x23;
+ case KEY_HOME:
+ return 0x24;
+ case KEY_LEFT:
+ return 0x25;
+ case KEY_UP:
+ return 0x26;
+ case KEY_RIGHT:
+ return 0x27;
+ case KEY_DOWN:
+ return 0x28;
+ case KEY_INSERT:
+ return 0x2D;
+ case KEY_DELETE:
+ return 0x2E;
+ case KEY_LEFTSHIFT:
+ case KEY_RIGHTSHIFT:
+ return 0x10;
+ case KEY_LEFTCTRL:
+ case KEY_RIGHTCTRL:
+ return 0x11;
+ case KEY_LEFTALT:
+ case KEY_RIGHTALT:
+ return 0x12;
+ case KEY_LEFTMETA:
+ return 0x5B;
+ case KEY_RIGHTMETA:
+ return 0x5C;
+ case KEY_KP0:
+ return 0x60;
+ case KEY_KP1:
+ return 0x61;
+ case KEY_KP2:
+ return 0x62;
+ case KEY_KP3:
+ return 0x63;
+ case KEY_KP4:
+ return 0x64;
+ case KEY_KP5:
+ return 0x65;
+ case KEY_KP6:
+ return 0x66;
+ case KEY_KP7:
+ return 0x67;
+ case KEY_KP8:
+ return 0x68;
+ case KEY_KP9:
+ return 0x69;
+ case KEY_KPASTERISK:
+ return 0x6A;
+ case KEY_KPPLUS:
+ return 0x6B;
+ case KEY_KPENTER:
+ return 0x0D;
+ case KEY_KPMINUS:
+ return 0x6D;
+ case KEY_KPDOT:
+ return 0x6E;
+ case KEY_KPSLASH:
+ return 0x6F;
+ case KEY_COMMA:
+ return 0xBC;
+ case KEY_DOT:
+ return 0xBE;
+ case KEY_SLASH:
+ return 0xBF;
+ case KEY_SEMICOLON:
+ return 0xBA;
+ case KEY_APOSTROPHE:
+ return 0xDE;
+ case KEY_LEFTBRACE:
+ return 0xDB;
+ case KEY_RIGHTBRACE:
+ return 0xDD;
+ case KEY_MINUS:
+ return 0xBD;
+ case KEY_EQUAL:
+ return 0xBB;
+ case KEY_GRAVE:
+ return 0xC0;
+ case KEY_BACKSLASH:
+ return 0xDC;
+ default:
+ return std::nullopt;
+ }
+}
+
+std::string ToLower(std::string value) {
+ std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) {
+ return static_cast(std::tolower(c));
+ });
+ return value;
+}
+
+std::optional FindTouchpadEventDevice() {
+ const std::filesystem::path inputClass{"/sys/class/input"};
+ std::error_code ec;
+ for (const auto& entry : std::filesystem::directory_iterator(inputClass, ec)) {
+ if (ec) {
+ break;
+ }
+ const auto eventName = entry.path().filename().string();
+ if (eventName.rfind("event", 0) != 0) {
+ continue;
+ }
+ std::ifstream nameFile(entry.path() / "device" / "name");
+ std::string name;
+ if (!std::getline(nameFile, name)) {
+ continue;
+ }
+ const auto lower = ToLower(name);
+ if (lower.find("touchpad") != std::string::npos) {
+ return "/dev/input/" + eventName;
+ }
+ }
+ return std::nullopt;
+}
+
+std::optional> ParseDeltaToken(const std::string& token) {
+ const auto slash = token.find('/');
+ if (slash == std::string::npos) {
+ return std::nullopt;
+ }
+ try {
+ return std::pair{
+ std::stod(token.substr(0, slash)),
+ std::stod(token.substr(slash + 1)),
+ };
+ } catch (...) {
+ return std::nullopt;
+ }
+}
+
+bool EmitClassifiedSwipe(
+ const std::function& sendGesture,
+ int fingers,
+ double dx,
+ double dy,
+ bool cancelled) {
+ if (!sendGesture || cancelled || fingers < 3) {
+ return false;
+ }
+
+ const double absX = std::abs(dx);
+ const double absY = std::abs(dy);
+ if (absY < 36.0 || absY < absX * 1.35) {
+ return false;
+ }
+
+ return sendGesture(fingers >= 4 ? "swipe4" : "swipe3", dx, dy);
+}
+
+#if defined(MWB_HAVE_LIBINPUT_GESTURES)
+
+int OpenLibinputDevice(const char* path, int flags, void*) {
+ const int fd = open(path, flags);
+ return fd < 0 ? -errno : fd;
+}
+
+void CloseLibinputDevice(int fd, void*) {
+ close(fd);
+}
+
+const libinput_interface kLibinputInterface{
+ OpenLibinputDevice,
+ CloseLibinputDevice,
+};
+
+bool RunNativeLibinputGestureMonitor(
+ const std::string& device,
+ const std::atomic& running,
+ const std::function& sendGesture) {
+ libinput* context = libinput_path_create_context(&kLibinputInterface, nullptr);
+ if (!context) {
+ std::cerr << "WARN: Failed to create native libinput gesture context." << std::endl;
+ return false;
+ }
+
+ libinput_device* inputDevice = libinput_path_add_device(context, device.c_str());
+ if (!inputDevice) {
+ std::cerr << "WARN: Failed to open " << device << " through native libinput." << std::endl;
+ libinput_unref(context);
+ return false;
+ }
+
+ std::cout << "[ANDROID] native libinput gesture monitor using " << device << std::endl;
+
+ bool swipeActive = false;
+ int swipeFingers = 0;
+ double swipeDx = 0.0;
+ double swipeDy = 0.0;
+ bool swipeCancelled = false;
+
+ bool pinchActive = false;
+ double pinchScale = 1.0;
+ bool pinchCancelled = false;
+
+ while (running) {
+ libinput_dispatch(context);
+ while (libinput_event* event = libinput_get_event(context)) {
+ const auto type = libinput_event_get_type(event);
+ if (type < LIBINPUT_EVENT_GESTURE_SWIPE_BEGIN ||
+ type > LIBINPUT_EVENT_GESTURE_HOLD_END) {
+ libinput_event_destroy(event);
+ continue;
+ }
+ switch (type) {
+ case LIBINPUT_EVENT_GESTURE_SWIPE_BEGIN: {
+ libinput_event_gesture* gesture = libinput_event_get_gesture_event(event);
+ swipeActive = true;
+ swipeFingers = libinput_event_gesture_get_finger_count(gesture);
+ swipeDx = 0.0;
+ swipeDy = 0.0;
+ swipeCancelled = false;
+ break;
+ }
+ case LIBINPUT_EVENT_GESTURE_SWIPE_UPDATE: {
+ if (swipeActive) {
+ libinput_event_gesture* gesture = libinput_event_get_gesture_event(event);
+ swipeDx += libinput_event_gesture_get_dx(gesture);
+ swipeDy += libinput_event_gesture_get_dy(gesture);
+ }
+ break;
+ }
+ case LIBINPUT_EVENT_GESTURE_SWIPE_END: {
+ if (swipeActive) {
+ libinput_event_gesture* gesture = libinput_event_get_gesture_event(event);
+ swipeCancelled = libinput_event_gesture_get_cancelled(gesture) != 0;
+ EmitClassifiedSwipe(sendGesture, swipeFingers, swipeDx, swipeDy, swipeCancelled);
+ swipeActive = false;
+ }
+ break;
+ }
+ case LIBINPUT_EVENT_GESTURE_PINCH_BEGIN:
+ pinchActive = true;
+ pinchScale = 1.0;
+ pinchCancelled = false;
+ break;
+ case LIBINPUT_EVENT_GESTURE_PINCH_UPDATE: {
+ if (pinchActive) {
+ libinput_event_gesture* gesture = libinput_event_get_gesture_event(event);
+ pinchScale = libinput_event_gesture_get_scale(gesture);
+ }
+ break;
+ }
+ case LIBINPUT_EVENT_GESTURE_PINCH_END: {
+ if (pinchActive) {
+ libinput_event_gesture* gesture = libinput_event_get_gesture_event(event);
+ pinchCancelled = libinput_event_gesture_get_cancelled(gesture) != 0;
+ if (!pinchCancelled && (pinchScale < 0.92 || pinchScale > 1.08) && sendGesture) {
+ sendGesture("pinch", pinchScale, 0.0);
+ }
+ pinchActive = false;
+ }
+ break;
+ }
+ default:
+ break;
+ }
+ libinput_event_destroy(event);
+ }
+
+ pollfd fd{libinput_get_fd(context), POLLIN, 0};
+ poll(&fd, 1, 250);
+ }
+
+ libinput_path_remove_device(inputDevice);
+ libinput_unref(context);
+ return true;
+}
+
+#endif
+
+#if defined(MWB_HAVE_LIBEI_INPUT_CAPTURE)
+
+constexpr const char* kPortalBusName = "org.freedesktop.portal.Desktop";
+constexpr const char* kInputCapturePath = "/org/freedesktop/portal/desktop";
+constexpr const char* kInputCaptureInterface = "org.freedesktop.portal.InputCapture";
+constexpr uint32_t kCapabilityKeyboard = 1;
+constexpr uint32_t kCapabilityPointer = 2;
+constexpr uint32_t kCapabilityMask = kCapabilityKeyboard | kCapabilityPointer;
+
+constexpr uint32_t kRightBarrierId = 1;
+
+struct RequestResult {
+ bool done{false};
+ uint32_t response{2};
+ GVariant* results{nullptr};
+};
+
+std::string UniqueToken(const char* prefix) {
+ const auto now = std::chrono::steady_clock::now().time_since_epoch().count();
+ return std::string(prefix) + "_" + std::to_string(getpid()) + "_" + std::to_string(now);
+}
+
+void FreeError(GError* error, const char* context) {
+ if (!error) {
+ return;
+ }
+ std::cerr << "WARN: libei input capture " << context << " failed: " << error->message << std::endl;
+ g_error_free(error);
+}
+
+void OnRequestResponse(GDBusConnection*,
+ const gchar*,
+ const gchar*,
+ const gchar*,
+ const gchar*,
+ GVariant* parameters,
+ gpointer userData) {
+ auto* result = static_cast(userData);
+ uint32_t response = 2;
+ GVariant* results = nullptr;
+ g_variant_get(parameters, "(u@a{sv})", &response, &results);
+ result->response = response;
+ result->results = results;
+ result->done = true;
+}
+
+bool WaitForRequest(GDBusConnection* connection,
+ const char* requestPath,
+ RequestResult& result,
+ int timeoutMs) {
+ const guint subscription = g_dbus_connection_signal_subscribe(
+ connection,
+ kPortalBusName,
+ "org.freedesktop.portal.Request",
+ "Response",
+ requestPath,
+ nullptr,
+ G_DBUS_SIGNAL_FLAGS_NO_MATCH_RULE,
+ OnRequestResponse,
+ &result,
+ nullptr);
+
+ const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs);
+ while (!result.done && std::chrono::steady_clock::now() < deadline) {
+ g_main_context_iteration(nullptr, TRUE);
+ }
+
+ g_dbus_connection_signal_unsubscribe(connection, subscription);
+ if (!result.done) {
+ std::cerr << "WARN: libei input capture request timed out: " << requestPath << std::endl;
+ return false;
+ }
+ if (result.response != 0) {
+ std::cerr << "WARN: libei input capture request was denied/cancelled: " << requestPath
+ << " response=" << result.response << std::endl;
+ return false;
+ }
+ return true;
+}
+
+std::optional CreateSession(GDBusConnection* connection) {
+ GVariantBuilder options;
+ g_variant_builder_init(&options, G_VARIANT_TYPE("a{sv}"));
+ const std::string token = UniqueToken("inputflow_android");
+ g_variant_builder_add(&options, "{sv}", "session_handle_token", g_variant_new_string(token.c_str()));
+ g_variant_builder_add(&options, "{sv}", "capabilities", g_variant_new_uint32(kCapabilityMask));
+
+ GError* error = nullptr;
+ GVariant* reply = g_dbus_connection_call_sync(
+ connection,
+ kPortalBusName,
+ kInputCapturePath,
+ kInputCaptureInterface,
+ "CreateSession",
+ g_variant_new("(sa{sv})", "", &options),
+ G_VARIANT_TYPE("(o)"),
+ G_DBUS_CALL_FLAGS_NONE,
+ -1,
+ nullptr,
+ &error);
+ if (!reply) {
+ FreeError(error, "CreateSession");
+ return std::nullopt;
+ }
+
+ const char* requestPath = nullptr;
+ g_variant_get(reply, "(&o)", &requestPath);
+ const std::string request(requestPath ? requestPath : "");
+ g_variant_unref(reply);
+ if (request.empty()) {
+ return std::nullopt;
+ }
+
+ RequestResult result;
+ if (!WaitForRequest(connection, request.c_str(), result, 30000)) {
+ if (result.results) {
+ g_variant_unref(result.results);
+ }
+ return std::nullopt;
+ }
+
+ const char* sessionHandle = nullptr;
+ const bool ok = result.results && g_variant_lookup(result.results, "session_handle", "&o", &sessionHandle);
+ std::optional out;
+ if (ok && sessionHandle) {
+ out = sessionHandle;
+ } else {
+ std::cerr << "WARN: libei input capture CreateSession returned no session_handle." << std::endl;
+ }
+ if (result.results) {
+ g_variant_unref(result.results);
+ }
+ return out;
+}
+
+struct ZoneInfo {
+ uint32_t width{0};
+ uint32_t height{0};
+ int32_t x{0};
+ int32_t y{0};
+ uint32_t zoneSet{0};
+};
+
+std::optional GetZones(GDBusConnection* connection, const std::string& sessionHandle) {
+ GVariantBuilder options;
+ g_variant_builder_init(&options, G_VARIANT_TYPE("a{sv}"));
+ g_variant_builder_add(&options, "{sv}", "handle_token", g_variant_new_string(UniqueToken("zones").c_str()));
+
+ GError* error = nullptr;
+ GVariant* reply = g_dbus_connection_call_sync(
+ connection,
+ kPortalBusName,
+ kInputCapturePath,
+ kInputCaptureInterface,
+ "GetZones",
+ g_variant_new("(oa{sv})", sessionHandle.c_str(), &options),
+ G_VARIANT_TYPE("(o)"),
+ G_DBUS_CALL_FLAGS_NONE,
+ -1,
+ nullptr,
+ &error);
+ if (!reply) {
+ FreeError(error, "GetZones");
+ return std::nullopt;
+ }
+ const char* requestPath = nullptr;
+ g_variant_get(reply, "(&o)", &requestPath);
+ const std::string request(requestPath ? requestPath : "");
+ g_variant_unref(reply);
+
+ RequestResult result;
+ if (request.empty() || !WaitForRequest(connection, request.c_str(), result, 30000)) {
+ if (result.results) {
+ g_variant_unref(result.results);
+ }
+ return std::nullopt;
+ }
+
+ ZoneInfo selected;
+ g_variant_lookup(result.results, "zone_set", "u", &selected.zoneSet);
+ GVariant* zones = g_variant_lookup_value(result.results, "zones", G_VARIANT_TYPE("a(uuii)"));
+ if (!zones || g_variant_n_children(zones) == 0) {
+ std::cerr << "WARN: libei input capture portal returned no zones." << std::endl;
+ if (zones) {
+ g_variant_unref(zones);
+ }
+ g_variant_unref(result.results);
+ return std::nullopt;
+ }
+
+ uint64_t bestArea = 0;
+ for (gsize index = 0; index < g_variant_n_children(zones); ++index) {
+ uint32_t width = 0;
+ uint32_t height = 0;
+ int32_t x = 0;
+ int32_t y = 0;
+ g_variant_get_child(zones, index, "(uuii)", &width, &height, &x, &y);
+ const uint64_t area = static_cast(width) * height;
+ if (area > bestArea) {
+ bestArea = area;
+ selected.width = width;
+ selected.height = height;
+ selected.x = x;
+ selected.y = y;
+ }
+ }
+ g_variant_unref(zones);
+ g_variant_unref(result.results);
+ return selected;
+}
+
+bool SetRightBarrier(GDBusConnection* connection,
+ const std::string& sessionHandle,
+ const ZoneInfo& zone) {
+ GVariantBuilder options;
+ g_variant_builder_init(&options, G_VARIANT_TYPE("a{sv}"));
+ g_variant_builder_add(&options, "{sv}", "handle_token", g_variant_new_string(UniqueToken("barrier").c_str()));
+
+ GVariantBuilder barrier;
+ g_variant_builder_init(&barrier, G_VARIANT_TYPE("a{sv}"));
+ g_variant_builder_add(&barrier, "{sv}", "barrier_id", g_variant_new_uint32(kRightBarrierId));
+ const int32_t x = zone.x + static_cast(zone.width);
+ const int32_t y1 = zone.y;
+ const int32_t y2 = zone.y + static_cast(zone.height) - 1;
+ g_variant_builder_add(&barrier, "{sv}", "position", g_variant_new("(iiii)", x, y1, x, y2));
+ GVariant* barrierValue = g_variant_builder_end(&barrier);
+
+ GVariantBuilder barriers;
+ g_variant_builder_init(&barriers, G_VARIANT_TYPE("aa{sv}"));
+ g_variant_builder_add_value(&barriers, barrierValue);
+
+ GError* error = nullptr;
+ GVariant* reply = g_dbus_connection_call_sync(
+ connection,
+ kPortalBusName,
+ kInputCapturePath,
+ kInputCaptureInterface,
+ "SetPointerBarriers",
+ g_variant_new("(oa{sv}aa{sv}u)", sessionHandle.c_str(), &options, &barriers, zone.zoneSet),
+ G_VARIANT_TYPE("(o)"),
+ G_DBUS_CALL_FLAGS_NONE,
+ -1,
+ nullptr,
+ &error);
+ if (!reply) {
+ FreeError(error, "SetPointerBarriers");
+ return false;
+ }
+ const char* requestPath = nullptr;
+ g_variant_get(reply, "(&o)", &requestPath);
+ const std::string request(requestPath ? requestPath : "");
+ g_variant_unref(reply);
+
+ RequestResult result;
+ const bool ok = !request.empty() && WaitForRequest(connection, request.c_str(), result, 30000);
+ if (ok && result.results) {
+ GVariant* failed = g_variant_lookup_value(result.results, "failed_barriers", G_VARIANT_TYPE("au"));
+ if (failed && g_variant_n_children(failed) > 0) {
+ std::cerr << "WARN: libei input capture portal rejected the right-edge barrier." << std::endl;
+ g_variant_unref(failed);
+ g_variant_unref(result.results);
+ return false;
+ }
+ if (failed) {
+ g_variant_unref(failed);
+ }
+ }
+ if (result.results) {
+ g_variant_unref(result.results);
+ }
+ return ok;
+}
+
+std::optional ConnectToEis(GDBusConnection* connection, const std::string& sessionHandle) {
+ GVariantBuilder options;
+ g_variant_builder_init(&options, G_VARIANT_TYPE("a{sv}"));
+
+ GUnixFDList* outFds = nullptr;
+ GError* error = nullptr;
+ GVariant* reply = g_dbus_connection_call_with_unix_fd_list_sync(
+ connection,
+ kPortalBusName,
+ kInputCapturePath,
+ kInputCaptureInterface,
+ "ConnectToEIS",
+ g_variant_new("(oa{sv})", sessionHandle.c_str(), &options),
+ G_VARIANT_TYPE("(h)"),
+ G_DBUS_CALL_FLAGS_NONE,
+ -1,
+ nullptr,
+ &outFds,
+ nullptr,
+ &error);
+ if (!reply) {
+ FreeError(error, "ConnectToEIS");
+ return std::nullopt;
+ }
+
+ int fdIndex = -1;
+ g_variant_get(reply, "(h)", &fdIndex);
+ g_variant_unref(reply);
+ if (!outFds || fdIndex < 0) {
+ if (outFds) {
+ g_object_unref(outFds);
+ }
+ return std::nullopt;
+ }
+ int fd = g_unix_fd_list_get(outFds, fdIndex, &error);
+ g_object_unref(outFds);
+ if (fd < 0) {
+ FreeError(error, "ConnectToEIS fd");
+ return std::nullopt;
+ }
+ return fd;
+}
+
+bool EnableCapture(GDBusConnection* connection, const std::string& sessionHandle) {
+ GVariantBuilder options;
+ g_variant_builder_init(&options, G_VARIANT_TYPE("a{sv}"));
+ GError* error = nullptr;
+ GVariant* reply = g_dbus_connection_call_sync(
+ connection,
+ kPortalBusName,
+ kInputCapturePath,
+ kInputCaptureInterface,
+ "Enable",
+ g_variant_new("(oa{sv})", sessionHandle.c_str(), &options),
+ nullptr,
+ G_DBUS_CALL_FLAGS_NONE,
+ -1,
+ nullptr,
+ &error);
+ if (!reply) {
+ FreeError(error, "Enable");
+ return false;
+ }
+ g_variant_unref(reply);
+ return true;
+}
+
+void ReleaseCapture(GDBusConnection* connection, const std::string& sessionHandle) {
+ GVariantBuilder options;
+ g_variant_builder_init(&options, G_VARIANT_TYPE("a{sv}"));
+ GError* error = nullptr;
+ GVariant* reply = g_dbus_connection_call_sync(
+ connection,
+ kPortalBusName,
+ kInputCapturePath,
+ kInputCaptureInterface,
+ "Release",
+ g_variant_new("(oa{sv})", sessionHandle.c_str(), &options),
+ nullptr,
+ G_DBUS_CALL_FLAGS_NONE,
+ 1000,
+ nullptr,
+ &error);
+ if (reply) {
+ g_variant_unref(reply);
+ } else {
+ FreeError(error, "Release");
+ }
+}
+
+void CloseSession(GDBusConnection* connection, const std::string& sessionHandle) {
+ if (sessionHandle.empty()) {
+ return;
+ }
+ GError* error = nullptr;
+ GVariant* reply = g_dbus_connection_call_sync(
+ connection,
+ kPortalBusName,
+ sessionHandle.c_str(),
+ "org.freedesktop.portal.Session",
+ "Close",
+ nullptr,
+ nullptr,
+ G_DBUS_CALL_FLAGS_NONE,
+ 1000,
+ nullptr,
+ &error);
+ if (reply) {
+ g_variant_unref(reply);
+ } else {
+ FreeError(error, "Session.Close");
+ }
+}
+
+#endif
+
+} // namespace
+
+LibeiInputCaptureBridge::LibeiInputCaptureBridge(LibeiInputCaptureBridgeOptions options)
+ : m_options(std::move(options)) {}
+
+LibeiInputCaptureBridge::~LibeiInputCaptureBridge() {
+ Stop();
+}
+
+bool LibeiInputCaptureBridge::Start() {
+ if (m_running.exchange(true)) {
+ return true;
+ }
+ m_thread = std::thread([this]() {
+ Run();
+ });
+ m_gestureThread = std::thread([this]() {
+ RunLibinputGestureMonitor();
+ });
+ return true;
+}
+
+void LibeiInputCaptureBridge::Stop() {
+ m_running = false;
+ if (m_thread.joinable()) {
+ m_thread.join();
+ }
+ if (m_gestureThread.joinable()) {
+ m_gestureThread.join();
+ }
+}
+
+void LibeiInputCaptureBridge::Run() {
+#if defined(MWB_HAVE_LIBEI_INPUT_CAPTURE)
+ GError* error = nullptr;
+ GDBusConnection* connection = g_bus_get_sync(G_BUS_TYPE_SESSION, nullptr, &error);
+ if (!connection) {
+ FreeError(error, "session bus");
+ m_running = false;
+ return;
+ }
+
+ const auto sessionHandle = CreateSession(connection);
+ if (!sessionHandle.has_value()) {
+ g_object_unref(connection);
+ m_running = false;
+ return;
+ }
+
+ const auto zone = GetZones(connection, *sessionHandle);
+ if (!zone.has_value() || !SetRightBarrier(connection, *sessionHandle, *zone)) {
+ CloseSession(connection, *sessionHandle);
+ g_object_unref(connection);
+ m_running = false;
+ return;
+ }
+
+ const auto eisFd = ConnectToEis(connection, *sessionHandle);
+ if (!eisFd.has_value()) {
+ CloseSession(connection, *sessionHandle);
+ g_object_unref(connection);
+ m_running = false;
+ return;
+ }
+
+ ei* context = ei_new_receiver(this);
+ if (!context) {
+ close(*eisFd);
+ CloseSession(connection, *sessionHandle);
+ g_object_unref(connection);
+ m_running = false;
+ return;
+ }
+ if (ei_setup_backend_fd(context, *eisFd) < 0) {
+ std::cerr << "WARN: libei input capture failed to initialize EIS fd." << std::endl;
+ ei_unref(context);
+ CloseSession(connection, *sessionHandle);
+ g_object_unref(connection);
+ m_running = false;
+ return;
+ }
+
+ if (!EnableCapture(connection, *sessionHandle)) {
+ ei_unref(context);
+ CloseSession(connection, *sessionHandle);
+ g_object_unref(connection);
+ m_running = false;
+ return;
+ }
+
+ std::cout << "[ANDROID] libei input capture enabled on right edge via XDG portal." << std::endl;
+ int androidX = 0;
+ int androidY = 32767;
+ int keyboardEventsLogged = 0;
+ bool remoteControlActive = false;
+ const auto setRemoteControlActive = [&](bool active) {
+ if (remoteControlActive == active) {
+ return;
+ }
+ remoteControlActive = active;
+ if (m_options.sendControl) {
+ m_options.sendControl(active);
+ }
+ };
+ while (m_running) {
+ pollfd fds[1] = {
+ pollfd{ei_get_fd(context), POLLIN, 0},
+ };
+ const int rc = poll(fds, 1, 100);
+ while (g_main_context_pending(nullptr)) {
+ g_main_context_iteration(nullptr, FALSE);
+ }
+ if (rc < 0) {
+ continue;
+ }
+ if (rc == 0) {
+ continue;
+ }
+ ei_dispatch(context);
+
+ ei_event* event = nullptr;
+ while ((event = ei_get_event(context)) != nullptr) {
+ const auto type = ei_event_get_type(event);
+ switch (type) {
+ case EI_EVENT_CONNECT:
+ break;
+ case EI_EVENT_DISCONNECT:
+ std::cerr << "WARN: libei input capture disconnected." << std::endl;
+ setRemoteControlActive(false);
+ m_running = false;
+ break;
+ case EI_EVENT_SEAT_ADDED: {
+ ei_seat* seat = ei_event_get_seat(event);
+ ei_seat_bind_capabilities(seat,
+ EI_DEVICE_CAP_POINTER,
+ EI_DEVICE_CAP_BUTTON,
+ EI_DEVICE_CAP_SCROLL,
+ EI_DEVICE_CAP_KEYBOARD,
+ nullptr);
+ break;
+ }
+ case EI_EVENT_DEVICE_ADDED: {
+ ei_device* device = ei_event_get_device(event);
+ if (device &&
+ !ei_device_has_capability(device, EI_DEVICE_CAP_POINTER) &&
+ !ei_device_has_capability(device, EI_DEVICE_CAP_BUTTON) &&
+ !ei_device_has_capability(device, EI_DEVICE_CAP_SCROLL) &&
+ !ei_device_has_capability(device, EI_DEVICE_CAP_KEYBOARD)) {
+ ei_device_close(device);
+ }
+ break;
+ }
+ case EI_EVENT_POINTER_MOTION: {
+ const double dx = ei_event_pointer_get_dx(event);
+ const double dy = ei_event_pointer_get_dy(event);
+ androidX = ClampNormalized(androidX + static_cast(std::lround(dx * 40.0)));
+ androidY = ClampNormalized(androidY + static_cast(std::lround(dy * 40.0)));
+ if (androidX <= 0 && dx < 0.0) {
+ setRemoteControlActive(false);
+ ReleaseCapture(connection, *sessionHandle);
+ std::cout << "[ANDROID] libei input capture released back to Fedora from Android left edge." << std::endl;
+ break;
+ }
+ setRemoteControlActive(true);
+ MouseData mouse{androidX, androidY, 0, WM_MOUSEMOVE};
+ if (m_options.sendMouse) {
+ m_options.sendMouse(mouse);
+ }
+ break;
+ }
+ case EI_EVENT_BUTTON_BUTTON: {
+ const uint32_t button = ei_event_button_get_button(event);
+ const bool press = ei_event_button_get_is_press(event);
+ uint32_t message = 0;
+ if (button == BTN_LEFT) {
+ message = press ? WM_LBUTTONDOWN : WM_LBUTTONUP;
+ } else if (button == BTN_RIGHT) {
+ message = press ? WM_RBUTTONDOWN : WM_RBUTTONUP;
+ } else if (button == BTN_MIDDLE) {
+ message = press ? WM_MBUTTONDOWN : WM_MBUTTONUP;
+ }
+ if (message != 0 && m_options.sendMouse) {
+ MouseData mouse{androidX, androidY, 0, message};
+ m_options.sendMouse(mouse);
+ }
+ break;
+ }
+ case EI_EVENT_SCROLL_DELTA: {
+ const double dx = ei_event_scroll_get_dx(event);
+ const double dy = ei_event_scroll_get_dy(event);
+ if ((std::abs(dx) > 0.01 || std::abs(dy) > 0.01) && m_options.sendGesture) {
+ m_options.sendGesture("scroll", dx, dy);
+ } else if (std::abs(dy) > 0.01 && m_options.sendMouse) {
+ MouseData mouse{androidX, androidY, static_cast(std::lround(-dy * 120.0)), WM_MOUSEWHEEL};
+ m_options.sendMouse(mouse);
+ }
+ break;
+ }
+ case EI_EVENT_SCROLL_STOP: {
+ break;
+ }
+ case EI_EVENT_KEYBOARD_KEY: {
+ const uint32_t key = ei_event_keyboard_get_key(event);
+ const bool press = ei_event_keyboard_get_key_is_press(event);
+ const auto vk = LinuxKeyToVirtualKey(key);
+ if (vk.has_value() && m_options.sendKeyboard) {
+ KeyboardData keyboard{*vk, press ? 0u : LLKHF_UP};
+ if (keyboardEventsLogged < 20) {
+ std::cout << "[ANDROID] libei keyboard event key=" << key
+ << " vk=" << *vk
+ << " press=" << (press ? "true" : "false") << std::endl;
+ ++keyboardEventsLogged;
+ }
+ m_options.sendKeyboard(keyboard);
+ }
+ break;
+ }
+ default:
+ break;
+ }
+ ei_event_unref(event);
+ }
+ }
+
+ setRemoteControlActive(false);
+ ei_unref(context);
+ CloseSession(connection, *sessionHandle);
+ g_object_unref(connection);
+#else
+ std::cerr << "WARN: android_capture_backend=libei requires libei-devel and glib2-devel at build time; local Android capture is disabled." << std::endl;
+#endif
+ m_running = false;
+}
+
+void LibeiInputCaptureBridge::RunLibinputGestureMonitor() {
+ if (!m_options.sendGesture) {
+ return;
+ }
+ const auto device = FindTouchpadEventDevice();
+ if (!device.has_value()) {
+ std::cout << "[ANDROID] libinput gesture monitor skipped; no touchpad event device found." << std::endl;
+ return;
+ }
+
+#if defined(MWB_HAVE_LIBINPUT_GESTURES)
+ if (RunNativeLibinputGestureMonitor(*device, m_running, m_options.sendGesture)) {
+ return;
+ }
+ std::cerr << "WARN: Falling back to libinput debug-events gesture parser." << std::endl;
+#endif
+
+ std::cout << "[ANDROID] libinput gesture monitor using " << *device << std::endl;
+ while (m_running) {
+ const std::string command =
+ "sh -c 'for fd in /proc/$$/fd/[3-9]*; do "
+ "[ -e \"$fd\" ] && eval \"exec ${fd##*/}<&-\"; "
+ "done; exec timeout 1s libinput debug-events --device " + *device + " 2>/dev/null'";
+ FILE* pipe = popen(command.c_str(), "r");
+ if (!pipe) {
+ std::cerr << "WARN: Failed to start libinput gesture monitor." << std::endl;
+ return;
+ }
+
+ char buffer[512];
+ bool swipeActive = false;
+ bool pinchActive = false;
+ int fingers = 0;
+ double swipeDx = 0.0;
+ double swipeDy = 0.0;
+ double pinchScale = 1.0;
+
+ while (m_running && fgets(buffer, sizeof(buffer), pipe) != nullptr) {
+ const std::string line(buffer);
+ std::istringstream in(line);
+ std::string deviceToken;
+ std::string type;
+ std::string timeToken;
+ in >> deviceToken >> type >> timeToken;
+
+ if (type == "GESTURE_SWIPE_BEGIN") {
+ in >> fingers;
+ swipeActive = true;
+ swipeDx = 0.0;
+ swipeDy = 0.0;
+ } else if (type == "GESTURE_SWIPE_UPDATE" && swipeActive) {
+ int updateFingers = 0;
+ std::string delta;
+ in >> updateFingers >> delta;
+ if (auto parsed = ParseDeltaToken(delta); parsed.has_value()) {
+ fingers = updateFingers;
+ swipeDx += parsed->first;
+ swipeDy += parsed->second;
+ }
+ } else if (type == "GESTURE_SWIPE_END" && swipeActive) {
+ EmitClassifiedSwipe(m_options.sendGesture, fingers, swipeDx, swipeDy, false);
+ swipeActive = false;
+ } else if (type == "GESTURE_PINCH_BEGIN") {
+ in >> fingers;
+ pinchActive = true;
+ pinchScale = 1.0;
+ } else if (type == "GESTURE_PINCH_UPDATE" && pinchActive) {
+ std::string token;
+ double lastReasonableScale = 1.0;
+ while (in >> token) {
+ try {
+ const double value = std::stod(token);
+ if (value > 0.25 && value < 4.0) {
+ lastReasonableScale = value;
+ }
+ } catch (...) {
+ }
+ }
+ pinchScale *= lastReasonableScale;
+ } else if (type == "GESTURE_PINCH_END" && pinchActive) {
+ if (pinchScale < 0.92 || pinchScale > 1.08) {
+ m_options.sendGesture("pinch", pinchScale, 0.0);
+ }
+ pinchActive = false;
+ }
+ }
+
+ pclose(pipe);
+ }
+}
+
+} // namespace mwb
diff --git a/src/LibeiInputCaptureBridge.h b/src/LibeiInputCaptureBridge.h
new file mode 100644
index 0000000..60b67cc
--- /dev/null
+++ b/src/LibeiInputCaptureBridge.h
@@ -0,0 +1,40 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+
+#include "Protocol.h"
+
+namespace mwb {
+
+struct LibeiInputCaptureBridgeOptions {
+ int desktopWidth{0};
+ int desktopHeight{0};
+ std::function sendMouse;
+ std::function sendKeyboard;
+ std::function sendGesture;
+ std::function sendControl;
+};
+
+class LibeiInputCaptureBridge {
+public:
+ explicit LibeiInputCaptureBridge(LibeiInputCaptureBridgeOptions options);
+ ~LibeiInputCaptureBridge();
+
+ bool Start();
+ void Stop();
+
+private:
+ void Run();
+ void RunLibinputGestureMonitor();
+
+ LibeiInputCaptureBridgeOptions m_options;
+ std::atomic m_running{false};
+ std::thread m_thread;
+ std::thread m_gestureThread;
+};
+
+} // namespace mwb
diff --git a/src/LocalAndroidInputBridge.cpp b/src/LocalAndroidInputBridge.cpp
new file mode 100644
index 0000000..94afe91
--- /dev/null
+++ b/src/LocalAndroidInputBridge.cpp
@@ -0,0 +1,531 @@
+#include "LocalAndroidInputBridge.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace mwb {
+namespace {
+
+constexpr uint32_t WM_MOUSEMOVE = 0x0200;
+constexpr uint32_t WM_LBUTTONDOWN = 0x0201;
+constexpr uint32_t WM_LBUTTONUP = 0x0202;
+constexpr uint32_t WM_RBUTTONDOWN = 0x0204;
+constexpr uint32_t WM_RBUTTONUP = 0x0205;
+constexpr uint32_t WM_MBUTTONDOWN = 0x0207;
+constexpr uint32_t WM_MBUTTONUP = 0x0208;
+constexpr uint32_t WM_MOUSEWHEEL = 0x020A;
+
+int ClampNormalized(int value) {
+ return std::max(0, std::min(65535, value));
+}
+
+std::string Lower(std::string value) {
+ std::transform(value.begin(), value.end(), value.begin(), [](unsigned char ch) {
+ return static_cast(std::tolower(ch));
+ });
+ return value;
+}
+
+struct DeviceState {
+ int fd{-1};
+ std::string path;
+ std::string name;
+ int relDx{0};
+ int relDy{0};
+ int wheel{0};
+ bool hasAbsX{false};
+ bool hasAbsY{false};
+ bool hasTouchState{false};
+ bool touchActive{true};
+ int absXMin{0};
+ int absXMax{0};
+ int absYMin{0};
+ int absYMax{0};
+ std::optional pendingAbsX;
+ std::optional pendingAbsY;
+ std::optional lastAbsX;
+ std::optional lastAbsY;
+};
+
+struct PointerDeviceCandidate {
+ std::string path;
+ std::string name;
+};
+
+std::vector DiscoverPointerEventDevices() {
+ std::ifstream input("/proc/bus/input/devices");
+ std::vector devices;
+ std::string line;
+ std::string name;
+ std::string handlers;
+
+ auto flush = [&]() {
+ const std::string lowerName = Lower(name);
+ const bool pointer =
+ lowerName.find("mouse") != std::string::npos ||
+ lowerName.find("touchpad") != std::string::npos ||
+ lowerName.find("trackpad") != std::string::npos ||
+ lowerName.find("pointer") != std::string::npos;
+ const bool virtualInputFlow = lowerName.find("inputflow virtual") != std::string::npos;
+ if (pointer && !virtualInputFlow) {
+ std::istringstream stream(handlers);
+ std::string token;
+ while (stream >> token) {
+ if (token.rfind("event", 0) == 0) {
+ devices.push_back(PointerDeviceCandidate{"/dev/input/" + token, name});
+ }
+ }
+ }
+ name.clear();
+ handlers.clear();
+ };
+
+ while (std::getline(input, line)) {
+ if (line.empty()) {
+ flush();
+ continue;
+ }
+ if (line.rfind("N: Name=", 0) == 0) {
+ name = line.substr(8);
+ } else if (line.rfind("H: Handlers=", 0) == 0) {
+ handlers = line.substr(12);
+ }
+ }
+ flush();
+ return devices;
+}
+
+void LoadAbsInfo(DeviceState& device) {
+ input_absinfo info{};
+ if (ioctl(device.fd, EVIOCGABS(ABS_X), &info) == 0 && info.maximum > info.minimum) {
+ device.hasAbsX = true;
+ device.absXMin = info.minimum;
+ device.absXMax = info.maximum;
+ }
+ if (ioctl(device.fd, EVIOCGABS(ABS_Y), &info) == 0 && info.maximum > info.minimum) {
+ device.hasAbsY = true;
+ device.absYMin = info.minimum;
+ device.absYMax = info.maximum;
+ }
+}
+
+std::vector OpenPointerDevices() {
+ std::vector devices;
+ for (const auto& candidate : DiscoverPointerEventDevices()) {
+ const int fd = open(candidate.path.c_str(), O_RDONLY | O_NONBLOCK | O_CLOEXEC);
+ if (fd < 0) {
+ continue;
+ }
+ DeviceState device;
+ device.fd = fd;
+ device.path = candidate.path;
+ device.name = candidate.name;
+ LoadAbsInfo(device);
+ devices.push_back(std::move(device));
+ }
+ return devices;
+}
+
+void ClosePointerDevices(std::vector& devices) {
+ for (auto& device : devices) {
+ if (device.fd >= 0) {
+ close(device.fd);
+ device.fd = -1;
+ }
+ }
+}
+
+bool MovementMatchesEdge(EdgeDirection edge, int dx, int dy) {
+ switch (edge) {
+ case EdgeDirection::Left:
+ return dx < 0;
+ case EdgeDirection::Right:
+ return dx > 0;
+ case EdgeDirection::Up:
+ return dy < 0;
+ case EdgeDirection::Down:
+ return dy > 0;
+ }
+ return false;
+}
+
+std::optional DominantMovementEdge(int dx, int dy) {
+ if (std::abs(dx) >= std::abs(dy) && dx != 0) {
+ return dx < 0 ? EdgeDirection::Left : EdgeDirection::Right;
+ }
+ if (dy != 0) {
+ return dy < 0 ? EdgeDirection::Up : EdgeDirection::Down;
+ }
+ return std::nullopt;
+}
+
+bool MovementReturnsFromEdge(EdgeDirection entryEdge, int x, int y, int dx, int dy) {
+ switch (entryEdge) {
+ case EdgeDirection::Left:
+ return x <= 0 && dx < 0;
+ case EdgeDirection::Right:
+ return x >= 65535 && dx > 0;
+ case EdgeDirection::Up:
+ return y <= 0 && dy < 0;
+ case EdgeDirection::Down:
+ return y >= 65535 && dy > 0;
+ }
+ return false;
+}
+
+} // namespace
+
+LocalAndroidInputBridge::LocalAndroidInputBridge(LocalAndroidInputBridgeOptions options)
+ : m_options(std::move(options)) {}
+
+LocalAndroidInputBridge::~LocalAndroidInputBridge() {
+ Stop();
+}
+
+bool LocalAndroidInputBridge::Start() {
+ if (m_running.exchange(true)) {
+ return true;
+ }
+ m_thread = std::thread([this]() {
+ Run();
+ });
+ return true;
+}
+
+void LocalAndroidInputBridge::Stop() {
+ if (!m_running.exchange(false)) {
+ return;
+ }
+ if (m_thread.joinable()) {
+ m_thread.join();
+ }
+}
+
+void LocalAndroidInputBridge::Run() {
+ ::Display* display = XOpenDisplay(nullptr);
+ if (display == nullptr) {
+ std::cerr << "WARN: Local Android input bridge could not open X display; Fedora-to-Android edge capture disabled." << std::endl;
+ m_running = false;
+ return;
+ }
+
+ auto devices = OpenPointerDevices();
+ if (devices.empty()) {
+ std::cerr << "WARN: Local Android input bridge found no readable pointer devices; Fedora-to-Android edge capture disabled." << std::endl;
+ XCloseDisplay(display);
+ m_running = false;
+ return;
+ }
+
+ std::vector polls;
+ polls.reserve(devices.size());
+ for (const auto& device : devices) {
+ polls.push_back(pollfd{device.fd, POLLIN, 0});
+ }
+
+ std::cout << "[ANDROID] Local Fedora pointer bridge watching " << devices.size()
+ << " device(s); push through configured Android edge to control Android." << std::endl;
+
+ bool active = false;
+ EdgeDirection activeEntryEdge = EdgeDirection::Left;
+ int androidX = 0;
+ int androidY = 0;
+
+ int localX = m_options.desktopWidth / 2;
+ int localY = m_options.desktopHeight / 2;
+ {
+ Window rootReturn{};
+ Window childReturn{};
+ int rootX = 0;
+ int rootY = 0;
+ int windowX = 0;
+ int windowY = 0;
+ unsigned int mask = 0;
+ if (XQueryPointer(display,
+ DefaultRootWindow(display),
+ &rootReturn,
+ &childReturn,
+ &rootX,
+ &rootY,
+ &windowX,
+ &windowY,
+ &mask)) {
+ localX = std::max(0, std::min(m_options.desktopWidth - 1, rootX));
+ localY = std::max(0, std::min(m_options.desktopHeight - 1, rootY));
+ }
+ }
+
+ auto queryTransition = [&](EdgeDirection pushedEdge) -> std::optional {
+ constexpr int edgeSlopPixels = 64;
+ for (const auto& displayInfo : m_options.topology->displays()) {
+ if (displayInfo.machineId != m_options.localMachineName) {
+ continue;
+ }
+
+ bool atEdge = false;
+ int coordinate = 0;
+ switch (pushedEdge) {
+ case EdgeDirection::Left:
+ atEdge = localX <= displayInfo.x + edgeSlopPixels &&
+ localY >= displayInfo.y &&
+ localY < displayInfo.y + displayInfo.height;
+ coordinate = localY - displayInfo.y;
+ break;
+ case EdgeDirection::Right:
+ atEdge = localX >= displayInfo.x + displayInfo.width - 1 - edgeSlopPixels &&
+ localY >= displayInfo.y &&
+ localY < displayInfo.y + displayInfo.height;
+ coordinate = localY - displayInfo.y;
+ break;
+ case EdgeDirection::Up:
+ atEdge = localY <= displayInfo.y + edgeSlopPixels &&
+ localX >= displayInfo.x &&
+ localX < displayInfo.x + displayInfo.width;
+ coordinate = localX - displayInfo.x;
+ break;
+ case EdgeDirection::Down:
+ atEdge = localY >= displayInfo.y + displayInfo.height - 1 - edgeSlopPixels &&
+ localX >= displayInfo.x &&
+ localX < displayInfo.x + displayInfo.width;
+ coordinate = localX - displayInfo.x;
+ break;
+ }
+
+ if (!atEdge) {
+ continue;
+ }
+
+ const auto transition = m_options.topology->transitionFromEdge(displayInfo.id, pushedEdge, coordinate);
+ if (!transition.has_value()) {
+ continue;
+ }
+ return TopologyPointerTransition{
+ displayInfo.id,
+ pushedEdge,
+ transition->targetDisplayId,
+ transition->entryEdge,
+ transition->coordinate,
+ };
+ }
+ return std::nullopt;
+ };
+
+ auto queryConfiguredTransition = [&](EdgeDirection pushedEdge) -> std::optional {
+ for (const auto& displayInfo : m_options.topology->displays()) {
+ if (displayInfo.machineId != m_options.localMachineName) {
+ continue;
+ }
+
+ int coordinate = 0;
+ switch (pushedEdge) {
+ case EdgeDirection::Left:
+ case EdgeDirection::Right:
+ coordinate = std::max(0, std::min(displayInfo.height - 1, localY - displayInfo.y));
+ break;
+ case EdgeDirection::Up:
+ case EdgeDirection::Down:
+ coordinate = std::max(0, std::min(displayInfo.width - 1, localX - displayInfo.x));
+ break;
+ }
+
+ const auto transition = m_options.topology->transitionFromEdge(displayInfo.id, pushedEdge, coordinate);
+ if (!transition.has_value()) {
+ continue;
+ }
+ return TopologyPointerTransition{
+ displayInfo.id,
+ pushedEdge,
+ transition->targetDisplayId,
+ transition->entryEdge,
+ transition->coordinate,
+ };
+ }
+ return std::nullopt;
+ };
+
+ auto sendMove = [&]() {
+ MouseData mouse{androidX, androidY, 0, WM_MOUSEMOVE};
+ return m_options.sendMouse && m_options.sendMouse(mouse);
+ };
+
+ std::optional pressureEdge;
+ int edgePressure = 0;
+
+ auto processMotion = [&](std::size_t deviceIndex, int normalizedDx, int normalizedDy) {
+ if (normalizedDx == 0 && normalizedDy == 0) {
+ return;
+ }
+
+ if (!active) {
+ localX = std::max(0, std::min(m_options.desktopWidth - 1,
+ localX + static_cast((static_cast(normalizedDx) * m_options.desktopWidth) / 65535)));
+ localY = std::max(0, std::min(m_options.desktopHeight - 1,
+ localY + static_cast((static_cast(normalizedDy) * m_options.desktopHeight) / 65535)));
+ const auto pushedEdge = DominantMovementEdge(normalizedDx, normalizedDy);
+ if (!pushedEdge.has_value()) {
+ return;
+ }
+
+ const int pressureDelta = std::max(std::abs(normalizedDx), std::abs(normalizedDy));
+ if (pressureEdge.has_value() && *pressureEdge == *pushedEdge) {
+ edgePressure = std::min(30000, edgePressure + pressureDelta);
+ } else {
+ pressureEdge = pushedEdge;
+ edgePressure = pressureDelta;
+ }
+
+ auto transition = queryTransition(*pushedEdge);
+ if (!transition.has_value() && edgePressure >= 4500) {
+ transition = queryConfiguredTransition(*pushedEdge);
+ }
+ if (!transition.has_value() || !MovementMatchesEdge(transition->exitEdge, normalizedDx, normalizedDy)) {
+ return;
+ }
+ const auto targetPoint = MapTransitionToTargetNormalizedPoint(*m_options.topology, *transition);
+ const auto targetMachineId = m_options.topology->machineIdForDisplay(transition->targetDisplayId);
+ if (!targetPoint.has_value() ||
+ !targetMachineId.has_value() ||
+ *targetMachineId != m_options.androidPeerName) {
+ return;
+ }
+ active = true;
+ edgePressure = 0;
+ activeEntryEdge = transition->entryEdge;
+ androidX = targetPoint->x;
+ androidY = targetPoint->y;
+ std::cout << "[ANDROID] Local pointer entered Android from "
+ << edgeDirectionName(transition->exitEdge)
+ << " edge using " << devices[deviceIndex].name
+ << " (" << devices[deviceIndex].path << ")." << std::endl;
+ }
+
+ androidX = ClampNormalized(androidX + normalizedDx);
+ androidY = ClampNormalized(androidY + normalizedDy);
+ if (!sendMove()) {
+ active = false;
+ return;
+ }
+ if (MovementReturnsFromEdge(activeEntryEdge, androidX, androidY, normalizedDx, normalizedDy)) {
+ active = false;
+ std::cout << "[ANDROID] Local pointer returned to Fedora." << std::endl;
+ }
+ };
+
+ auto sendButton = [&](std::size_t deviceIndex, uint32_t down, uint32_t up, int value) {
+ (void)deviceIndex;
+ if (!active || value == 2) {
+ return;
+ }
+ MouseData mouse{androidX, androidY, 0, value ? down : up};
+ if (m_options.sendMouse) {
+ m_options.sendMouse(mouse);
+ }
+ };
+
+ while (m_running) {
+ const int rc = poll(polls.data(), polls.size(), 100);
+ if (rc < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ break;
+ }
+ if (rc == 0) {
+ continue;
+ }
+
+ for (std::size_t index = 0; index < devices.size(); ++index) {
+ if ((polls[index].revents & POLLIN) == 0) {
+ continue;
+ }
+
+ input_event event{};
+ while (read(devices[index].fd, &event, sizeof(event)) == sizeof(event)) {
+ auto& device = devices[index];
+ if (event.type == EV_REL) {
+ if (event.code == REL_X) {
+ device.relDx += event.value;
+ } else if (event.code == REL_Y) {
+ device.relDy += event.value;
+ } else if (event.code == REL_WHEEL) {
+ device.wheel += event.value;
+ }
+ } else if (event.type == EV_ABS) {
+ if (event.code == ABS_X && device.hasAbsX) {
+ device.pendingAbsX = event.value;
+ } else if (event.code == ABS_Y && device.hasAbsY) {
+ device.pendingAbsY = event.value;
+ }
+ } else if (event.type == EV_KEY) {
+ if (event.code == BTN_TOUCH || event.code == BTN_TOOL_FINGER) {
+ device.hasTouchState = true;
+ device.touchActive = event.value != 0;
+ if (!device.touchActive) {
+ device.lastAbsX.reset();
+ device.lastAbsY.reset();
+ device.pendingAbsX.reset();
+ device.pendingAbsY.reset();
+ }
+ } else if (event.code == BTN_LEFT) {
+ sendButton(index, WM_LBUTTONDOWN, WM_LBUTTONUP, event.value);
+ } else if (event.code == BTN_RIGHT) {
+ sendButton(index, WM_RBUTTONDOWN, WM_RBUTTONUP, event.value);
+ } else if (event.code == BTN_MIDDLE) {
+ sendButton(index, WM_MBUTTONDOWN, WM_MBUTTONUP, event.value);
+ }
+ } else if (event.type == EV_SYN && event.code == SYN_REPORT) {
+ int normalizedDx = 0;
+ int normalizedDy = 0;
+ if (device.relDx != 0) {
+ normalizedDx += static_cast((static_cast(device.relDx) * 65535) / std::max(1, m_options.desktopWidth));
+ }
+ if (device.relDy != 0) {
+ normalizedDy += static_cast((static_cast(device.relDy) * 65535) / std::max(1, m_options.desktopHeight));
+ }
+ if ((!device.hasTouchState || device.touchActive) && device.pendingAbsX.has_value()) {
+ if (device.lastAbsX.has_value() && device.absXMax > device.absXMin) {
+ normalizedDx += static_cast((static_cast(*device.pendingAbsX - *device.lastAbsX) * 65535) /
+ (device.absXMax - device.absXMin));
+ }
+ device.lastAbsX = *device.pendingAbsX;
+ }
+ if ((!device.hasTouchState || device.touchActive) && device.pendingAbsY.has_value()) {
+ if (device.lastAbsY.has_value() && device.absYMax > device.absYMin) {
+ normalizedDy += static_cast((static_cast(*device.pendingAbsY - *device.lastAbsY) * 65535) /
+ (device.absYMax - device.absYMin));
+ }
+ device.lastAbsY = *device.pendingAbsY;
+ }
+ processMotion(index, normalizedDx, normalizedDy);
+ if (active && device.wheel != 0 && m_options.sendMouse) {
+ MouseData wheel{androidX, androidY, device.wheel * 120, WM_MOUSEWHEEL};
+ m_options.sendMouse(wheel);
+ }
+ device.relDx = 0;
+ device.relDy = 0;
+ device.wheel = 0;
+ }
+ }
+ }
+ }
+
+ ClosePointerDevices(devices);
+ XCloseDisplay(display);
+}
+
+} // namespace mwb
diff --git a/src/LocalAndroidInputBridge.h b/src/LocalAndroidInputBridge.h
new file mode 100644
index 0000000..831a185
--- /dev/null
+++ b/src/LocalAndroidInputBridge.h
@@ -0,0 +1,39 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+
+#include "Protocol.h"
+#include "TopologyModel.h"
+
+namespace mwb {
+
+struct LocalAndroidInputBridgeOptions {
+ std::shared_ptr topology;
+ std::string localMachineName;
+ std::string androidPeerName;
+ int desktopWidth{0};
+ int desktopHeight{0};
+ std::function sendMouse;
+};
+
+class LocalAndroidInputBridge {
+public:
+ explicit LocalAndroidInputBridge(LocalAndroidInputBridgeOptions options);
+ ~LocalAndroidInputBridge();
+
+ bool Start();
+ void Stop();
+
+private:
+ void Run();
+
+ LocalAndroidInputBridgeOptions m_options;
+ std::atomic m_running{false};
+ std::thread m_thread;
+};
+
+} // namespace mwb
diff --git a/src/MonitorLayoutWidget.cpp b/src/MonitorLayoutWidget.cpp
new file mode 100644
index 0000000..8fc8dbc
--- /dev/null
+++ b/src/MonitorLayoutWidget.cpp
@@ -0,0 +1,313 @@
+#include "MonitorLayoutWidget.h"
+#include "AppConfig.h"
+#include "TopologyModel.h"
+
+#include
+#include
+#include
+#include
+
+namespace mwb {
+
+namespace {
+
+constexpr double kGridStep = 48.0;
+constexpr double kPad = 8.0;
+constexpr double kRadius = 6.0;
+constexpr double kLocalR = 0.10, kLocalG = 0.40, kLocalB = 0.75; // blue
+constexpr double kRemoteR = 0.23, kRemoteG = 0.30, kRemoteB = 0.38; // slate
+
+void RoundedRect(cairo_t* cr, double x, double y, double w, double h, double r) {
+ cairo_new_sub_path(cr);
+ cairo_arc(cr, x + w - r, y + r, r, -G_PI / 2, 0);
+ cairo_arc(cr, x + w - r, y + h - r, r, 0, G_PI / 2);
+ cairo_arc(cr, x + r, y + h - r, r, G_PI / 2, G_PI);
+ cairo_arc(cr, x + r, y + r, r, G_PI, 3 * G_PI / 2);
+ cairo_close_path(cr);
+}
+
+gboolean OnDraw(GtkWidget* widget, cairo_t* cr, gpointer data) {
+ auto* mlw = static_cast(data);
+ int W = gtk_widget_get_allocated_width(widget);
+ int H = gtk_widget_get_allocated_height(widget);
+
+ // Background
+ cairo_set_source_rgb(cr, 0.039, 0.086, 0.157); // #0A1628
+ cairo_paint(cr);
+
+ // Grid lines
+ cairo_set_source_rgba(cr, 0.102, 0.157, 0.251, 0.6); // #1A2840
+ cairo_set_line_width(cr, 1.0);
+ for (double x = 0; x < W; x += kGridStep) {
+ cairo_move_to(cr, x, 0); cairo_line_to(cr, x, H);
+ }
+ for (double y = 0; y < H; y += kGridStep) {
+ cairo_move_to(cr, 0, y); cairo_line_to(cr, W, y);
+ }
+ cairo_stroke(cr);
+
+ // Machines
+ for (const auto& m : mlw->machines) {
+ if (m.isLocal) {
+ cairo_set_source_rgb(cr, kLocalR, kLocalG, kLocalB);
+ } else {
+ cairo_set_source_rgb(cr, kRemoteR, kRemoteG, kRemoteB);
+ }
+ RoundedRect(cr, m.x, m.y, m.w, m.h, kRadius);
+ cairo_fill(cr);
+
+ // Border
+ cairo_set_source_rgba(cr, 1, 1, 1, 0.15);
+ cairo_set_line_width(cr, 1.0);
+ RoundedRect(cr, m.x, m.y, m.w, m.h, kRadius);
+ cairo_stroke(cr);
+
+ // Label
+ cairo_set_source_rgb(cr, 1, 1, 1);
+ cairo_select_font_face(cr, "Sans", CAIRO_FONT_SLANT_NORMAL, CAIRO_FONT_WEIGHT_BOLD);
+ cairo_set_font_size(cr, 12.0);
+ cairo_text_extents_t te;
+ cairo_text_extents(cr, m.label.c_str(), &te);
+ cairo_move_to(cr,
+ m.x + (m.w - te.width) / 2 - te.x_bearing,
+ m.y + m.h / 2 - te.height / 2 - te.y_bearing);
+ cairo_show_text(cr, m.label.c_str());
+
+ if (m.isLocal) {
+ cairo_set_font_size(cr, 9.0);
+ const char* badge = "THIS DEVICE";
+ cairo_text_extents(cr, badge, &te);
+ cairo_move_to(cr,
+ m.x + (m.w - te.width) / 2 - te.x_bearing,
+ m.y + m.h / 2 + 14);
+ cairo_set_source_rgba(cr, 1, 1, 1, 0.6);
+ cairo_show_text(cr, badge);
+ }
+ }
+
+ return FALSE;
+}
+
+int HitTest(const MonitorLayoutWidget* mlw, double px, double py) {
+ for (int i = static_cast(mlw->machines.size()) - 1; i >= 0; --i) {
+ const auto& m = mlw->machines[i];
+ if (px >= m.x && px <= m.x + m.w && py >= m.y && py <= m.y + m.h) {
+ return i;
+ }
+ }
+ return -1;
+}
+
+gboolean OnButtonPress(GtkWidget*, GdkEventButton* ev, gpointer data) {
+ auto* mlw = static_cast(data);
+ if (ev->button != 1) return FALSE;
+ int idx = HitTest(mlw, ev->x, ev->y);
+ if (idx < 0) return FALSE;
+ mlw->dragIndex = idx;
+ mlw->dragOffX = ev->x - mlw->machines[idx].x;
+ mlw->dragOffY = ev->y - mlw->machines[idx].y;
+ return TRUE;
+}
+
+gboolean OnMotion(GtkWidget* widget, GdkEventMotion* ev, gpointer data) {
+ auto* mlw = static_cast(data);
+ if (mlw->dragIndex < 0) return FALSE;
+ auto& m = mlw->machines[mlw->dragIndex];
+ m.x = ev->x - mlw->dragOffX;
+ m.y = ev->y - mlw->dragOffY;
+ gtk_widget_queue_draw(widget);
+ return TRUE;
+}
+
+gboolean OnButtonRelease(GtkWidget* widget, GdkEventButton* ev, gpointer data) {
+ auto* mlw = static_cast(data);
+ if (ev->button != 1 || mlw->dragIndex < 0) return FALSE;
+ // Snap to grid
+ auto& m = mlw->machines[mlw->dragIndex];
+ m.x = std::round(m.x / kGridStep) * kGridStep;
+ m.y = std::round(m.y / kGridStep) * kGridStep;
+ mlw->dragIndex = -1;
+ gtk_widget_queue_draw(widget);
+ return TRUE;
+}
+
+void PopulateFromConfig(MonitorLayoutWidget* mlw, const AppConfig& cfg) {
+ mlw->machines.clear();
+
+ TopologyModel topo;
+ if (!cfg.topologyFile.empty()) {
+ std::string err;
+ (void)LoadTopologyConfig(cfg.topologyFile, topo, &err);
+ }
+
+ const auto& machines = topo.machines();
+ const auto& displays = topo.displays();
+
+ if (machines.empty()) {
+ // No topology — add two placeholder machines
+ LayoutMachine local;
+ local.id = "linux";
+ local.label = cfg.machineName.empty() ? "This PC" : cfg.machineName;
+ local.isLocal = true;
+ local.x = kGridStep; local.y = kGridStep;
+ local.w = 192; local.h = 120;
+ mlw->machines.push_back(local);
+
+ if (cfg.androidPeersEnabled) {
+ LayoutMachine android;
+ android.id = "android";
+ android.label = cfg.androidPeerName.empty() ? "Android" : cfg.androidPeerName;
+ android.x = kGridStep + 192 + kGridStep;
+ android.y = kGridStep;
+ android.w = 128; android.h = 120;
+ mlw->machines.push_back(android);
+ }
+ return;
+ }
+
+ // Map machine ID → first display for position
+ double offsetX = kGridStep;
+ for (const auto& mach : machines) {
+ LayoutMachine lm;
+ lm.id = mach.id;
+ lm.label = mach.id;
+
+ // Sum display widths/heights for bounding box
+ int maxW = 0, maxH = 0;
+ int baseX = 0, baseY = 0;
+ bool first = true;
+ for (const auto& disp : displays) {
+ if (disp.machineId != mach.id) continue;
+ if (first) { baseX = disp.x; baseY = disp.y; first = false; }
+ maxW = std::max(maxW, disp.x - baseX + disp.width);
+ maxH = std::max(maxH, disp.y - baseY + disp.height);
+ }
+
+ const double scale = 0.1;
+ lm.w = std::max(96.0, maxW * scale);
+ lm.h = std::max(60.0, maxH * scale);
+ lm.x = offsetX;
+ lm.y = kGridStep;
+ lm.isLocal = (mach.id == "linux" || (!cfg.machineName.empty() && mach.id == cfg.machineName));
+ offsetX += lm.w + kGridStep;
+ mlw->machines.push_back(lm);
+ }
+}
+
+// Build a minimal topology INI string from current machine positions
+std::string BuildTopologyIni(const MonitorLayoutWidget* mlw, const AppConfig& cfg) {
+ std::ostringstream oss;
+ oss << "[wrap]\nmode=none\n\n";
+
+ for (const auto& m : mlw->machines) {
+ oss << "[machine]\nid=" << m.id << "\n\n";
+ }
+
+ // Compute pixel coordinates (scale canvas back to screen space)
+ // Use a nominal 1920 width for the local machine
+ const double scale = 10.0; // 0.1 scale used in PopulateFromConfig
+ for (const auto& m : mlw->machines) {
+ const int px = static_cast(m.x * scale);
+ const int py = static_cast(m.y * scale);
+ const int pw = m.isLocal ? 1920 : cfg.androidDeviceWidth;
+ const int ph = m.isLocal ? 1080 : cfg.androidDeviceHeight;
+ oss << "[display]\n"
+ << "id=" << m.id << "-1\n"
+ << "machine=" << m.id << "\n"
+ << "x=" << px << "\ny=" << py << "\n"
+ << "width=" << pw << "\nheight=" << ph << "\n\n";
+ }
+
+ // Add links between adjacent machines (sharing an edge within tolerance)
+ const double tol = kGridStep * 2;
+ for (std::size_t i = 0; i < mlw->machines.size(); ++i) {
+ for (std::size_t j = i + 1; j < mlw->machines.size(); ++j) {
+ const auto& a = mlw->machines[i];
+ const auto& b = mlw->machines[j];
+ // b is to the right of a
+ if (std::abs((a.x + a.w) - b.x) < tol &&
+ std::abs(a.y - b.y) < tol) {
+ oss << "[link]\nsource=" << a.id << "-1\nexit=right\n"
+ << "target=" << b.id << "-1\nentry=left\n\n";
+ oss << "[link]\nsource=" << b.id << "-1\nexit=left\n"
+ << "target=" << a.id << "-1\nentry=right\n\n";
+ }
+ // b is to the left of a
+ if (std::abs((b.x + b.w) - a.x) < tol &&
+ std::abs(a.y - b.y) < tol) {
+ oss << "[link]\nsource=" << b.id << "-1\nexit=right\n"
+ << "target=" << a.id << "-1\nentry=left\n\n";
+ oss << "[link]\nsource=" << a.id << "-1\nexit=left\n"
+ << "target=" << b.id << "-1\nentry=right\n\n";
+ }
+ // b is below a
+ if (std::abs((a.y + a.h) - b.y) < tol &&
+ std::abs(a.x - b.x) < tol) {
+ oss << "[link]\nsource=" << a.id << "-1\nexit=down\n"
+ << "target=" << b.id << "-1\nentry=up\n\n";
+ oss << "[link]\nsource=" << b.id << "-1\nexit=up\n"
+ << "target=" << a.id << "-1\nentry=down\n\n";
+ }
+ }
+ }
+
+ return oss.str();
+}
+
+} // namespace
+
+MonitorLayoutWidget* CreateMonitorLayoutWidget(const AppConfig& cfg) {
+ auto* mlw = new MonitorLayoutWidget();
+ mlw->configPath = cfg.topologyFile;
+
+ PopulateFromConfig(mlw, cfg);
+
+ mlw->widget = gtk_drawing_area_new();
+ gtk_widget_add_events(mlw->widget,
+ GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK | GDK_POINTER_MOTION_MASK);
+
+ g_signal_connect(mlw->widget, "draw", G_CALLBACK(OnDraw), mlw);
+ g_signal_connect(mlw->widget, "button-press-event", G_CALLBACK(OnButtonPress), mlw);
+ g_signal_connect(mlw->widget, "button-release-event", G_CALLBACK(OnButtonRelease), mlw);
+ g_signal_connect(mlw->widget, "motion-notify-event", G_CALLBACK(OnMotion), mlw);
+
+ // Attach mlw lifetime to widget so it's freed when widget is destroyed
+ g_object_set_data_full(G_OBJECT(mlw->widget), "mlw", mlw,
+ [](gpointer p) { delete static_cast(p); });
+
+ return mlw;
+}
+
+void MonitorLayoutWidgetApply(GtkButton*, gpointer data) {
+ auto* mlw = static_cast(data);
+ if (mlw->configPath.empty()) return;
+
+ // Load current config to preserve other settings
+ AppConfig cfg;
+ std::string err;
+ (void)LoadAppConfig(mlw->configPath, cfg, &err);
+
+ const std::string ini = BuildTopologyIni(mlw, cfg);
+
+ // Write to topology file (use config path parent / topology.ini if not set)
+ std::string topoPath = cfg.topologyFile;
+ if (topoPath.empty()) {
+ topoPath = std::filesystem::path(mlw->configPath).parent_path() / "topology.ini";
+ cfg.topologyFile = topoPath;
+ (void)WriteAppConfig(mlw->configPath, cfg, &err);
+ }
+
+ std::ofstream f(topoPath);
+ if (f) f << ini;
+}
+
+void MonitorLayoutWidgetReset(GtkButton*, gpointer data) {
+ auto* mlw = static_cast(data);
+ AppConfig cfg;
+ std::string err;
+ (void)LoadAppConfig(mlw->configPath, cfg, &err);
+ PopulateFromConfig(mlw, cfg);
+ gtk_widget_queue_draw(mlw->widget);
+}
+
+} // namespace mwb
diff --git a/src/MonitorLayoutWidget.h b/src/MonitorLayoutWidget.h
new file mode 100644
index 0000000..324d4de
--- /dev/null
+++ b/src/MonitorLayoutWidget.h
@@ -0,0 +1,37 @@
+#pragma once
+
+#include
+#include
+#include
+
+namespace mwb {
+
+struct AppConfig;
+
+struct LayoutMachine {
+ std::string id;
+ std::string label;
+ bool isLocal{false};
+ // position in canvas pixels (mutable, drag target)
+ double x{0}, y{0};
+ double w{160}, h{100};
+};
+
+struct MonitorLayoutWidget {
+ GtkWidget* widget{nullptr}; // GtkDrawingArea
+ std::vector machines;
+ std::string configPath;
+ int dragIndex{-1};
+ double dragOffX{0}, dragOffY{0};
+};
+
+// Creates a MonitorLayoutWidget populated from cfg.topologyFile (if set).
+// Returns heap-allocated; caller owns it (kept alive by GtkWidget lifetime via
+// g_object_set_data).
+MonitorLayoutWidget* CreateMonitorLayoutWidget(const AppConfig& cfg);
+
+// Callbacks wired to GTK buttons (signature matches GCallback).
+void MonitorLayoutWidgetApply(GtkButton* btn, gpointer data);
+void MonitorLayoutWidgetReset(GtkButton* btn, gpointer data);
+
+} // namespace mwb
diff --git a/src/TrayController.cpp b/src/TrayController.cpp
index 5615158..d40415f 100644
--- a/src/TrayController.cpp
+++ b/src/TrayController.cpp
@@ -2,6 +2,7 @@
#include
#include
+#include
#include
#include