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 #include @@ -12,9 +13,18 @@ #include #include #include +#include #include #include +#ifdef MWB_HAVE_GTK_GUI +#include "TrayController.h" +#include "GuiMainWindow.h" +#include "AppConfig.h" +#include "AppState.h" +#include "ClientRuntime.h" +#endif + namespace { constexpr const char* kAppName = "InputFlow"; @@ -46,6 +56,10 @@ struct TrayContext { std::string controllerPath; std::string iconThemePath; std::string lastState; +#ifdef MWB_HAVE_GTK_GUI + mwb::GuiMainWindow* mainWindow{nullptr}; + std::atomic* stopFlag{nullptr}; +#endif }; std::optional RunCommandCapture(const std::string& command) { @@ -393,7 +407,11 @@ void UpdateIndicatorVisuals(TrayContext* context, const std::string& state) { gtk_widget_set_sensitive(context->stopItem, active || starting); gtk_widget_set_sensitive(context->restartItem, active); +#ifdef MWB_HAVE_GTK_GUI + const bool controllerAvailable = (context->mainWindow != nullptr); +#else const bool controllerAvailable = !context->controllerPath.empty(); +#endif gtk_widget_set_sensitive(context->editSettingsItem, controllerAvailable); gtk_widget_set_sensitive(context->editConnectionItem, controllerAvailable); gtk_widget_set_sensitive(context->healthCheckItem, controllerAvailable); @@ -472,46 +490,73 @@ gboolean RefreshStatus(gpointer userData) { void OnOpenController(GtkMenuItem*, gpointer userData) { auto* context = static_cast(userData); +#ifdef MWB_HAVE_GTK_GUI + if (context->mainWindow) { context->mainWindow->ShowTab(0); return; } +#endif (void)LaunchController(context); } void OnEditSettings(GtkMenuItem*, gpointer userData) { auto* context = static_cast(userData); +#ifdef MWB_HAVE_GTK_GUI + if (context->mainWindow) { context->mainWindow->ShowTab(1); return; } +#endif (void)LaunchController(context, {"settings"}); } void OnEditConnectionBehavior(GtkMenuItem*, gpointer userData) { auto* context = static_cast(userData); +#ifdef MWB_HAVE_GTK_GUI + if (context->mainWindow) { context->mainWindow->ShowTab(1); return; } +#endif (void)LaunchController(context, {"connection"}); } void OnHealthCheck(GtkMenuItem*, gpointer userData) { auto* context = static_cast(userData); +#ifdef MWB_HAVE_GTK_GUI + if (context->mainWindow) { context->mainWindow->ShowTab(0); return; } +#endif (void)LaunchController(context, {"health-check"}); } void OnDiagnosticsBundle(GtkMenuItem*, gpointer userData) { auto* context = static_cast(userData); +#ifdef MWB_HAVE_GTK_GUI + if (context->mainWindow) { context->mainWindow->ShowTab(0); return; } +#endif (void)LaunchController(context, {"diagnostics-bundle"}); } void OnConnectionQuality(GtkMenuItem*, gpointer userData) { auto* context = static_cast(userData); +#ifdef MWB_HAVE_GTK_GUI + if (context->mainWindow) { context->mainWindow->ShowTab(0); return; } +#endif (void)LaunchController(context, {"connection-quality"}); } void OnGuidedPairing(GtkMenuItem*, gpointer userData) { auto* context = static_cast(userData); +#ifdef MWB_HAVE_GTK_GUI + if (context->mainWindow) { context->mainWindow->ShowTab(1); return; } +#endif (void)LaunchController(context, {"guided-pairing"}); } void OnDiscoverPeers(GtkMenuItem*, gpointer userData) { auto* context = static_cast(userData); +#ifdef MWB_HAVE_GTK_GUI + if (context->mainWindow) { context->mainWindow->ShowTab(0); return; } +#endif (void)LaunchController(context, {"discover"}); } void OnShowPeers(GtkMenuItem*, gpointer userData) { auto* context = static_cast(userData); +#ifdef MWB_HAVE_GTK_GUI + if (context->mainWindow) { context->mainWindow->ShowTab(0); return; } +#endif (void)LaunchController(context, {"peers"}); } @@ -532,6 +577,9 @@ void OnRestartService(GtkMenuItem*, gpointer userData) { void OnShowStatus(GtkMenuItem*, gpointer userData) { auto* context = static_cast(userData); +#ifdef MWB_HAVE_GTK_GUI + if (context->mainWindow) { context->mainWindow->ShowTab(0); return; } +#endif (void)LaunchController(context, {"status"}); } @@ -542,10 +590,26 @@ void OnShowTrayHelp(GtkMenuItem*, gpointer userData) { void OnInstallDesktopEntries(GtkMenuItem*, gpointer userData) { auto* context = static_cast(userData); +#ifdef MWB_HAVE_GTK_GUI + if (context->mainWindow) { context->mainWindow->ShowTab(1); return; } +#endif (void)LaunchController(context, {"install-desktop-entry"}); } -void OnQuit(GtkMenuItem*, gpointer) { +void OnOpenMonitorLayout(GtkMenuItem*, gpointer userData) { + auto* context = static_cast(userData); +#ifdef MWB_HAVE_GTK_GUI + if (context->mainWindow) { context->mainWindow->ShowTab(2); return; } +#endif +} + +void OnQuit(GtkMenuItem*, gpointer userData) { +#ifdef MWB_HAVE_GTK_GUI + auto* context = static_cast(userData); + if (context->stopFlag) context->stopFlag->store(true); +#else + (void)userData; +#endif gtk_main_quit(); } @@ -558,46 +622,41 @@ GtkWidget* AddMenuItem(GtkWidget* menu, const char* label, GCallback callback, g } // namespace -int main(int argc, char** argv) { - const int instanceLockFd = AcquireSingleInstanceLock(); - if (instanceLockFd == -2) { - return 0; - } - - g_set_prgname(kIndicatorId); - g_set_application_name(kAppName); - - gtk_init(&argc, &argv); +// ---- shared tray-building helper (used by both standalone and embedded) ----- - TrayContext context; - context.controllerPath = ResolveControllerPath(); - context.iconThemePath = ResolveIconThemePath(); +namespace { +void BuildTrayMenu(TrayContext& context) { GtkWidget* menu = gtk_menu_new(); context.statusItem = gtk_menu_item_new_with_label("Service: Checking..."); gtk_widget_set_sensitive(context.statusItem, FALSE); gtk_menu_shell_append(GTK_MENU_SHELL(menu), context.statusItem); gtk_menu_shell_append(GTK_MENU_SHELL(menu), gtk_separator_menu_item_new()); - context.editSettingsItem = AddMenuItem(menu, "Settings", G_CALLBACK(OnEditSettings), &context); - context.editConnectionItem = AddMenuItem(menu, "Connection Behavior", G_CALLBACK(OnEditConnectionBehavior), &context); - context.healthCheckItem = AddMenuItem(menu, "Health Check", G_CALLBACK(OnHealthCheck), &context); - context.diagnosticsBundleItem = AddMenuItem(menu, "Diagnostics Bundle", G_CALLBACK(OnDiagnosticsBundle), &context); - context.connectionQualityItem = AddMenuItem(menu, "Connection Quality", G_CALLBACK(OnConnectionQuality), &context); - context.guidedPairingItem = AddMenuItem(menu, "Guided Pairing", G_CALLBACK(OnGuidedPairing), &context); - context.discoverPeersItem = AddMenuItem(menu, "Discover Peers", G_CALLBACK(OnDiscoverPeers), &context); - context.showPeersItem = AddMenuItem(menu, "Known Peers", G_CALLBACK(OnShowPeers), &context); +#ifdef MWB_HAVE_GTK_GUI + AddMenuItem(menu, "Open Dashboard", G_CALLBACK(OnOpenController), &context); + AddMenuItem(menu, "Monitor Layout", G_CALLBACK(OnOpenMonitorLayout), &context); + gtk_menu_shell_append(GTK_MENU_SHELL(menu), gtk_separator_menu_item_new()); +#endif + + context.editSettingsItem = AddMenuItem(menu, "Settings", G_CALLBACK(OnEditSettings), &context); + context.editConnectionItem = AddMenuItem(menu, "Connection Behavior", G_CALLBACK(OnEditConnectionBehavior),&context); + context.healthCheckItem = AddMenuItem(menu, "Health Check", G_CALLBACK(OnHealthCheck), &context); + context.diagnosticsBundleItem = AddMenuItem(menu, "Diagnostics Bundle", G_CALLBACK(OnDiagnosticsBundle), &context); + context.connectionQualityItem = AddMenuItem(menu, "Connection Quality", G_CALLBACK(OnConnectionQuality), &context); + context.guidedPairingItem = AddMenuItem(menu, "Guided Pairing", G_CALLBACK(OnGuidedPairing), &context); + context.discoverPeersItem = AddMenuItem(menu, "Discover Peers", G_CALLBACK(OnDiscoverPeers), &context); + context.showPeersItem = AddMenuItem(menu, "Known Peers", G_CALLBACK(OnShowPeers), &context); gtk_menu_shell_append(GTK_MENU_SHELL(menu), gtk_separator_menu_item_new()); - context.startItem = AddMenuItem(menu, "Start Service", G_CALLBACK(OnStartService), &context); - context.stopItem = AddMenuItem(menu, "Stop Service", G_CALLBACK(OnStopService), &context); + context.startItem = AddMenuItem(menu, "Start Service", G_CALLBACK(OnStartService), &context); + context.stopItem = AddMenuItem(menu, "Stop Service", G_CALLBACK(OnStopService), &context); context.restartItem = AddMenuItem(menu, "Restart Service", G_CALLBACK(OnRestartService), &context); gtk_menu_shell_append(GTK_MENU_SHELL(menu), gtk_separator_menu_item_new()); - - context.showStatusItem = AddMenuItem(menu, "Show Service Details", G_CALLBACK(OnShowStatus), &context); - context.installDesktopEntriesItem = AddMenuItem(menu, "Install Desktop Entries", G_CALLBACK(OnInstallDesktopEntries), &context); - context.trayHelpItem = AddMenuItem(menu, "Tray Visibility Help", G_CALLBACK(OnShowTrayHelp), &context); + context.showStatusItem = AddMenuItem(menu, "Show Service Details", G_CALLBACK(OnShowStatus), &context); + context.installDesktopEntriesItem = AddMenuItem(menu, "Install Desktop Entries", G_CALLBACK(OnInstallDesktopEntries),&context); + context.trayHelpItem = AddMenuItem(menu, "Tray Visibility Help", G_CALLBACK(OnShowTrayHelp), &context); gtk_menu_shell_append(GTK_MENU_SHELL(menu), gtk_separator_menu_item_new()); AddMenuItem(menu, "Quit", G_CALLBACK(OnQuit), &context); @@ -621,10 +680,186 @@ int main(int argc, char** argv) { UpdateIndicatorVisuals(&context, QueryServiceState()); app_indicator_set_status(context.indicator, APP_INDICATOR_STATUS_ACTIVE); g_timeout_add_seconds(30, RefreshStatus, &context); +} + +} // namespace + +// ---- standalone mwb_tray entry point (no embedded runtime) ----------------- + +#ifndef MWB_HAVE_GTK_GUI +int main(int argc, char** argv) { + const int instanceLockFd = AcquireSingleInstanceLock(); + if (instanceLockFd == -2) { + return 0; + } + + g_set_prgname(kIndicatorId); + g_set_application_name(kAppName); + + gtk_init(&argc, &argv); + + TrayContext context; + context.controllerPath = ResolveControllerPath(); + context.iconThemePath = ResolveIconThemePath(); + + BuildTrayMenu(context); + MaybeShowStartupHint(context); + + gtk_main(); + if (instanceLockFd >= 0) { + close(instanceLockFd); + } + return 0; +} +#endif // !MWB_HAVE_GTK_GUI + +// ---- embedded GUI + runtime entry point (mwb_client gui subcommand) -------- + +#ifdef MWB_HAVE_GTK_GUI +namespace mwb { + +namespace { + +struct StatusUpdate { + TrayContext* tray; + GuiMainWindow* win; + std::string state; + std::string detail; +}; + +gboolean ApplyStatusOnMainThread(gpointer data) { + auto* upd = static_cast(data); + if (upd->win) upd->win->UpdateStatus(upd->state, upd->detail); + if (upd->tray) UpdateIndicatorVisuals(upd->tray, upd->state); + delete upd; + return G_SOURCE_REMOVE; +} + +void PostStatus(TrayContext* tray, GuiMainWindow* win, + const std::string& state, const std::string& detail) { + auto* upd = new StatusUpdate{tray, win, state, detail}; + g_idle_add(ApplyStatusOnMainThread, upd); +} + +} // namespace + +int RunTrayAndGui(const std::string& binary, + const std::vector& args, + const AppConfig& config, + const std::string& configPath, + const std::string& statePath) { + const int instanceLockFd = AcquireSingleInstanceLock(); + if (instanceLockFd == -2) { + std::cerr << "InputFlow GUI is already running." << std::endl; + return 0; + } + + g_set_prgname(kIndicatorId); + g_set_application_name(kAppName); + + // GTK already initialised by caller via gtk_init_check + + // Shared stop flag for daemon thread + std::atomic stopFlag{false}; + + // Build GUI window + GuiMainWindow* mainWin = CreateMainWindow( + config, configPath, + [](const AppConfig&) { /* settings saved; daemon will reload on restart */ }); + + // Build tray + TrayContext context; + context.iconThemePath = ResolveIconThemePath(); + context.mainWindow = mainWin; + context.stopFlag = &stopFlag; + + BuildTrayMenu(context); + + // Seed status + PostStatus(&context, mainWin, QueryServiceState(), ""); + + // Start daemon runtime in background thread + AppConfig runtimeConfig = config; + std::thread daemonThread([&]() { + // Minimal state/option wiring (mirrors RunClient in main.cpp) + AppState state; + (void)EnsureLocalMachineId(state); + + RuntimeOptions options; + options.host = runtimeConfig.host; + options.key = runtimeConfig.key; + options.port = runtimeConfig.port; + options.clipboardEnabled = runtimeConfig.clipboardEnabled; + options.clipboardSendEnabled = runtimeConfig.clipboardSendEnabled; + options.clipboardForcePoll = runtimeConfig.clipboardForcePoll; + options.clipboardPollMs = runtimeConfig.clipboardPollMs; + options.autoConnectEnabled = runtimeConfig.autoConnectEnabled; + options.reconnectInitialBackoffMs = runtimeConfig.reconnectInitialBackoffMs; + options.reconnectMaxBackoffMs = runtimeConfig.reconnectMaxBackoffMs; + options.reconnectIdleRetryMs = runtimeConfig.reconnectIdleRetryMs; + options.screenWidth = runtimeConfig.screenWidth; + options.screenHeight = runtimeConfig.screenHeight; + options.mprisMediaKeysEnabled = runtimeConfig.mprisMediaKeysEnabled; + options.mprisPlayer = runtimeConfig.mprisPlayer; + options.localMachineId = state.localMachineId; + options.localMachineName = runtimeConfig.machineName; + options.latencyReport = runtimeConfig.latencyReport; + options.topologyRuntimeEnabled = runtimeConfig.topologyRuntimeEnabled; + options.topologyFilePath = runtimeConfig.topologyFile; + options.androidCaptureBackend = runtimeConfig.androidCaptureBackend; + options.androidRelay.enabled = runtimeConfig.androidPeersEnabled; + options.androidRelay.port = runtimeConfig.androidRelayPort; + options.androidRelay.secret = runtimeConfig.androidRelaySecret; + options.androidRelay.peerName = runtimeConfig.androidPeerName; + options.androidRelay.layoutEditorEnabled = runtimeConfig.androidLayoutEditorEnabled; + options.androidRelay.androidDeviceWidth = runtimeConfig.androidDeviceWidth; + options.androidRelay.androidDeviceHeight = runtimeConfig.androidDeviceHeight; + + options.onSessionEstablished = [&](const std::string& host, int port, + const std::string& remoteName, uint32_t, uint32_t) { + PostStatus(&context, mainWin, "active", host + ":" + std::to_string(port) + " (" + remoteName + ")"); + if (mainWin) { + auto* logMsg = new std::string("Connected to " + host + ":" + std::to_string(port)); + g_idle_add([](gpointer p) -> gboolean { + auto* msg = static_cast(p); + // mainWin captured by pointer — safe since GTK window outlives runtime + delete msg; + return G_SOURCE_REMOVE; + }, logMsg); + } + }; + options.onSessionDisconnected = [&]() { + PostStatus(&context, mainWin, "inactive", "Disconnected"); + }; + + ClientRuntime runtime(std::move(options)); + + // Stopper thread: when GTK quits it sets stopFlag → we call runtime.Stop() + std::thread stopper([&]() { + while (!stopFlag.load()) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + runtime.Stop(); + }); + + (void)runtime.Run(); + + stopFlag.store(true); + if (stopper.joinable()) stopper.join(); + }); gtk_main(); + + stopFlag.store(true); + if (daemonThread.joinable()) { + daemonThread.join(); + } if (instanceLockFd >= 0) { close(instanceLockFd); } + delete mainWin; return 0; } + +} // namespace mwb +#endif // MWB_HAVE_GTK_GUI diff --git a/src/TrayController.h b/src/TrayController.h new file mode 100644 index 0000000..f2d2258 --- /dev/null +++ b/src/TrayController.h @@ -0,0 +1,18 @@ +#pragma once + +#include +#include + +namespace mwb { + +struct AppConfig; + +// Runs the combined tray + GUI + daemon. GTK must already be initialised +// (gtk_init / gtk_init_check called by caller). Blocks until gtk_main_quit(). +int RunTrayAndGui(const std::string& binary, + const std::vector& args, + const AppConfig& config, + const std::string& configPath, + const std::string& statePath); + +} // namespace mwb diff --git a/src/main.cpp b/src/main.cpp index 53a04ed..21c4331 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -19,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -34,6 +36,11 @@ #include "SecretStore.h" #include "TopologyModel.h" +#ifdef MWB_HAVE_GTK_GUI +#include +#include "TrayController.h" +#endif + namespace { constexpr int kDefaultPort = 15101; @@ -59,12 +66,14 @@ void PrintGeneralUsage(std::ostream& out, const char* argv0) { << " [--latency-report]\n"; out << " " << binary << " discover [--state PATH] [--port PORT] [--timeout-ms MS] [--max-hosts N]\n"; out << " " << binary << " doctor [--config PATH] [--state PATH]\n"; + out << " " << binary << " android-pair [--config PATH]\n"; out << " " << binary << " topology explain [PATH] [--config PATH]\n"; out << " " << binary << " init-config [--config PATH] [--force] [--host IP] [--key KEY | --key-file PATH | --key-secret-id ID] [--name NAME] [--port PORT]\n"; out << " " << binary << " export-windows-pair [--config PATH] [--output PATH] [--force] [--dry-run] [--check] [--linux-ip IP] [--position auto|top-left|top-right|bottom-left|bottom-right] [--key KEY | --key-file PATH | --key-secret-id ID] [--name NAME]\n"; out << " " << binary << " install-user-service [--config PATH] [--unit PATH] [--force]\n"; out << " " << binary << " secret-store [--config PATH] --secret-id ID [--key KEY | --key-file PATH | --stdin]\n"; out << " " << binary << " secret-clear [--config PATH] [--secret-id ID]\n"; + out << " " << binary << " gui [--config PATH] [--state PATH] — start combined tray + GUI (falls back to run if no display)\n"; out << "Connection policy: auto_connect_enabled=true|false, reconnect_initial_backoff_ms, reconnect_max_backoff_ms, reconnect_idle_retry_ms in config\n"; out << "Media keys: mpris_media_keys_enabled=true|false, mpris_player=PLAYER in config\n"; out << "Set latency_report=true, MWB_LATENCY_REPORT=1, or use --latency-report to print client-side input queue/inject timing on shutdown\n"; @@ -573,6 +582,19 @@ std::optional DetectOutboundLocalIpv4(const std::string& host, int return localIp; } +std::string PercentEncode(std::string_view value) { + std::ostringstream out; + out << std::uppercase << std::hex; + for (const unsigned char ch : value) { + if (std::isalnum(ch) != 0 || ch == '-' || ch == '_' || ch == '.' || ch == '~') { + out << static_cast(ch); + continue; + } + out << '%' << std::setw(2) << std::setfill('0') << static_cast(ch); + } + return out.str(); +} + std::optional NormalizePeerPosition(std::string_view value) { std::string normalized; normalized.reserve(value.size()); @@ -1191,6 +1213,14 @@ int RunClient(const mwb::AppConfig& config, options.latencyReport = runtimeConfig.latencyReport; options.topologyRuntimeEnabled = runtimeConfig.topologyRuntimeEnabled; options.topologyFilePath = runtimeConfig.topologyFile; + options.androidCaptureBackend = runtimeConfig.androidCaptureBackend; + options.androidRelay.enabled = runtimeConfig.androidPeersEnabled; + options.androidRelay.port = runtimeConfig.androidRelayPort; + options.androidRelay.secret = runtimeConfig.androidRelaySecret; + options.androidRelay.peerName = runtimeConfig.androidPeerName; + options.androidRelay.layoutEditorEnabled = runtimeConfig.androidLayoutEditorEnabled; + options.androidRelay.androidDeviceWidth = runtimeConfig.androidDeviceWidth; + options.androidRelay.androidDeviceHeight = runtimeConfig.androidDeviceHeight; options.onSessionEstablished = [&](const std::string& host, int port, const std::string& remoteName, uint32_t, uint32_t localMachineId) { std::lock_guard lock(stateMutex); mwb::MarkSessionEstablished(state, host, port, remoteName, localMachineId, CurrentEpochSeconds()); @@ -1672,6 +1702,11 @@ int HandleDoctorCommand(const std::vector& args) { PrintDoctorLine("INFO", "port", std::to_string(config.port)); PrintDoctorLine("INFO", "clipboard", std::string(config.clipboardEnabled ? "enabled" : "disabled") + (config.clipboardSendEnabled ? ", send enabled" : ", receive-only")); + PrintDoctorLine("INFO", "android relay", + std::string(config.androidPeersEnabled ? "enabled" : "disabled") + + " port=" + std::to_string(config.androidRelayPort) + + " peer=" + (config.androidPeerName.empty() ? "" : config.androidPeerName) + + " secret=" + (config.androidRelaySecret.empty() ? "missing" : "configured")); PrintDoctorLine("INFO", "reconnect", "initial=" + std::to_string(config.reconnectInitialBackoffMs) + "ms max=" + std::to_string(config.reconnectMaxBackoffMs) + "ms idle=" + std::to_string(config.reconnectIdleRetryMs) + "ms"); @@ -2625,6 +2660,82 @@ int HandleTopologyCommand(const std::vector& args) { return 0; } +int HandleAndroidPairCommand(const std::vector& args) { + std::filesystem::path configPath = mwb::DefaultConfigPath(); + + for (std::size_t index = 0; index < args.size(); ++index) { + const std::string& arg = args[index]; + if (arg == "--config") { + if (index + 1 >= args.size()) { + std::cerr << "ERR: Missing value for --config." << std::endl; + return 1; + } + configPath = args[++index]; + } else { + std::cerr << "ERR: Unknown android-pair option: " << arg << std::endl; + return 1; + } + } + + mwb::AppConfig config; + std::string error; + if (!mwb::LoadConfigFile(configPath, config, error)) { + std::cerr << "ERR: " << error << std::endl; + return 1; + } + if (!config.androidPeersEnabled) { + std::cerr << "ERR: android_peers_enabled is false in " << configPath << "." << std::endl; + return 1; + } + if (config.androidRelaySecret.empty()) { + std::cerr << "ERR: android_relay_secret is empty in " << configPath << "." << std::endl; + return 1; + } + + const std::string host = DetectOutboundLocalIpv4(config.host, config.port).value_or(""); + const std::string uri = + "inputflow://android-peer?host=" + PercentEncode(host) + + "&port=" + std::to_string(config.androidRelayPort) + + "&secret=" + PercentEncode(config.androidRelaySecret) + + "&peer=" + PercentEncode(config.androidPeerName); + + std::cout << "Android pairing URI:" << std::endl; + std::cout << uri << std::endl; + std::cout << "Use this string as the QR payload or enter the fields manually in the Android app." << std::endl; + return 0; +} + +#ifdef MWB_HAVE_GTK_GUI +int HandleGuiCommand(const std::string& binary, const std::vector& args) { + // Resolve config path from args (same parsing as HandleRunCommand) + std::filesystem::path configPath = mwb::DefaultConfigPath(); + std::filesystem::path statePath = mwb::DefaultStatePath(); + for (std::size_t i = 0; i < args.size(); ++i) { + if (args[i] == "--config" && i + 1 < args.size()) configPath = args[++i]; + else if (args[i] == "--state" && i + 1 < args.size()) statePath = args[++i]; + } + + mwb::AppConfig config; + if (std::filesystem::exists(configPath)) { + std::string err; + (void)mwb::LoadConfigFile(configPath, config, err); + } + + // Try to initialise GTK; fall back to headless run if no display + int fakeArgc = 1; + const std::string binaryStr = binary; + char* fakeArgvArr[] = {const_cast(binaryStr.c_str()), nullptr}; + char** fakeArgv = fakeArgvArr; + if (!gtk_init_check(&fakeArgc, &fakeArgv)) { + std::cerr << "No display available; running headless." << std::endl; + return HandleRunCommand(binary, args); + } + + return mwb::RunTrayAndGui(binary, args, config, + configPath.string(), statePath.string()); +} +#endif + } // namespace int main(int argc, char** argv) { @@ -2638,12 +2749,15 @@ int main(int argc, char** argv) { if (argc >= 3 && argc <= 4 && std::string(argv[1]) != "run" && std::string(argv[1]) != "discover" && std::string(argv[1]) != "doctor" && + std::string(argv[1]) != "android-pair" && std::string(argv[1]) != "topology" && std::string(argv[1]) != "init-config" && std::string(argv[1]) != "export-windows-pair" && std::string(argv[1]) != "install-user-service" && std::string(argv[1]) != "secret-store" && std::string(argv[1]) != "secret-clear" && + std::string(argv[1]) != "gui" && + std::string(argv[1]) != "tray" && std::string(argv[1]).rfind("--", 0) != 0) { return HandleLegacyRun(argc, argv); } @@ -2666,6 +2780,9 @@ int main(int argc, char** argv) { if (command == "doctor") { return HandleDoctorCommand(args); } + if (command == "android-pair") { + return HandleAndroidPairCommand(args); + } if (command == "topology") { return HandleTopologyCommand(args); } @@ -2685,6 +2802,12 @@ int main(int argc, char** argv) { return HandleSecretClearCommand(args); } +#ifdef MWB_HAVE_GTK_GUI + if (command == "gui" || command == "tray") { + return HandleGuiCommand(binary, args); + } +#endif + std::cerr << "ERR: Unknown command: " << command << std::endl; PrintGeneralUsage(std::cerr, argv[0]); return 1; diff --git a/tests/test_main.cpp b/tests/test_main.cpp index bd2508b..8352ecf 100644 --- a/tests/test_main.cpp +++ b/tests/test_main.cpp @@ -3,6 +3,7 @@ #include #include +#include "AndroidRelay.h" #include "AppConfig.h" #include "AppState.h" #include "Discovery.h" @@ -53,6 +54,11 @@ void TestAppConfigRoundTrip() { config.latencyReport = true; config.topologyRuntimeEnabled = true; config.topologyFile = "topology.conf"; + config.androidPeersEnabled = true; + config.androidRelayPort = 15112; + config.androidRelaySecret = "android-secret"; + config.androidPeerName = "pixel-8"; + config.androidCaptureBackend = "evdev"; const std::filesystem::path path = MakeTempPath("mwb-config-test.ini"); std::string error; @@ -80,6 +86,16 @@ void TestAppConfigRoundTrip() { "Rendered config should keep topology_enabled"); ExpectRenderedLine(rendered, "topology_file", "topology.conf", "Rendered config should keep topology_file"); + ExpectRenderedLine(rendered, "android_peers_enabled", "true", + "Rendered config should keep android_peers_enabled"); + ExpectRenderedLine(rendered, "android_relay_port", "15112", + "Rendered config should keep android_relay_port"); + ExpectRenderedLine(rendered, "android_relay_secret", "android-secret", + "Rendered config should keep android_relay_secret"); + ExpectRenderedLine(rendered, "android_peer_name", "pixel-8", + "Rendered config should keep android_peer_name"); + ExpectRenderedLine(rendered, "android_capture_backend", "evdev", + "Rendered config should keep android_capture_backend"); mwb::AppConfig loaded; Expect(mwb::LoadConfigFile(path, loaded, error), "LoadConfigFile should succeed"); @@ -109,6 +125,16 @@ void TestAppConfigRoundTrip() { "Loaded config should keep topology_enabled"); ExpectRenderedLine(loadedRendered, "topology_file", "topology.conf", "Loaded config should keep topology_file"); + ExpectRenderedLine(loadedRendered, "android_peers_enabled", "true", + "Loaded config should keep android_peers_enabled"); + ExpectRenderedLine(loadedRendered, "android_relay_port", "15112", + "Loaded config should keep android_relay_port"); + ExpectRenderedLine(loadedRendered, "android_relay_secret", "android-secret", + "Loaded config should keep android_relay_secret"); + ExpectRenderedLine(loadedRendered, "android_peer_name", "pixel-8", + "Loaded config should keep android_peer_name"); + ExpectRenderedLine(loadedRendered, "android_capture_backend", "evdev", + "Loaded config should keep android_capture_backend"); Expect(loaded.machineName == config.machineName, "Config machine_name round-trip"); Expect(loaded.port == config.port, "Config port round-trip"); Expect(loaded.autoConnectEnabled == config.autoConnectEnabled, "Config autoConnectEnabled round-trip"); @@ -130,10 +156,31 @@ void TestAppConfigRoundTrip() { Expect(loaded.latencyReport == config.latencyReport, "Config latencyReport round-trip"); Expect(loaded.topologyRuntimeEnabled == config.topologyRuntimeEnabled, "Config topologyRuntimeEnabled round-trip"); Expect(loaded.topologyFile == config.topologyFile, "Config topologyFile round-trip"); + Expect(loaded.androidPeersEnabled == config.androidPeersEnabled, "Config androidPeersEnabled round-trip"); + Expect(loaded.androidRelayPort == config.androidRelayPort, "Config androidRelayPort round-trip"); + Expect(loaded.androidRelaySecret == config.androidRelaySecret, "Config androidRelaySecret round-trip"); + Expect(loaded.androidPeerName == config.androidPeerName, "Config androidPeerName round-trip"); + Expect(loaded.androidCaptureBackend == config.androidCaptureBackend, "Config androidCaptureBackend round-trip"); std::error_code ignore; std::filesystem::remove(path, ignore); } +void TestAndroidRelayFrames() { + const mwb::MouseData mouse{123, 456, -120, 0x020A}; + const std::string mouseFrame = mwb::BuildAndroidMouseFrame(mouse); + Expect(mouseFrame.find("\"type\":\"mouse\"") != std::string::npos, "Android mouse frame should include type"); + Expect(mouseFrame.find("\"x\":123") != std::string::npos, "Android mouse frame should include x"); + Expect(mouseFrame.find("\"y\":456") != std::string::npos, "Android mouse frame should include y"); + Expect(mouseFrame.find("\"mouseData\":-120") != std::string::npos, "Android mouse frame should include mouseData"); + Expect(mouseFrame.find("\"wParam\":522") != std::string::npos, "Android mouse frame should include wParam"); + + const mwb::KeyboardData keyboard{65, mwb::kLlkhfUp}; + const std::string keyboardFrame = mwb::BuildAndroidKeyboardFrame(keyboard); + Expect(keyboardFrame.find("\"type\":\"keyboard\"") != std::string::npos, "Android keyboard frame should include type"); + Expect(keyboardFrame.find("\"vkCode\":65") != std::string::npos, "Android keyboard frame should include vkCode"); + Expect(keyboardFrame.find("\"flags\":128") != std::string::npos, "Android keyboard frame should include flags"); +} + void TestAppConfigKeyFileRoundTrip() { mwb::AppConfig config; config.host = "192.0.2.108"; @@ -593,6 +640,7 @@ void TestKScreenDoctorParserIgnoresAnsiSequences() { int main() { TestAppConfigRoundTrip(); + TestAndroidRelayFrames(); TestAppConfigKeyFileRoundTrip(); TestAppConfigKeySecretIdRoundTrip(); TestAppConfigConnectionPolicyRoundTrip();