From 00a8b5d26715209236b0c4bbc1da699f431a4c2e Mon Sep 17 00:00:00 2001
From: daredoole <49536135+daredoole@users.noreply.github.com>
Date: Wed, 24 Jun 2026 20:16:46 -0400
Subject: [PATCH 1/9] Share donkey art in master builder
---
tools/icon-generator/build-masters.js | 105 ++------------------------
tools/icon-generator/donkey.js | 29 ++++---
2 files changed, 25 insertions(+), 109 deletions(-)
diff --git a/tools/icon-generator/build-masters.js b/tools/icon-generator/build-masters.js
index f63819f..8fa3a94 100644
--- a/tools/icon-generator/build-masters.js
+++ b/tools/icon-generator/build-masters.js
@@ -2,70 +2,11 @@
// so the colored logo and the monochrome tray/notification glyphs stay identical.
const fs = require('fs');
const path = require('path');
+const { wrap, donkeyColor, donkeyMono, cursor, gradientDefs } = require('./donkey');
+
const OUT = path.join(__dirname, 'masters');
fs.mkdirSync(OUT, { recursive: true });
-// ---- shared donkey geometry (authored in a 1024 box, centred ~ (512,500)) ----
-const D = {
- earL: 't(450,432) r(-20)',
- earR: 't(574,432) r(20)',
-};
-
-// colored, multi-part donkey (for the brand logo)
-const donkeyColor = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
-
-// monochrome donkey via a mask (solid ink face with knocked-out eyes / inner-ears /
-// nostrils) -> survives single-colour theme tinting on Linux tray + Android notif.
-function donkeyMono(id, ink) {
- return `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- `;
-}
-
// corner status badge: a solid disc with a symbol knocked out (evenodd), plus a
// transparent gap ring so it separates from the donkey under any tint.
function badge(symbolPath) {
@@ -73,23 +14,11 @@ function badge(symbolPath) {
return { cx, cy, r, symbolPath };
}
-function wrap(inner, extra='') {
- return `\n`;
-}
-
// 1) BRAND LOGO (color, gradient squircle bg + donkey + cursor)
-const logo = wrap(`
-
-
-
-
-
-
+const logo = wrap(`${gradientDefs}
${donkeyColor}
-
-
- `);
+ ${cursor}`);
fs.writeFileSync(path.join(OUT,'inputflow-logo.svg'), logo);
// foreground-only donkey (transparent) for Android adaptive + in-app, re-centred
@@ -103,30 +32,10 @@ fs.writeFileSync(path.join(OUT,'inputflow-tray.svg'), wrap(donkeyMono('m0','#2E3
// and we punch a transparent ring out of the donkey so the badge reads cleanly.
function trayVariant(name, symbolInner, ink='#2E3440') {
const b = badge();
+ const gapRing = `
+ `;
const inner = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ ${donkeyMono('dk', ink, gapRing)}
${symbolInner(b)}`;
fs.writeFileSync(path.join(OUT,`inputflow-tray-${name}.svg`), wrap(inner));
}
diff --git a/tools/icon-generator/donkey.js b/tools/icon-generator/donkey.js
index d192d40..1d4226c 100644
--- a/tools/icon-generator/donkey.js
+++ b/tools/icon-generator/donkey.js
@@ -35,27 +35,34 @@ const donkeySilhouette = (ink) => `
`;
-// masked monochrome: solid face with knocked-out eyes / inner-ears / nostrils.
-// Best for raster tray + notification PNGs (survives theme tinting).
-const donkeyMono = (id, ink) => `
-
-
-
+const donkeyInkShapes = `
-
-
-
+ `;
+
+const donkeyKnockoutShapes = `
-
+ `;
+
+const donkeyMask = (id, extraKnockouts='') => `
+
+
+ ${donkeyInkShapes}
+
+ ${donkeyKnockoutShapes}${extraKnockouts}
+`;
+
+// masked monochrome: solid face with knocked-out eyes / inner-ears / nostrils.
+// Best for raster tray + notification PNGs (survives theme tinting).
+const donkeyMono = (id, ink, extraKnockouts='') => `${donkeyMask(id, extraKnockouts)}
`;
const cursor = `
@@ -65,4 +72,4 @@ const cursor = `
const gradientDefs = `
`;
-module.exports = { W, wrap, donkeyColor, donkeySilhouette, donkeyMono, cursor, gradientDefs };
+module.exports = { W, wrap, donkeyColor, donkeySilhouette, donkeyMask, donkeyMono, cursor, gradientDefs };
From 85a8b2b8c2144c66ec5f05358330c7406e25a350 Mon Sep 17 00:00:00 2001
From: daredoole <49536135+daredoole@users.noreply.github.com>
Date: Wed, 24 Jun 2026 20:30:16 -0400
Subject: [PATCH 2/9] android: lower input latency + native trackpad scroll +
more keybinds
- Coalesce backlogged mouse-move frames to the newest (snappy cursor, no lag).
- Native continuous 2-finger scroll via ACTION_SCROLL when a native backend is active.
- Extend keymap with F1-F12, Insert, CapsLock.
Co-Authored-By: Claude Opus 4.8
---
.../com/inputflow/android/InjectorManager.kt | 7 ++
.../com/inputflow/android/NativeInjector.kt | 22 ++++
.../android/RelayForegroundService.kt | 104 +++++++++++-------
.../main/java/com/inputflow/android/VkMap.kt | 4 +
4 files changed, 98 insertions(+), 39 deletions(-)
diff --git a/android/app/src/main/java/com/inputflow/android/InjectorManager.kt b/android/app/src/main/java/com/inputflow/android/InjectorManager.kt
index b1a347e..5f4063d 100644
--- a/android/app/src/main/java/com/inputflow/android/InjectorManager.kt
+++ b/android/app/src/main/java/com/inputflow/android/InjectorManager.kt
@@ -163,6 +163,13 @@ object InjectorManager {
return n.handleKeyboard(frame)
}
+ /** Native continuous scroll for 2-finger trackpad gestures. */
+ fun handleScroll(frame: JSONObject): Boolean {
+ val n = native ?: return false
+ n.scrollBy(frame.optDouble("dx", 0.0).toFloat(), frame.optDouble("dy", 0.0).toFloat())
+ return true
+ }
+
private fun sensitivity(context: Context): Float {
val prefs = context.getSharedPreferences(RelayForegroundService.PREFS, Context.MODE_PRIVATE)
return prefs.getFloat(SettingsActivity.KEY_SENSITIVITY, 1.0f).coerceIn(0.5f, 3.0f)
diff --git a/android/app/src/main/java/com/inputflow/android/NativeInjector.kt b/android/app/src/main/java/com/inputflow/android/NativeInjector.kt
index c24a83e..9a0278b 100644
--- a/android/app/src/main/java/com/inputflow/android/NativeInjector.kt
+++ b/android/app/src/main/java/com/inputflow/android/NativeInjector.kt
@@ -62,6 +62,26 @@ class NativeInjector(
inject(pointerEvent(MotionEvent.ACTION_UP, 0, down, SystemClock.uptimeMillis()))
}
+ /** Continuous trackpad-style scroll at the cursor (native ACTION_SCROLL). */
+ fun scrollBy(dx: Float, dy: Float) {
+ val now = SystemClock.uptimeMillis()
+ val props = MotionEvent.PointerProperties().apply {
+ id = 0; toolType = MotionEvent.TOOL_TYPE_MOUSE
+ }
+ val coords = MotionEvent.PointerCoords().apply {
+ x = px; y = py; pressure = 1f; size = 1f
+ // Android scroll axes: positive vscroll = away/up. Trackpad dy>0 = down.
+ setAxisValue(MotionEvent.AXIS_VSCROLL, -dy / SCROLL_DIVISOR)
+ setAxisValue(MotionEvent.AXIS_HSCROLL, dx / SCROLL_DIVISOR)
+ }
+ val ev = MotionEvent.obtain(
+ now, now, MotionEvent.ACTION_SCROLL, 1,
+ arrayOf(props), arrayOf(coords), 0, 0, 1f, 1f, 0, 0,
+ InputDevice.SOURCE_MOUSE, 0)
+ inject(ev)
+ ev.recycle()
+ }
+
private fun scroll(mouseData: Int) {
val now = SystemClock.uptimeMillis()
val props = MotionEvent.PointerProperties().apply {
@@ -130,5 +150,7 @@ class NativeInjector(
private const val WM_MBUTTONUP = 0x0208
private const val WM_MOUSEWHEEL = 0x020A
private const val LLKHF_UP = 0x80
+ // Trackpad pixel-delta → scroll-unit scale; smaller = faster scroll.
+ private const val SCROLL_DIVISOR = 40f
}
}
diff --git a/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt b/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt
index e1aef36..82cdbc6 100644
--- a/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt
+++ b/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt
@@ -99,6 +99,52 @@ class RelayForegroundService : Service() {
worker = Thread({ relayLoop() }, "inputflow-relay").also { it.start() }
}
+ private fun dispatchFrame(frame: JSONObject, prefs: android.content.SharedPreferences) {
+ when (frame.optString("type")) {
+ "control" -> setRemoteControlActive(frame.optBoolean("active", false))
+ "mouse" -> if (remoteControlActive) {
+ // Prefer native injection (Shizuku/root); fall back to accessibility.
+ if (!InjectorManager.handleMouse(frame)) {
+ val accessibility = InputFlowAccessibilityService.instance
+ if (accessibility != null) {
+ deliveredMouseFrames += 1
+ accessibility.handleMouse(frame)
+ } else {
+ if (droppedMouseFrames < 5) {
+ Log.w(TAG, "mouse frame dropped: no injector active")
+ }
+ droppedMouseFrames += 1
+ }
+ }
+ }
+ "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) {
+ // handled by a user-defined key mapping
+ } else if (InjectorManager.handleKeyboard(frame)) {
+ // injected natively (Shizuku/root)
+ } else {
+ val laptopTypingEnabled = prefs.getBoolean(KEY_LAPTOP_TYPING_ENABLED, false)
+ if (!laptopTypingEnabled || InputFlowImeService.instance?.handleKeyboard(frame) != true) {
+ accessibility?.handleKeyboard(frame)
+ }
+ }
+ }
+ }
+ "gesture" -> if (remoteControlActive) {
+ // Native continuous scroll when available; richer gestures via accessibility.
+ if (frame.optString("kind") != "scroll" || !InjectorManager.handleScroll(frame)) {
+ InputFlowAccessibilityService.instance?.handleGesture(frame)
+ }
+ }
+ "devices_info" -> handleDevicesInfo(frame)
+ }
+ }
+
private fun relayLoop() {
val prefs = getSharedPreferences(PREFS, Context.MODE_PRIVATE)
while (running.get()) {
@@ -131,47 +177,26 @@ class RelayForegroundService : Service() {
startRelayForeground("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) {
- // Prefer native injection (Shizuku/root); fall back to accessibility.
- if (!InjectorManager.handleMouse(frame)) {
- val accessibility = InputFlowAccessibilityService.instance
- if (accessibility != null) {
- deliveredMouseFrames += 1
- accessibility.handleMouse(frame)
- } else {
- if (droppedMouseFrames < 5) {
- Log.w(TAG, "mouse frame dropped: no injector active")
- }
- droppedMouseFrames += 1
- }
- }
- }
- "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) {
- // handled by a user-defined key mapping
- } else if (InjectorManager.handleKeyboard(frame)) {
- // injected natively (Shizuku/root)
- } else {
- 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)
+ var frame = RelayProtocol.readFrame(streams.first)
+ // Collapse a backlog of mouse-move frames to the newest so the
+ // cursor tracks live with no lag under load. Non-move frames are
+ // dispatched in order; only intermediate moves are dropped.
+ while (frame.optString("type") == "mouse" &&
+ frame.optInt("wParam") == WM_MOUSEMOVE &&
+ streams.first.available() > 4
+ ) {
+ val next = RelayProtocol.readFrame(streams.first)
+ if (next.optString("type") == "mouse" &&
+ next.optInt("wParam") == WM_MOUSEMOVE
+ ) {
+ frame = next
+ } else {
+ dispatchFrame(frame, prefs)
+ frame = next
+ break
}
- "devices_info" -> handleDevicesInfo(frame)
}
+ dispatchFrame(frame, prefs)
}
deactivateRemoteControl()
}
@@ -257,6 +282,7 @@ class RelayForegroundService : Service() {
}
companion object {
+ private const val WM_MOUSEMOVE = 0x0200
const val PREFS = "inputflow"
const val KEY_HOST = "host"
const val KEY_PORT = "port"
diff --git a/android/app/src/main/java/com/inputflow/android/VkMap.kt b/android/app/src/main/java/com/inputflow/android/VkMap.kt
index b5576dc..e378ff0 100644
--- a/android/app/src/main/java/com/inputflow/android/VkMap.kt
+++ b/android/app/src/main/java/com/inputflow/android/VkMap.kt
@@ -27,6 +27,10 @@ object VkMap {
put(0x27, KeyEvent.KEYCODE_DPAD_RIGHT)
put(0x28, KeyEvent.KEYCODE_DPAD_DOWN)
put(0x2E, KeyEvent.KEYCODE_FORWARD_DEL)
+ put(0x2D, KeyEvent.KEYCODE_INSERT)
+ put(0x14, KeyEvent.KEYCODE_CAPS_LOCK)
+ // Function keys F1-F12 (VK 0x70-0x7B → KEYCODE_F1..F12)
+ for (i in 0..11) put(0x70 + i, KeyEvent.KEYCODE_F1 + i)
put(0x5B, KeyEvent.KEYCODE_META_LEFT)
put(0x5C, KeyEvent.KEYCODE_META_RIGHT)
// OEM punctuation
From dcaf35a933e88be9e052e8853ba7365ed17c123f Mon Sep 17 00:00:00 2001
From: daredoole <49536135+daredoole@users.noreply.github.com>
Date: Wed, 24 Jun 2026 20:40:31 -0400
Subject: [PATCH 3/9] android: improve trackpad swipe gestures
---
.../android/InputFlowAccessibilityService.kt | 18 ++++++++++++++++--
src/LibeiInputCaptureBridge.cpp | 4 +++-
2 files changed, 19 insertions(+), 3 deletions(-)
diff --git a/android/app/src/main/java/com/inputflow/android/InputFlowAccessibilityService.kt b/android/app/src/main/java/com/inputflow/android/InputFlowAccessibilityService.kt
index e6cb1d5..0131725 100644
--- a/android/app/src/main/java/com/inputflow/android/InputFlowAccessibilityService.kt
+++ b/android/app/src/main/java/com/inputflow/android/InputFlowAccessibilityService.kt
@@ -290,17 +290,25 @@ class InputFlowAccessibilityService : AccessibilityService() {
}
private fun handleSwipe3(dx: Double, dy: Double) {
+ if (isAccurateHorizontalSwipe(dx, dy)) {
+ dispatchDirectionalSwipe(if (dx < 0) -1f else 1f, 0f)
+ return
+ }
if (!isAccurateVerticalSwipe(dx, dy)) return
mainHandler.post {
if (dy < 0) {
- performGlobalActionLogged(GLOBAL_ACTION_HOME, "gesture:3-up")
+ performGlobalActionLogged(GLOBAL_ACTION_RECENTS, "gesture:3-up")
} else {
- performGlobalActionLogged(GLOBAL_ACTION_RECENTS, "gesture:3-down")
+ performGlobalActionLogged(GLOBAL_ACTION_HOME, "gesture:3-down")
}
}
}
private fun handleSwipe4(dx: Double, dy: Double) {
+ if (isAccurateHorizontalSwipe(dx, dy)) {
+ dispatchDirectionalSwipe(if (dx < 0) -1f else 1f, 0f)
+ return
+ }
if (!isAccurateVerticalSwipe(dx, dy)) return
mainHandler.post {
if (dy < 0) {
@@ -317,6 +325,12 @@ class InputFlowAccessibilityService : AccessibilityService() {
return absY >= 32.0 && absY >= absX * 1.45
}
+ private fun isAccurateHorizontalSwipe(dx: Double, dy: Double): Boolean {
+ val absX = abs(dx)
+ val absY = abs(dy)
+ return absX >= 32.0 && absX >= absY * 1.45
+ }
+
private fun openAppDrawerGesture() {
performGlobalActionLogged(GLOBAL_ACTION_HOME, "gesture:4-up-home")
mainHandler.postDelayed({
diff --git a/src/LibeiInputCaptureBridge.cpp b/src/LibeiInputCaptureBridge.cpp
index 62f58c4..b833651 100644
--- a/src/LibeiInputCaptureBridge.cpp
+++ b/src/LibeiInputCaptureBridge.cpp
@@ -254,7 +254,9 @@ bool EmitClassifiedSwipe(
const double absX = std::abs(dx);
const double absY = std::abs(dy);
- if (absY < 36.0 || absY < absX * 1.35) {
+ const bool vertical = absY >= 36.0 && absY >= absX * 1.35;
+ const bool horizontal = absX >= 36.0 && absX >= absY * 1.35;
+ if (!vertical && !horizontal) {
return false;
}
From ec39859970effc8e628cace06c98ad71685b975e Mon Sep 17 00:00:00 2001
From: daredoole <49536135+daredoole@users.noreply.github.com>
Date: Wed, 24 Jun 2026 20:46:23 -0400
Subject: [PATCH 4/9] android: support home page trackpad swipes
---
.../android/InputFlowAccessibilityService.kt | 16 ++++++++++++++--
.../inputflow/android/RelayForegroundService.kt | 9 ++++++++-
src/ClientRuntime.cpp | 3 +++
src/LocalAndroidInputBridge.cpp | 7 +++++++
src/LocalAndroidInputBridge.h | 1 +
5 files changed, 33 insertions(+), 3 deletions(-)
diff --git a/android/app/src/main/java/com/inputflow/android/InputFlowAccessibilityService.kt b/android/app/src/main/java/com/inputflow/android/InputFlowAccessibilityService.kt
index 0131725..53d3913 100644
--- a/android/app/src/main/java/com/inputflow/android/InputFlowAccessibilityService.kt
+++ b/android/app/src/main/java/com/inputflow/android/InputFlowAccessibilityService.kt
@@ -291,7 +291,7 @@ class InputFlowAccessibilityService : AccessibilityService() {
private fun handleSwipe3(dx: Double, dy: Double) {
if (isAccurateHorizontalSwipe(dx, dy)) {
- dispatchDirectionalSwipe(if (dx < 0) -1f else 1f, 0f)
+ dispatchPageSwipe(if (dx < 0) -1f else 1f)
return
}
if (!isAccurateVerticalSwipe(dx, dy)) return
@@ -306,7 +306,7 @@ class InputFlowAccessibilityService : AccessibilityService() {
private fun handleSwipe4(dx: Double, dy: Double) {
if (isAccurateHorizontalSwipe(dx, dy)) {
- dispatchDirectionalSwipe(if (dx < 0) -1f else 1f, 0f)
+ dispatchPageSwipe(if (dx < 0) -1f else 1f)
return
}
if (!isAccurateVerticalSwipe(dx, dy)) return
@@ -565,6 +565,10 @@ class InputFlowAccessibilityService : AccessibilityService() {
pendingScrollDx = 0.0
pendingScrollDy = 0.0
if (abs(dx) < 0.05 && abs(dy) < 0.05) return
+ if (isAccurateHorizontalSwipe(dx, dy)) {
+ dispatchPageSwipe(if (dx < 0) -1f else 1f)
+ return
+ }
val metrics = resources.displayMetrics
val scale = 34f * metrics.density
val maxStep = 170f * metrics.density
@@ -575,6 +579,14 @@ class InputFlowAccessibilityService : AccessibilityService() {
gesture(pointerX, pointerY, toX, toY, 62)
}
+ private fun dispatchPageSwipe(directionX: Float) {
+ val metrics = resources.displayMetrics
+ val y = metrics.heightPixels * 0.56f
+ val fromX = if (directionX < 0f) metrics.widthPixels * 0.84f else metrics.widthPixels * 0.16f
+ val toX = if (directionX < 0f) metrics.widthPixels * 0.16f else metrics.widthPixels * 0.84f
+ gesture(fromX, y, toX, y, 260)
+ }
+
private fun showCursorOverlay() {
if (cursorView != null) return
val prefs = applicationContext.getSharedPreferences(RelayForegroundService.PREFS, MODE_PRIVATE)
diff --git a/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt b/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt
index 82cdbc6..17cebec 100644
--- a/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt
+++ b/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt
@@ -137,7 +137,7 @@ class RelayForegroundService : Service() {
}
"gesture" -> if (remoteControlActive) {
// Native continuous scroll when available; richer gestures via accessibility.
- if (frame.optString("kind") != "scroll" || !InjectorManager.handleScroll(frame)) {
+ if (shouldUseAccessibilityGesture(frame) || !InjectorManager.handleScroll(frame)) {
InputFlowAccessibilityService.instance?.handleGesture(frame)
}
}
@@ -145,6 +145,13 @@ class RelayForegroundService : Service() {
}
}
+ private fun shouldUseAccessibilityGesture(frame: JSONObject): Boolean {
+ if (frame.optString("kind") != "scroll") return true
+ val dx = kotlin.math.abs(frame.optDouble("dx", 0.0))
+ val dy = kotlin.math.abs(frame.optDouble("dy", 0.0))
+ return dx >= 2.0 && dx >= dy * 1.8
+ }
+
private fun relayLoop() {
val prefs = getSharedPreferences(PREFS, Context.MODE_PRIVATE)
while (running.get()) {
diff --git a/src/ClientRuntime.cpp b/src/ClientRuntime.cpp
index 7865cca..db530a2 100644
--- a/src/ClientRuntime.cpp
+++ b/src/ClientRuntime.cpp
@@ -699,6 +699,9 @@ void ClientRuntime::StartLocalAndroidInputBridge(const ScreenSize& screenSize) {
options.sendMouse = [this](const MouseData& mouse) {
return TrySendAndroidMouse(mouse);
};
+ options.sendGesture = [this](const std::string& kind, double dx, double dy) {
+ return TrySendAndroidGesture(kind, dx, dy);
+ };
m_localAndroidInputBridge = std::make_unique(std::move(options));
m_localAndroidInputBridge->Start();
diff --git a/src/LocalAndroidInputBridge.cpp b/src/LocalAndroidInputBridge.cpp
index 94afe91..a04c322 100644
--- a/src/LocalAndroidInputBridge.cpp
+++ b/src/LocalAndroidInputBridge.cpp
@@ -48,6 +48,7 @@ struct DeviceState {
int relDx{0};
int relDy{0};
int wheel{0};
+ int hWheel{0};
bool hasAbsX{false};
bool hasAbsY{false};
bool hasTouchState{false};
@@ -464,6 +465,8 @@ void LocalAndroidInputBridge::Run() {
device.relDy += event.value;
} else if (event.code == REL_WHEEL) {
device.wheel += event.value;
+ } else if (event.code == REL_HWHEEL) {
+ device.hWheel += event.value;
}
} else if (event.type == EV_ABS) {
if (event.code == ABS_X && device.hasAbsX) {
@@ -516,9 +519,13 @@ void LocalAndroidInputBridge::Run() {
MouseData wheel{androidX, androidY, device.wheel * 120, WM_MOUSEWHEEL};
m_options.sendMouse(wheel);
}
+ if (active && device.hWheel != 0 && m_options.sendGesture) {
+ m_options.sendGesture("scroll", device.hWheel * 8.0, 0.0);
+ }
device.relDx = 0;
device.relDy = 0;
device.wheel = 0;
+ device.hWheel = 0;
}
}
}
diff --git a/src/LocalAndroidInputBridge.h b/src/LocalAndroidInputBridge.h
index 831a185..01905b6 100644
--- a/src/LocalAndroidInputBridge.h
+++ b/src/LocalAndroidInputBridge.h
@@ -18,6 +18,7 @@ struct LocalAndroidInputBridgeOptions {
int desktopWidth{0};
int desktopHeight{0};
std::function sendMouse;
+ std::function sendGesture;
};
class LocalAndroidInputBridge {
From b235053ab8681375ecd7580ae2c98a3fed222661 Mon Sep 17 00:00:00 2001
From: daredoole <49536135+daredoole@users.noreply.github.com>
Date: Wed, 24 Jun 2026 21:00:58 -0400
Subject: [PATCH 5/9] android: add notification sync
---
android/app/src/main/AndroidManifest.xml | 10 ++
.../com/inputflow/android/InjectorManager.kt | 6 +-
.../com/inputflow/android/NativeInjector.kt | 26 ++--
.../android/NotificationSyncBridge.kt | 141 ++++++++++++++++++
.../android/RelayForegroundService.kt | 101 +++++++++----
.../com/inputflow/android/SettingsActivity.kt | 45 ++++++
.../src/main/res/layout/activity_settings.xml | 54 +++++++
android/app/src/main/res/values/strings.xml | 11 ++
docs/android.md | 19 +++
src/AndroidRelay.cpp | 85 ++++++++++-
src/AndroidRelay.h | 1 +
src/AppConfig.cpp | 12 ++
src/AppConfig.h | 1 +
src/TrayController.cpp | 1 +
src/main.cpp | 3 +
15 files changed, 468 insertions(+), 48 deletions(-)
create mode 100644 android/app/src/main/java/com/inputflow/android/NotificationSyncBridge.kt
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index e4da20e..d3b1aa3 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -49,6 +49,16 @@
android:exported="false"
android:foregroundServiceType="connectedDevice" />
+
+
+
+
+
+
{
val sens = sensitivityProvider()
val baseX = frame.optInt("x") / 65535f * m.widthPixels
@@ -51,19 +51,21 @@ class NativeInjector(
WM_RBUTTONUP -> click(MotionEvent.BUTTON_SECONDARY)
WM_MBUTTONUP -> click(MotionEvent.BUTTON_TERTIARY)
WM_MOUSEWHEEL -> scroll(frame.optInt("mouseData"))
+ else -> false
}
}
- private fun click(button: Int) {
+ private fun click(button: Int): Boolean {
val down = SystemClock.uptimeMillis()
- inject(pointerEvent(MotionEvent.ACTION_DOWN, button, down, down))
- inject(pointerEvent(MotionEvent.ACTION_BUTTON_PRESS, button, down, down))
- inject(pointerEvent(MotionEvent.ACTION_BUTTON_RELEASE, 0, down, SystemClock.uptimeMillis()))
- inject(pointerEvent(MotionEvent.ACTION_UP, 0, down, SystemClock.uptimeMillis()))
+ val downSent = inject(pointerEvent(MotionEvent.ACTION_DOWN, button, down, down))
+ val pressSent = inject(pointerEvent(MotionEvent.ACTION_BUTTON_PRESS, button, down, down))
+ val releaseSent = inject(pointerEvent(MotionEvent.ACTION_BUTTON_RELEASE, 0, down, SystemClock.uptimeMillis()))
+ val upSent = inject(pointerEvent(MotionEvent.ACTION_UP, 0, down, SystemClock.uptimeMillis()))
+ return downSent && pressSent && releaseSent && upSent
}
/** Continuous trackpad-style scroll at the cursor (native ACTION_SCROLL). */
- fun scrollBy(dx: Float, dy: Float) {
+ fun scrollBy(dx: Float, dy: Float): Boolean {
val now = SystemClock.uptimeMillis()
val props = MotionEvent.PointerProperties().apply {
id = 0; toolType = MotionEvent.TOOL_TYPE_MOUSE
@@ -78,11 +80,12 @@ class NativeInjector(
now, now, MotionEvent.ACTION_SCROLL, 1,
arrayOf(props), arrayOf(coords), 0, 0, 1f, 1f, 0, 0,
InputDevice.SOURCE_MOUSE, 0)
- inject(ev)
+ val sent = inject(ev)
ev.recycle()
+ return sent
}
- private fun scroll(mouseData: Int) {
+ private fun scroll(mouseData: Int): Boolean {
val now = SystemClock.uptimeMillis()
val props = MotionEvent.PointerProperties().apply {
id = 0; toolType = MotionEvent.TOOL_TYPE_MOUSE
@@ -95,8 +98,9 @@ class NativeInjector(
now, now, MotionEvent.ACTION_SCROLL, 1,
arrayOf(props), arrayOf(coords), 0, 0, 1f, 1f, 0, 0,
InputDevice.SOURCE_MOUSE, 0)
- inject(ev)
+ val sent = inject(ev)
ev.recycle()
+ return sent
}
private fun pointerEvent(
diff --git a/android/app/src/main/java/com/inputflow/android/NotificationSyncBridge.kt b/android/app/src/main/java/com/inputflow/android/NotificationSyncBridge.kt
new file mode 100644
index 0000000..b22c78a
--- /dev/null
+++ b/android/app/src/main/java/com/inputflow/android/NotificationSyncBridge.kt
@@ -0,0 +1,141 @@
+package com.inputflow.android
+
+import android.Manifest
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+import android.service.notification.NotificationListenerService
+import android.service.notification.StatusBarNotification
+import org.json.JSONObject
+import kotlin.math.absoluteValue
+
+class InputFlowNotificationListenerService : NotificationListenerService() {
+ override fun onNotificationPosted(sbn: StatusBarNotification) {
+ NotificationSyncBridge.sendAndroidNotification(this, sbn)
+ }
+
+ override fun onNotificationRemoved(sbn: StatusBarNotification) {
+ NotificationSyncBridge.sendAndroidNotificationDismiss(this, sbn)
+ }
+}
+
+object NotificationSyncBridge {
+ private const val MIRROR_CHANNEL_ID = "inputflow-mirrored"
+ private const val MIRROR_CHANNEL_NAME = "Mirrored notifications"
+ private val sensitivePattern = Regex(
+ "\\b(otp|one[- ]?time|verification|2fa|two[- ]?factor|password|passcode|bank|credit|debit)\\b",
+ RegexOption.IGNORE_CASE
+ )
+
+ fun sendAndroidNotification(context: Context, sbn: StatusBarNotification) {
+ if (!isEnabled(context) || sbn.packageName == context.packageName) return
+
+ val notification = sbn.notification ?: return
+ if ((notification.flags and Notification.FLAG_GROUP_SUMMARY) != 0) return
+ if ((notification.flags and Notification.FLAG_ONGOING_EVENT) != 0) return
+ if (notification.visibility == Notification.VISIBILITY_SECRET) return
+
+ val title = notification.extras.getCharSequence(Notification.EXTRA_TITLE)
+ ?.toString()
+ ?.trim()
+ .orEmpty()
+ val body = (
+ notification.extras.getCharSequence(Notification.EXTRA_BIG_TEXT)
+ ?: notification.extras.getCharSequence(Notification.EXTRA_TEXT)
+ )
+ ?.toString()
+ ?.trim()
+ .orEmpty()
+
+ if (title.isBlank() && body.isBlank()) return
+ if (isSensitive(title, body)) return
+
+ RelayForegroundService.instance?.sendNotificationUpsert(
+ stableId = stableId(sbn),
+ app = appLabel(context, sbn.packageName),
+ packageName = sbn.packageName,
+ title = title,
+ body = body,
+ postedAtMs = sbn.postTime
+ )
+ }
+
+ fun sendAndroidNotificationDismiss(context: Context, sbn: StatusBarNotification) {
+ if (!isEnabled(context) || sbn.packageName == context.packageName) return
+ RelayForegroundService.instance?.sendNotificationDismiss(stableId(sbn), sbn.packageName)
+ }
+
+ fun showMirroredNotification(context: Context, frame: JSONObject) {
+ if (!isEnabled(context)) return
+ if (Build.VERSION.SDK_INT >= 33 &&
+ context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) !=
+ PackageManager.PERMISSION_GRANTED
+ ) {
+ return
+ }
+
+ val title = frame.optString("title", "InputFlow notification")
+ val body = frame.optString("body", "")
+ val app = frame.optString("app", "")
+ val stableId = frame.optString("stable_id", "$app:$title:$body")
+ val manager = context.getSystemService(NotificationManager::class.java)
+ ensureMirrorChannel(manager)
+
+ val notification = Notification.Builder(context, MIRROR_CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentTitle(if (app.isBlank()) title else "$app: $title")
+ .setContentText(body)
+ .setStyle(Notification.BigTextStyle().bigText(body))
+ .setAutoCancel(true)
+ .build()
+
+ manager.notify(notificationId(stableId), notification)
+ }
+
+ fun cancelMirroredNotification(context: Context, frame: JSONObject) {
+ val stableId = frame.optString("stable_id", "")
+ if (stableId.isBlank()) return
+ context.getSystemService(NotificationManager::class.java)
+ .cancel(notificationId(stableId))
+ }
+
+ private fun isEnabled(context: Context): Boolean =
+ context.getSharedPreferences(RelayForegroundService.PREFS, Context.MODE_PRIVATE)
+ .getBoolean(RelayForegroundService.KEY_NOTIFICATION_SYNC_ENABLED, false)
+
+ private fun isSensitive(title: String, body: String): Boolean =
+ sensitivePattern.containsMatchIn(title) || sensitivePattern.containsMatchIn(body)
+
+ private fun stableId(sbn: StatusBarNotification): String {
+ val tag = sbn.tag ?: ""
+ return "${sbn.packageName}:${sbn.id}:$tag:${sbn.postTime}"
+ }
+
+ private fun appLabel(context: Context, packageName: String): String {
+ return try {
+ val pm = context.packageManager
+ val info = pm.getApplicationInfo(packageName, 0)
+ pm.getApplicationLabel(info).toString()
+ } catch (_: Exception) {
+ packageName
+ }
+ }
+
+ private fun ensureMirrorChannel(manager: NotificationManager) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ if (manager.getNotificationChannel(MIRROR_CHANNEL_ID) != null) return
+ manager.createNotificationChannel(
+ NotificationChannel(
+ MIRROR_CHANNEL_ID,
+ MIRROR_CHANNEL_NAME,
+ NotificationManager.IMPORTANCE_DEFAULT
+ )
+ )
+ }
+
+ private fun notificationId(stableId: String): Int =
+ (stableId.hashCode().toLong().absoluteValue % 900_000L).toInt() + 100_000
+}
diff --git a/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt b/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt
index 17cebec..e56c4ba 100644
--- a/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt
+++ b/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt
@@ -21,6 +21,7 @@ class RelayForegroundService : Service() {
@Volatile
private var output: DataOutputStream? = null
+ private val outputLock = Any()
@Volatile
private var remoteControlActive = false
private var deliveredMouseFrames = 0
@@ -84,13 +85,41 @@ class RelayForegroundService : Service() {
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) {}
+ writeFrame(JSONObject().put("type", "topology_update").put("layout", layout))
}
+ fun isConnectedForWrites(): Boolean =
+ currentState == STATE_CONNECTED && output != null
+
+ fun sendNotificationUpsert(
+ stableId: String,
+ app: String,
+ packageName: String,
+ title: String,
+ body: String,
+ postedAtMs: Long
+ ): Boolean =
+ writeFrame(
+ JSONObject()
+ .put("type", "notification_upsert")
+ .put("stable_id", stableId)
+ .put("origin", "android")
+ .put("app", app)
+ .put("package", packageName)
+ .put("title", title)
+ .put("body", body)
+ .put("posted_at_ms", postedAtMs)
+ )
+
+ fun sendNotificationDismiss(stableId: String, packageName: String): Boolean =
+ writeFrame(
+ JSONObject()
+ .put("type", "notification_dismiss")
+ .put("stable_id", stableId)
+ .put("origin", "android")
+ .put("package", packageName)
+ )
+
private fun startRelay() {
if (!running.compareAndSet(false, true)) return
InjectorManager.start(this)
@@ -104,17 +133,18 @@ class RelayForegroundService : Service() {
"control" -> setRemoteControlActive(frame.optBoolean("active", false))
"mouse" -> if (remoteControlActive) {
// Prefer native injection (Shizuku/root); fall back to accessibility.
- if (!InjectorManager.handleMouse(frame)) {
- val accessibility = InputFlowAccessibilityService.instance
- if (accessibility != null) {
- deliveredMouseFrames += 1
- accessibility.handleMouse(frame)
- } else {
- if (droppedMouseFrames < 5) {
- Log.w(TAG, "mouse frame dropped: no injector active")
- }
- droppedMouseFrames += 1
+ val accessibility = InputFlowAccessibilityService.instance
+ val isMove = frame.optInt("wParam") == WM_MOUSEMOVE
+ if (isMove && InjectorManager.handleMouse(frame)) {
+ // Native pointer motion succeeded.
+ } else if (accessibility != null) {
+ deliveredMouseFrames += 1
+ accessibility.handleMouse(frame)
+ } else if (!InjectorManager.handleMouse(frame)) {
+ if (droppedMouseFrames < 5) {
+ Log.w(TAG, "mouse frame dropped: no injector active")
}
+ droppedMouseFrames += 1
}
}
"keyboard" -> {
@@ -136,22 +166,19 @@ class RelayForegroundService : Service() {
}
}
"gesture" -> if (remoteControlActive) {
- // Native continuous scroll when available; richer gestures via accessibility.
- if (shouldUseAccessibilityGesture(frame) || !InjectorManager.handleScroll(frame)) {
- InputFlowAccessibilityService.instance?.handleGesture(frame)
+ val accessibility = InputFlowAccessibilityService.instance
+ if (accessibility != null) {
+ accessibility.handleGesture(frame)
+ } else if (frame.optString("kind") == "scroll") {
+ InjectorManager.handleScroll(frame)
}
}
"devices_info" -> handleDevicesInfo(frame)
+ "notification_upsert" -> NotificationSyncBridge.showMirroredNotification(this, frame)
+ "notification_dismiss" -> NotificationSyncBridge.cancelMirroredNotification(this, frame)
}
}
- private fun shouldUseAccessibilityGesture(frame: JSONObject): Boolean {
- if (frame.optString("kind") != "scroll") return true
- val dx = kotlin.math.abs(frame.optDouble("dx", 0.0))
- val dy = kotlin.math.abs(frame.optDouble("dy", 0.0))
- return dx >= 2.0 && dx >= dy * 1.8
- }
-
private fun relayLoop() {
val prefs = getSharedPreferences(PREFS, Context.MODE_PRIVATE)
while (running.get()) {
@@ -225,11 +252,26 @@ class RelayForegroundService : Service() {
}
private fun sendRelease() {
- try {
- output?.let {
- RelayProtocol.writeFrame(it, JSONObject().put("type", "release"))
+ writeFrame(JSONObject().put("type", "release"))
+ }
+
+ private fun writeFrame(frame: JSONObject): Boolean {
+ return try {
+ synchronized(outputLock) {
+ output?.let {
+ RelayProtocol.writeFrame(it, frame)
+ true
+ } ?: run {
+ Log.w(TAG, "failed to write relay frame type=${frame.optString("type")}: no relay output stream")
+ false
+ }
}
- } catch (_: Exception) {}
+ } catch (e: Exception) {
+ Log.w(TAG, "failed to write relay frame type=${frame.optString("type")}", e)
+ output = null
+ broadcastStatus(STATE_DISCONNECTED, "Retrying…")
+ false
+ }
}
private fun setRemoteControlActive(active: Boolean) {
@@ -295,6 +337,7 @@ class RelayForegroundService : Service() {
const val KEY_PORT = "port"
const val KEY_SECRET = "secret"
const val KEY_LAPTOP_TYPING_ENABLED = "laptop_typing_enabled"
+ const val KEY_NOTIFICATION_SYNC_ENABLED = "notification_sync_enabled"
const val KEY_STATUS_STATE = "status_state"
const val KEY_STATUS_DETAIL = "status_detail"
const val KEY_STATUS_HAS_DETAIL = "status_has_detail"
diff --git a/android/app/src/main/java/com/inputflow/android/SettingsActivity.kt b/android/app/src/main/java/com/inputflow/android/SettingsActivity.kt
index 707b18a..27fc7ef 100644
--- a/android/app/src/main/java/com/inputflow/android/SettingsActivity.kt
+++ b/android/app/src/main/java/com/inputflow/android/SettingsActivity.kt
@@ -4,9 +4,11 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
+import android.provider.Settings
import android.view.inputmethod.InputMethodManager
import android.widget.RadioGroup
import android.widget.TextView
+import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.button.MaterialButton
@@ -29,6 +31,7 @@ class SettingsActivity : AppCompatActivity() {
private lateinit var injectBackendGroup: RadioGroup
private lateinit var injectStatusText: TextView
private lateinit var injectConsentSwitch: MaterialSwitch
+ private lateinit var notificationSyncSwitch: MaterialSwitch
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -68,6 +71,18 @@ class SettingsActivity : AppCompatActivity() {
sensitivitySlider.value = prefs.getFloat(KEY_SENSITIVITY, 1.0f).coerceIn(0.5f, 3.0f)
laptopTypingSwitch.isChecked = prefs.getBoolean(RelayForegroundService.KEY_LAPTOP_TYPING_ENABLED, false)
+ notificationSyncSwitch = findViewById(R.id.notificationSyncSwitch)
+ notificationSyncSwitch.isChecked =
+ prefs.getBoolean(RelayForegroundService.KEY_NOTIFICATION_SYNC_ENABLED, false)
+ notificationSyncSwitch.setOnCheckedChangeListener { _, checked ->
+ saveSettings()
+ if (checked) {
+ startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
+ }
+ }
+ findViewById(R.id.btnSendTestNotification).setOnClickListener {
+ sendTestNotification()
+ }
// Restore keyboard mode
when (prefs.getString(KEY_KEYBOARD_MODE, "accessibility")) {
@@ -179,6 +194,35 @@ class SettingsActivity : AppCompatActivity() {
"Shizuku: ${if (shizuku) "ready" else "unavailable"} Root: ${if (root) "ready" else "unavailable"}"
}
+ private fun sendTestNotification() {
+ saveSettings()
+ if (!notificationSyncSwitch.isChecked) {
+ Toast.makeText(this, R.string.test_notification_enable_sync, Toast.LENGTH_SHORT).show()
+ return
+ }
+
+ val relay = RelayForegroundService.instance
+ if (relay?.isConnectedForWrites() != true) {
+ Toast.makeText(this, R.string.test_notification_not_connected, Toast.LENGTH_SHORT).show()
+ return
+ }
+
+ val now = System.currentTimeMillis()
+ val sent = relay.sendNotificationUpsert(
+ stableId = "inputflow-test:$now",
+ app = getString(R.string.app_name),
+ packageName = packageName,
+ title = getString(R.string.test_notification_title),
+ body = getString(R.string.test_notification_body),
+ postedAtMs = now
+ )
+ Toast.makeText(
+ this,
+ if (sent) R.string.test_notification_sent else R.string.test_notification_failed,
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+
private fun saveSettings() {
val prefs = getSharedPreferences(RelayForegroundService.PREFS, Context.MODE_PRIVATE)
val cursorSize = when (cursorSizeGroup.checkedButtonId) {
@@ -207,6 +251,7 @@ class SettingsActivity : AppCompatActivity() {
.putString(KEY_CURSOR_COLOR, cursorColor)
.putFloat(KEY_SENSITIVITY, sensitivitySlider.value)
.putBoolean(RelayForegroundService.KEY_LAPTOP_TYPING_ENABLED, laptopTypingSwitch.isChecked)
+ .putBoolean(RelayForegroundService.KEY_NOTIFICATION_SYNC_ENABLED, notificationSyncSwitch.isChecked)
.putString(KEY_KEYBOARD_MODE, keyboardMode)
.putString(KEY_CONNECTION_MODE, connectionMode)
.putString(InjectorManager.KEY_INJECT_BACKEND, injectBackend)
diff --git a/android/app/src/main/res/layout/activity_settings.xml b/android/app/src/main/res/layout/activity_settings.xml
index 45dd989..19c0fc2 100644
--- a/android/app/src/main/res/layout/activity_settings.xml
+++ b/android/app/src/main/res/layout/activity_settings.xml
@@ -375,6 +375,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
InputFlow
InputFlow controlled peer
InputFlow keyboard
+ InputFlow notification sync
Connected
@@ -44,6 +45,16 @@
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.
+ Notifications
+ Sync Android notifications
+ Mirrors allowed Android notifications to connected InputFlow desktops. Sensitive-looking messages are skipped.
+ Send test notification
+ InputFlow test notification
+ If this appears on your desktop, notification sync is working.
+ Test notification sent to desktop.
+ Could not send test notification.
+ Connect to InputFlow before sending a test.
+ Enable notification sync first.
Device layout
Edit Device Layout
Drag devices to set their position relative to each other. The green device is this tablet.
diff --git a/docs/android.md b/docs/android.md
index 78fe34b..bfbae6a 100644
--- a/docs/android.md
+++ b/docs/android.md
@@ -13,6 +13,7 @@ android_relay_port=15102
android_relay_secret=replace-with-a-long-random-secret
android_peer_name=pixel-8
android_capture_backend=none
+notification_sync_enabled=false
```
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.
@@ -38,6 +39,24 @@ JAVA_HOME=/path/to/jdk-21 ./gradlew :app:assembleDebug
The relay foreground service uses the `connectedDevice` type so Android 15's
`dataSync` runtime cap does not kill long sessions.
+### Notification sync
+
+Android-to-Linux notification mirroring is available behind two opt-ins:
+
+1. Set `notification_sync_enabled=true` in the Linux config.
+2. In the Android app, open **Settings → Notifications**, enable sync, and grant
+ Android notification listener access when Settings opens.
+
+When enabled, the Android app sends `notification_upsert` and
+`notification_dismiss` frames over the existing authenticated relay. The Linux
+client displays mirrored Android notifications through `notify-send` when it is
+available. InputFlow skips its own notifications, ongoing/group-summary
+notifications, hidden-content notifications, and messages that look like OTP,
+password, banking, or card content.
+
+This first slice is Android → Linux. Linux and Windows notification capture can
+reuse the same relay frames, but require platform-specific listener work.
+
### Input injection backends (Settings → Input method)
Choose how input is delivered to the phone:
diff --git a/src/AndroidRelay.cpp b/src/AndroidRelay.cpp
index 7154784..5d06d71 100644
--- a/src/AndroidRelay.cpp
+++ b/src/AndroidRelay.cpp
@@ -13,6 +13,7 @@
#include
#include
#include
+#include
#include
#include
@@ -125,11 +126,43 @@ std::optional JsonStringValue(const std::string& json, const std::s
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;
+ std::string value;
+ bool escaping = false;
+ for (std::size_t index = valueStart; index < json.size(); ++index) {
+ const char ch = json[index];
+ if (escaping) {
+ switch (ch) {
+ case '"':
+ case '\\':
+ case '/':
+ value.push_back(ch);
+ break;
+ case 'n':
+ value.push_back('\n');
+ break;
+ case 'r':
+ value.push_back('\r');
+ break;
+ case 't':
+ value.push_back('\t');
+ break;
+ default:
+ value.push_back(ch);
+ break;
+ }
+ escaping = false;
+ continue;
+ }
+ if (ch == '\\') {
+ escaping = true;
+ continue;
+ }
+ if (ch == '"') {
+ return value;
+ }
+ value.push_back(ch);
}
- return json.substr(valueStart, valueEnd - valueStart);
+ return std::nullopt;
}
std::string EscapeJson(std::string_view value) {
@@ -160,6 +193,37 @@ std::string EscapeJson(std::string_view value) {
return escaped;
}
+void ShowLinuxNotification(const std::string& appName,
+ const std::string& title,
+ const std::string& body) {
+ if (title.empty() && body.empty()) {
+ return;
+ }
+
+ const std::string displayTitle = appName.empty() || title.empty()
+ ? (title.empty() ? "Android notification" : title)
+ : appName + ": " + title;
+
+ const pid_t child = fork();
+ if (child == 0) {
+ execlp(
+ "notify-send",
+ "notify-send",
+ "-a",
+ "InputFlow",
+ "-i",
+ "inputflow",
+ displayTitle.c_str(),
+ body.c_str(),
+ static_cast(nullptr));
+ _exit(127);
+ }
+ if (child > 0) {
+ int status = 0;
+ (void)waitpid(child, &status, 0);
+ }
+}
+
} // namespace
AndroidRelayServer::AndroidRelayServer(AndroidRelayOptions options)
@@ -347,6 +411,19 @@ void AndroidRelayServer::HandleClient(std::shared_ptr session) {
if (callback) {
callback(*frame);
}
+ } else if (*type == "notification_upsert") {
+ if (m_options.notificationSyncEnabled) {
+ ShowLinuxNotification(
+ JsonStringValue(*frame, "app").value_or("Android"),
+ JsonStringValue(*frame, "title").value_or(""),
+ JsonStringValue(*frame, "body").value_or(""));
+ }
+ } else if (*type == "notification_dismiss") {
+ if (m_options.notificationSyncEnabled) {
+ std::cout << "[ANDROID] Notification dismissed: "
+ << JsonStringValue(*frame, "stable_id").value_or("")
+ << std::endl;
+ }
}
}
diff --git a/src/AndroidRelay.h b/src/AndroidRelay.h
index e89dbc1..fb6d478 100644
--- a/src/AndroidRelay.h
+++ b/src/AndroidRelay.h
@@ -21,6 +21,7 @@ struct AndroidRelayOptions {
bool layoutEditorEnabled{true};
int androidDeviceWidth{1920};
int androidDeviceHeight{1200};
+ bool notificationSyncEnabled{false};
};
class AndroidRelayServer {
diff --git a/src/AppConfig.cpp b/src/AppConfig.cpp
index e9c544a..a0ebeb2 100644
--- a/src/AppConfig.cpp
+++ b/src/AppConfig.cpp
@@ -501,6 +501,16 @@ bool ParseAppConfig(std::string_view text, AppConfig& outConfig, std::string* er
continue;
}
+ if (key == "notification_sync_enabled" || key == "notifications_sync_enabled") {
+ const auto parsed = ParseConfigBool(value);
+ if (!parsed.has_value()) {
+ SetError(errorMessage, "Config key 'notification_sync_enabled' expects true/false.");
+ return false;
+ }
+ outConfig.notificationSyncEnabled = *parsed;
+ continue;
+ }
+
SetError(errorMessage, "Unknown config key '" + std::string(key) + "' on line " + std::to_string(lineNumber) + ".");
return false;
}
@@ -577,6 +587,7 @@ std::string RenderAppConfig(const AppConfig& config) {
out << "android_layout_editor_enabled=" << RenderBool(config.androidLayoutEditorEnabled) << '\n';
out << "android_device_width=" << config.androidDeviceWidth << '\n';
out << "android_device_height=" << config.androidDeviceHeight << '\n';
+ out << "notification_sync_enabled=" << RenderBool(config.notificationSyncEnabled) << '\n';
return out.str();
}
@@ -599,6 +610,7 @@ std::string RenderSampleAppConfig() {
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 << "# Set notification_sync_enabled=true to mirror Android notifications to this desktop over the Android relay.\n";
out << RenderAppConfig(sample);
return out.str();
}
diff --git a/src/AppConfig.h b/src/AppConfig.h
index f4e1d85..40bedee 100644
--- a/src/AppConfig.h
+++ b/src/AppConfig.h
@@ -45,6 +45,7 @@ struct AppConfig {
bool androidLayoutEditorEnabled{true};
int androidDeviceWidth{1920};
int androidDeviceHeight{1200};
+ bool notificationSyncEnabled{false};
};
AppConfig LoadDefaultAppConfig();
diff --git a/src/TrayController.cpp b/src/TrayController.cpp
index 3942d2d..8c38ee8 100644
--- a/src/TrayController.cpp
+++ b/src/TrayController.cpp
@@ -867,6 +867,7 @@ int RunTrayAndGui(const std::string& binary,
options.androidRelay.layoutEditorEnabled = runtimeConfig.androidLayoutEditorEnabled;
options.androidRelay.androidDeviceWidth = runtimeConfig.androidDeviceWidth;
options.androidRelay.androidDeviceHeight = runtimeConfig.androidDeviceHeight;
+ options.androidRelay.notificationSyncEnabled = runtimeConfig.notificationSyncEnabled;
options.onSessionEstablished = [&](const std::string& host, int port,
const std::string& remoteName, uint32_t, uint32_t localMachineId) {
diff --git a/src/main.cpp b/src/main.cpp
index 2b3f4ae..3e52923 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -1240,6 +1240,7 @@ int RunClient(const mwb::AppConfig& config,
options.androidRelay.layoutEditorEnabled = runtimeConfig.androidLayoutEditorEnabled;
options.androidRelay.androidDeviceWidth = runtimeConfig.androidDeviceWidth;
options.androidRelay.androidDeviceHeight = runtimeConfig.androidDeviceHeight;
+ options.androidRelay.notificationSyncEnabled = runtimeConfig.notificationSyncEnabled;
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());
@@ -1745,6 +1746,8 @@ int HandleDoctorCommand(const std::vector& args) {
" port=" + std::to_string(config.androidRelayPort) +
" peer=" + (config.androidPeerName.empty() ? "" : config.androidPeerName) +
" secret=" + (config.androidRelaySecret.empty() ? "missing" : "configured"));
+ PrintDoctorLine("INFO", "notification sync",
+ config.notificationSyncEnabled ? "enabled" : "disabled");
PrintDoctorLine("INFO", "reconnect", "initial=" + std::to_string(config.reconnectInitialBackoffMs) +
"ms max=" + std::to_string(config.reconnectMaxBackoffMs) +
"ms idle=" + std::to_string(config.reconnectIdleRetryMs) + "ms");
From a932fdb1ab914453ef172988be5dee3ea3530bf3 Mon Sep 17 00:00:00 2001
From: daredoole <49536135+daredoole@users.noreply.github.com>
Date: Wed, 24 Jun 2026 21:07:09 -0400
Subject: [PATCH 6/9] android: enable control for evdev cursor handoff
---
src/ClientRuntime.cpp | 3 +++
src/LocalAndroidInputBridge.cpp | 13 +++++++++++++
src/LocalAndroidInputBridge.h | 1 +
3 files changed, 17 insertions(+)
diff --git a/src/ClientRuntime.cpp b/src/ClientRuntime.cpp
index db530a2..3930faa 100644
--- a/src/ClientRuntime.cpp
+++ b/src/ClientRuntime.cpp
@@ -702,6 +702,9 @@ void ClientRuntime::StartLocalAndroidInputBridge(const ScreenSize& screenSize) {
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_localAndroidInputBridge = std::make_unique(std::move(options));
m_localAndroidInputBridge->Start();
diff --git a/src/LocalAndroidInputBridge.cpp b/src/LocalAndroidInputBridge.cpp
index a04c322..e0fbb11 100644
--- a/src/LocalAndroidInputBridge.cpp
+++ b/src/LocalAndroidInputBridge.cpp
@@ -409,6 +409,10 @@ void LocalAndroidInputBridge::Run() {
activeEntryEdge = transition->entryEdge;
androidX = targetPoint->x;
androidY = targetPoint->y;
+ if (m_options.sendControl && !m_options.sendControl(true)) {
+ active = false;
+ return;
+ }
std::cout << "[ANDROID] Local pointer entered Android from "
<< edgeDirectionName(transition->exitEdge)
<< " edge using " << devices[deviceIndex].name
@@ -419,10 +423,16 @@ void LocalAndroidInputBridge::Run() {
androidY = ClampNormalized(androidY + normalizedDy);
if (!sendMove()) {
active = false;
+ if (m_options.sendControl) {
+ m_options.sendControl(false);
+ }
return;
}
if (MovementReturnsFromEdge(activeEntryEdge, androidX, androidY, normalizedDx, normalizedDy)) {
active = false;
+ if (m_options.sendControl) {
+ m_options.sendControl(false);
+ }
std::cout << "[ANDROID] Local pointer returned to Fedora." << std::endl;
}
};
@@ -531,6 +541,9 @@ void LocalAndroidInputBridge::Run() {
}
}
+ if (active && m_options.sendControl) {
+ m_options.sendControl(false);
+ }
ClosePointerDevices(devices);
XCloseDisplay(display);
}
diff --git a/src/LocalAndroidInputBridge.h b/src/LocalAndroidInputBridge.h
index 01905b6..ccc2255 100644
--- a/src/LocalAndroidInputBridge.h
+++ b/src/LocalAndroidInputBridge.h
@@ -19,6 +19,7 @@ struct LocalAndroidInputBridgeOptions {
int desktopHeight{0};
std::function sendMouse;
std::function sendGesture;
+ std::function sendControl;
};
class LocalAndroidInputBridge {
From c9b8252677ac2818b48aeee93200ded1de36bb00 Mon Sep 17 00:00:00 2001
From: daredoole <49536135+daredoole@users.noreply.github.com>
Date: Wed, 24 Jun 2026 21:10:13 -0400
Subject: [PATCH 7/9] android: fix native cursor overlay and clicks
---
.../com/inputflow/android/InjectorManager.kt | 2 +
.../com/inputflow/android/NativeInjector.kt | 51 +++++++++++++++----
.../android/RelayForegroundService.kt | 2 +-
3 files changed, 44 insertions(+), 11 deletions(-)
diff --git a/android/app/src/main/java/com/inputflow/android/InjectorManager.kt b/android/app/src/main/java/com/inputflow/android/InjectorManager.kt
index cbd82e6..d28b72d 100644
--- a/android/app/src/main/java/com/inputflow/android/InjectorManager.kt
+++ b/android/app/src/main/java/com/inputflow/android/InjectorManager.kt
@@ -157,6 +157,8 @@ object InjectorManager {
return n.handleMouse(frame)
}
+ fun hasNativeInjector(): Boolean = native != null
+
fun handleKeyboard(frame: JSONObject): Boolean {
val n = native ?: return false
return n.handleKeyboard(frame)
diff --git a/android/app/src/main/java/com/inputflow/android/NativeInjector.kt b/android/app/src/main/java/com/inputflow/android/NativeInjector.kt
index ddfc2ac..2610aa8 100644
--- a/android/app/src/main/java/com/inputflow/android/NativeInjector.kt
+++ b/android/app/src/main/java/com/inputflow/android/NativeInjector.kt
@@ -23,6 +23,9 @@ class NativeInjector(
private var py = 0f
private var primed = false
private var metaState = 0
+ private var primaryDownTime: Long = 0L
+ private var secondaryDownTime: Long = 0L
+ private var tertiaryDownTime: Long = 0L
fun ping(): Boolean = try { service.ping() } catch (_: Throwable) { false }
@@ -47,21 +50,46 @@ class NativeInjector(
py = (cy + (baseY - cy) * sens).coerceIn(0f, m.heightPixels - 1f)
inject(pointerEvent(MotionEvent.ACTION_HOVER_MOVE, 0))
}
- WM_LBUTTONUP -> click(MotionEvent.BUTTON_PRIMARY)
- WM_RBUTTONUP -> click(MotionEvent.BUTTON_SECONDARY)
- WM_MBUTTONUP -> click(MotionEvent.BUTTON_TERTIARY)
+ WM_LBUTTONDOWN -> button(MotionEvent.BUTTON_PRIMARY, down = true)
+ WM_LBUTTONUP -> button(MotionEvent.BUTTON_PRIMARY, down = false)
+ WM_RBUTTONDOWN -> button(MotionEvent.BUTTON_SECONDARY, down = true)
+ WM_RBUTTONUP -> button(MotionEvent.BUTTON_SECONDARY, down = false)
+ WM_MBUTTONDOWN -> button(MotionEvent.BUTTON_TERTIARY, down = true)
+ WM_MBUTTONUP -> button(MotionEvent.BUTTON_TERTIARY, down = false)
WM_MOUSEWHEEL -> scroll(frame.optInt("mouseData"))
else -> false
}
}
- private fun click(button: Int): Boolean {
- val down = SystemClock.uptimeMillis()
- val downSent = inject(pointerEvent(MotionEvent.ACTION_DOWN, button, down, down))
- val pressSent = inject(pointerEvent(MotionEvent.ACTION_BUTTON_PRESS, button, down, down))
- val releaseSent = inject(pointerEvent(MotionEvent.ACTION_BUTTON_RELEASE, 0, down, SystemClock.uptimeMillis()))
- val upSent = inject(pointerEvent(MotionEvent.ACTION_UP, 0, down, SystemClock.uptimeMillis()))
- return downSent && pressSent && releaseSent && upSent
+ private fun button(button: Int, down: Boolean): Boolean {
+ val now = SystemClock.uptimeMillis()
+ return if (down) {
+ setButtonDownTime(button, now)
+ val downSent = inject(pointerEvent(MotionEvent.ACTION_DOWN, button, now, now))
+ val pressSent = inject(pointerEvent(MotionEvent.ACTION_BUTTON_PRESS, button, now, now))
+ downSent && pressSent
+ } else {
+ val downTime = getButtonDownTime(button).takeIf { it != 0L } ?: now
+ setButtonDownTime(button, 0L)
+ val releaseSent = inject(pointerEvent(MotionEvent.ACTION_BUTTON_RELEASE, 0, downTime, now))
+ val upSent = inject(pointerEvent(MotionEvent.ACTION_UP, 0, downTime, now))
+ releaseSent && upSent
+ }
+ }
+
+ private fun getButtonDownTime(button: Int): Long = when (button) {
+ MotionEvent.BUTTON_PRIMARY -> primaryDownTime
+ MotionEvent.BUTTON_SECONDARY -> secondaryDownTime
+ MotionEvent.BUTTON_TERTIARY -> tertiaryDownTime
+ else -> 0L
+ }
+
+ private fun setButtonDownTime(button: Int, value: Long) {
+ when (button) {
+ MotionEvent.BUTTON_PRIMARY -> primaryDownTime = value
+ MotionEvent.BUTTON_SECONDARY -> secondaryDownTime = value
+ MotionEvent.BUTTON_TERTIARY -> tertiaryDownTime = value
+ }
}
/** Continuous trackpad-style scroll at the cursor (native ACTION_SCROLL). */
@@ -149,8 +177,11 @@ class NativeInjector(
companion object {
private const val WM_MOUSEMOVE = 0x0200
+ private const val WM_LBUTTONDOWN = 0x0201
private const val WM_LBUTTONUP = 0x0202
+ private const val WM_RBUTTONDOWN = 0x0204
private const val WM_RBUTTONUP = 0x0205
+ private const val WM_MBUTTONDOWN = 0x0207
private const val WM_MBUTTONUP = 0x0208
private const val WM_MOUSEWHEEL = 0x020A
private const val LLKHF_UP = 0x80
diff --git a/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt b/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt
index e56c4ba..81b4f7c 100644
--- a/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt
+++ b/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt
@@ -284,7 +284,7 @@ class RelayForegroundService : Service() {
if (active && accessibility == null) {
Log.w(TAG, "remote control requested but accessibility service is not active")
}
- accessibility?.setRemoteControlActive(active)
+ accessibility?.setRemoteControlActive(active && !InjectorManager.hasNativeInjector())
if (!active) {
InputFlowImeService.restorePreviousKeyboard()
}
From 8eb2f1588ace5d2793402078a7f5adb19805f327 Mon Sep 17 00:00:00 2001
From: daredoole <49536135+daredoole@users.noreply.github.com>
Date: Tue, 28 Jul 2026 22:25:31 -0400
Subject: [PATCH 8/9] Prepare InputFlow for production release
---
.github/workflows/ci.yml | 17 +-
.gitignore | 14 +
CMakeLists.txt | 72 +-
README.md | 389 ++++++++---
android/app/src/main/AndroidManifest.xml | 10 +-
.../inputflow/android/IInjectorService.aidl | 3 +-
.../android/InputFlowAccessibilityService.kt | 7 +-
.../inputflow/android/InputFlowImeService.kt | 6 +-
.../com/inputflow/android/LayoutEditorView.kt | 49 +-
.../com/inputflow/android/MainActivity.kt | 125 +++-
.../com/inputflow/android/NativeInjector.kt | 54 +-
.../android/NotificationSyncBridge.kt | 103 ++-
.../inputflow/android/PairingSecretStore.kt | 114 ++++
.../android/RelayForegroundService.kt | 262 ++++---
.../com/inputflow/android/RelayProtocol.kt | 175 ++++-
.../inputflow/android/RootInjectorService.kt | 4 +-
.../com/inputflow/android/SettingsActivity.kt | 24 +-
.../android/ShizukuInjectorService.kt | 4 +-
.../com/inputflow/android/SystemInject.kt | 21 +-
.../main/res/layout/activity_key_mapper.xml | 3 +-
.../res/layout/activity_layout_editor.xml | 3 +-
.../app/src/main/res/layout/activity_main.xml | 15 +-
.../src/main/res/layout/activity_settings.xml | 24 +-
.../app/src/main/res/layout/row_shortcut.xml | 2 +-
android/app/src/main/res/menu/main_menu.xml | 2 +-
.../app/src/main/res/values-night/colors.xml | 37 +-
.../app/src/main/res/values-night/styles.xml | 16 +
android/app/src/main/res/values/colors.xml | 35 +-
android/app/src/main/res/values/strings.xml | 24 +
android/app/src/main/res/values/styles.xml | 13 +-
android/app/src/main/res/xml/backup_rules.xml | 8 +
.../main/res/xml/data_extraction_rules.xml | 17 +
.../inputflow/android/RelayProtocolTest.kt | 72 ++
.../16x16/status/inputflow-tray-attention.png | Bin 371 -> 505 bytes
.../16x16/status/inputflow-tray-busy.png | Bin 374 -> 533 bytes
.../16x16/status/inputflow-tray-offline.png | Bin 375 -> 542 bytes
.../hicolor/16x16/status/inputflow-tray.png | Bin 303 -> 463 bytes
.../22x22/status/inputflow-tray-attention.png | Bin 549 -> 628 bytes
.../22x22/status/inputflow-tray-busy.png | Bin 543 -> 684 bytes
.../22x22/status/inputflow-tray-offline.png | Bin 550 -> 693 bytes
.../hicolor/22x22/status/inputflow-tray.png | Bin 408 -> 583 bytes
.../24x24/status/inputflow-tray-attention.png | Bin 543 -> 729 bytes
.../24x24/status/inputflow-tray-busy.png | Bin 543 -> 783 bytes
.../24x24/status/inputflow-tray-offline.png | Bin 546 -> 785 bytes
.../hicolor/24x24/status/inputflow-tray.png | Bin 417 -> 677 bytes
.../32x32/status/inputflow-tray-attention.png | Bin 735 -> 881 bytes
.../32x32/status/inputflow-tray-busy.png | Bin 727 -> 939 bytes
.../32x32/status/inputflow-tray-offline.png | Bin 733 -> 976 bytes
.../hicolor/32x32/status/inputflow-tray.png | Bin 581 -> 795 bytes
.../48x48/status/inputflow-tray-attention.png | Bin 1101 -> 1309 bytes
.../48x48/status/inputflow-tray-busy.png | Bin 1081 -> 1408 bytes
.../48x48/status/inputflow-tray-offline.png | Bin 1099 -> 1425 bytes
.../hicolor/48x48/status/inputflow-tray.png | Bin 839 -> 1184 bytes
.../status/inputflow-tray-attention.svg | 55 +-
.../scalable/status/inputflow-tray-busy.svg | 55 +-
.../status/inputflow-tray-offline.svg | 53 +-
.../scalable/status/inputflow-tray.svg | 45 +-
assets/icons/inputflow-tray-attention.svg | 55 +-
assets/icons/inputflow-tray-busy.svg | 55 +-
assets/icons/inputflow-tray-offline.svg | 53 +-
assets/icons/inputflow-tray.svg | 45 +-
docs/android.md | 5 +
docs/public-repository-audit.md | 65 ++
docs/release-checklist.md | 23 +
docs/release-readiness-report.md | 63 ++
docs/security-privacy.md | 45 ++
packaging/portable/README.md | 17 +
packaging/portable/inputflow-controller | 13 +
scripts/audit-public-repo.py | 163 +++++
scripts/generate-release-metadata.py | 205 ++++++
scripts/inputflow-diagnostics-bundle.sh | 112 ++-
scripts/release-gate.sh | 68 ++
scripts/validate-rpm-packaging.sh | 2 +-
src/AndroidRelay.cpp | 641 +++++++++++++++++-
src/AndroidRelay.h | 30 +
src/AppConfig.cpp | 36 +-
src/AppState.cpp | 22 +-
src/ClipboardManager.cpp | 101 ++-
src/ClipboardManager.h | 4 +-
src/Discovery.cpp | 89 ++-
src/GuiMainWindow.cpp | 182 ++++-
src/LibeiInputCaptureBridge.cpp | 208 ++++--
src/LibeiInputCaptureBridge.h | 5 +
src/NetworkManager.cpp | 82 ++-
src/PeerRecovery.cpp | 16 +-
src/SecureFile.cpp | 125 ++++
src/SecureFile.h | 16 +
src/TrayController.cpp | 141 +++-
src/main.cpp | 69 +-
tests/test_diagnostics_privacy.sh | 58 ++
tests/test_main.cpp | 113 +++
tests/test_portable_archive.sh | 55 ++
tests/test_protocol_security.cpp | 10 +
tools/icon-generator/build-masters.js | 56 +-
94 files changed, 4084 insertions(+), 896 deletions(-)
create mode 100644 android/app/src/main/java/com/inputflow/android/PairingSecretStore.kt
create mode 100644 android/app/src/main/res/values-night/styles.xml
create mode 100644 android/app/src/main/res/xml/backup_rules.xml
create mode 100644 android/app/src/main/res/xml/data_extraction_rules.xml
create mode 100644 android/app/src/test/java/com/inputflow/android/RelayProtocolTest.kt
create mode 100644 docs/public-repository-audit.md
create mode 100644 docs/release-checklist.md
create mode 100644 docs/release-readiness-report.md
create mode 100644 docs/security-privacy.md
create mode 100644 packaging/portable/README.md
create mode 100755 packaging/portable/inputflow-controller
create mode 100644 scripts/audit-public-repo.py
create mode 100644 scripts/generate-release-metadata.py
create mode 100755 scripts/release-gate.sh
create mode 100644 src/SecureFile.cpp
create mode 100644 src/SecureFile.h
create mode 100755 tests/test_diagnostics_privacy.sh
create mode 100755 tests/test_portable_archive.sh
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4a55203..f5e3a5e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -61,6 +61,12 @@ jobs:
- name: Test
run: ctest --test-dir build --output-on-failure
+ - name: Package and smoke-test portable archive
+ run: |
+ cmake --build build --target package
+ archive="$(find build -maxdepth 1 -name 'inputflow-*.tar.gz' -print -quit)"
+ tests/test_portable_archive.sh "$archive"
+
- name: Compiler cache stats
run: ccache --show-stats
@@ -174,7 +180,14 @@ jobs:
run: |
bash -n mwb-desktop-ui.sh
bash -n scripts/inputflow-diagnostics-bundle.sh
+ bash -n scripts/release-gate.sh
bash -n scripts/validate-rpm-packaging.sh
+ bash -n tests/test_diagnostics_privacy.sh
+ bash -n tests/test_portable_archive.sh
+ python3 -m py_compile scripts/generate-release-metadata.py
+
+ - name: Verify diagnostics privacy
+ run: tests/test_diagnostics_privacy.sh scripts/inputflow-diagnostics-bundle.sh
- name: Validate RPM packaging skeleton
run: scripts/validate-rpm-packaging.sh
@@ -196,7 +209,7 @@ jobs:
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- - name: Build debug APK and run unit tests
+ - name: Build release APK, run unit tests, and enforce lint
working-directory: android
run: |
- ./gradlew :app:assembleDebug :app:testDebugUnitTest --no-daemon --stacktrace
+ ./gradlew :app:assembleRelease :app:testDebugUnitTest :app:lintRelease --no-daemon --stacktrace
diff --git a/.gitignore b/.gitignore
index 6e5416e..3ef83b5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,6 +18,20 @@ mwb-socket-trace-*.txt
inputflow-windows-pair-*.ps1
AGENTS.md
artifacts/
+graphify-out/
+inputflow-diagnostics-*.tar.gz
+inputflow-diagnostics-*.zip
+
+# Local configuration and credentials
+.env
+.env.*
+!.env.example
+config.ini
+*.pem
+*.key
+*.p12
+*.pfx
+*.mobileprovision
# Android build / local SDK + signing material (never commit)
.gradle/
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 7416169..afd2e81 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -1,5 +1,7 @@
cmake_minimum_required(VERSION 3.10)
-project(mwb_client CXX)
+project(mwb_client VERSION 0.2.0 LANGUAGES CXX)
+
+include(GNUInstallDirs)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -50,6 +52,7 @@ add_executable(mwb_client
src/main.cpp
src/PeerRecovery.cpp
src/SecretStore.cpp
+ src/SecureFile.cpp
src/TopologyModel.cpp
src/ClipboardManager.cpp
src/CryptoHelper.cpp
@@ -120,8 +123,10 @@ if (BUILD_TESTING)
src/AppState.cpp
src/Discovery.cpp
src/PeerRecovery.cpp
+ src/SecureFile.cpp
)
target_include_directories(mwb_client_unit_tests PRIVATE src)
+ target_compile_definitions(mwb_client_unit_tests PRIVATE MWB_TESTING=1)
target_compile_options(mwb_client_unit_tests PRIVATE -Wall -Wextra -Wpedantic)
target_link_libraries(mwb_client_unit_tests PRIVATE OpenSSL::Crypto pthread)
mwb_apply_sanitizers(mwb_client_unit_tests)
@@ -243,6 +248,13 @@ if (BUILD_TESTING)
)
add_test(NAME mwb_client_doctor_invalid_config COMMAND mwb_client doctor --config "${CMAKE_CURRENT_SOURCE_DIR}/tests/invalid_config.ini")
set_tests_properties(mwb_client_doctor_invalid_config PROPERTIES WILL_FAIL TRUE)
+ add_test(
+ NAME inputflow_diagnostics_privacy
+ COMMAND bash
+ "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_diagnostics_privacy.sh"
+ "${CMAKE_CURRENT_SOURCE_DIR}/scripts/inputflow-diagnostics-bundle.sh"
+ )
+ set_tests_properties(inputflow_diagnostics_privacy PROPERTIES TIMEOUT 30)
endif()
if (PkgConfig_FOUND)
@@ -263,3 +275,61 @@ if (PkgConfig_FOUND)
mwb_apply_sanitizers(mwb_tray)
endif()
endif()
+
+install(TARGETS mwb_client RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}")
+if (TARGET mwb_tray)
+ install(TARGETS mwb_tray RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}")
+endif()
+install(
+ PROGRAMS packaging/portable/inputflow-controller
+ DESTINATION "${CMAKE_INSTALL_BINDIR}"
+)
+install(
+ PROGRAMS mwb-desktop-ui.sh
+ DESTINATION "${CMAKE_INSTALL_LIBEXECDIR}/inputflow"
+)
+install(
+ PROGRAMS scripts/inputflow-diagnostics-bundle.sh
+ DESTINATION "${CMAKE_INSTALL_LIBEXECDIR}/inputflow/scripts"
+)
+install(
+ FILES packaging/usr/lib/systemd/user/mwb-client.service
+ DESTINATION "${CMAKE_INSTALL_LIBDIR}/systemd/user"
+)
+install(
+ FILES
+ packaging/usr/lib/modules-load.d/mwb-client-uinput.conf
+ packaging/usr/lib/udev/rules.d/70-mwb-client-uinput.rules
+ packaging/usr/lib/sysusers.d/mwb-client.conf
+ DESTINATION "${CMAKE_INSTALL_DATADIR}/inputflow/system-integration"
+)
+install(
+ FILES packaging/usr/share/applications/inputflow.desktop
+ DESTINATION "${CMAKE_INSTALL_DATADIR}/applications"
+)
+install(DIRECTORY assets/hicolor/ DESTINATION "${CMAKE_INSTALL_DATADIR}/icons/hicolor")
+install(
+ FILES
+ README.md
+ SECURITY.md
+ LICENSE
+ docs/compatibility.md
+ docs/security-privacy.md
+ docs/public-repository-audit.md
+ docs/release-checklist.md
+ docs/release-readiness-report.md
+ packaging/portable/README.md
+ DESTINATION "${CMAKE_INSTALL_DOCDIR}"
+)
+
+set(CPACK_GENERATOR "TGZ")
+set(CPACK_PACKAGE_NAME "inputflow")
+set(CPACK_PACKAGE_VENDOR "InputFlow")
+set(CPACK_PACKAGE_CONTACT "InputFlow maintainers")
+set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "Trusted-LAN Linux client for PowerToys Mouse Without Borders")
+set(CPACK_PACKAGE_FILE_NAME
+ "inputflow-${PROJECT_VERSION}-${CMAKE_SYSTEM_NAME}-${CMAKE_SYSTEM_PROCESSOR}"
+)
+set(CPACK_INCLUDE_TOPLEVEL_DIRECTORY ON)
+set(CPACK_PACKAGE_CHECKSUM "SHA256")
+include(CPack)
diff --git a/README.md b/README.md
index 48f1880..a8e4f20 100644
--- a/README.md
+++ b/README.md
@@ -1,158 +1,329 @@
# InputFlow
-
-
+[](https://github.com/daredoole/inputflow-linux/actions/workflows/ci.yml)
+[](docs/release-readiness-report.md)
+[](CHANGELOG.md)
+[](LICENSE)
+
+InputFlow is a native C++17 Linux companion for
+[Microsoft PowerToys Mouse Without Borders](https://learn.microsoft.com/windows/powertoys/mouse-without-borders).
+It lets a Windows keyboard and pointer control Linux, synchronizes clipboard
+content, provides Linux tray and service integration, and can expose an
+encrypted relay to an Android peer.
+
+> **Public beta:** InputFlow is usable, but desktop-compositor and reconnect
+> edge cases are still being validated. Review the
+> [release-readiness report](docs/release-readiness-report.md) and
+> [compatibility guide](docs/compatibility.md) before deployment.
+
+
+
+## Contents
+
+- [Why InputFlow](#why-inputflow)
+- [Features](#features)
+- [Compatibility](#compatibility)
+- [Security and privacy](#security-and-privacy)
+- [Requirements](#requirements)
+- [Build from source](#build-from-source)
+- [First run](#first-run)
+- [Connection modes](#connection-modes)
+- [Command line](#command-line)
+- [Testing and release builds](#testing-and-release-builds)
+- [Documentation](#documentation)
+- [Support and contributing](#support-and-contributing)
+- [License and attribution](#license-and-attribution)
+
+## Why InputFlow
+
+PowerToys Mouse Without Borders does not provide a native Linux peer.
+InputFlow fills that gap while keeping the default workflow compatible with
+PowerToys machine placement. Advanced topology, Android relay, diagnostics,
+and recovery tools remain opt-in.
+
+InputFlow is not a Barrier, Synergy, Input Leap, Deskflow, or Cursr protocol
+implementation.
+
+## Features
+
+- Keyboard, pointer, media-key, and absolute-coordinate input on Linux.
+- Text, HTML, and image clipboard synchronization.
+- PowerToys-compatible Windows pairing helper.
+- Automatic reconnection and approved-peer rediscovery after DHCP, VPN,
+ resume, or network changes.
+- GTK controller and system tray for status, peers, settings, diagnostics,
+ service control, and topology.
+- User-level systemd service integration.
+- Optional multi-monitor topology and edge handoff.
+- Optional Android relay with Accessibility, Shizuku, or root injection
+ backends.
+- Privacy-redacted, local-only diagnostics bundles.
+- Portable Linux archive, Android APK, SBOM, checksums, and provenance
+ generated by the release gate.
+
+## Compatibility
+
+| Component | Status | Notes |
+| --- | --- | --- |
+| Linux on X11 | Supported beta path | Uses `/dev/uinput`; clipboard requires `xclip` or `xsel`. |
+| Linux on Wayland | Supported with caveats | Uses `/dev/uinput`; clipboard requires `wl-clipboard`; the compositor may display one input-capture permission request. |
+| PowerToys MWB on Windows | Target peer | Use the exported pairing helper whenever possible. |
+| Android | Optional beta peer | Requires the InputFlow Android app and an enabled relay. |
+| Secret Service/keyring | Supported when available | Recommended for interactive desktop sessions. |
+| Protected key file | Supported | Suitable for services when owner-only permissions are maintained. |
+
+The default is PowerToys-compatible machine-level placement. Display-level
+topology is disabled unless explicitly configured. See
+[Compatibility](docs/compatibility.md) and [Topology](docs/topology.md) for
+desktop-specific behavior.
+
+## Security and privacy
+
+- Configuration and state files are written atomically with owner-only
+ permissions and symbolic-link targets are rejected.
+- Pairing keys can be stored in the desktop keyring or in a protected file;
+ inline configuration keys are supported but discouraged.
+- The Android relay authenticates sessions with HMAC and encrypts
+ post-authentication frames with directional AES-256-GCM keys, ordered
+ sequence numbers, and replay rejection.
+- PowerToys compatibility uses the protocol and cryptography required by the
+ PowerToys MWB peer, including AES-256-CBC on that transport.
+- Diagnostics never upload automatically. Network and journal collection are
+ opt-in, and likely secrets, usernames, hostnames, addresses, and input
+ metadata are redacted.
+- Android backups exclude protected application data, and pairing secrets are
+ protected with Android Keystore.
+
+Read [Security and privacy](docs/security-privacy.md) for the trust model,
+stored data, network exposure, and diagnostics behavior. Report
+vulnerabilities using [SECURITY.md](SECURITY.md), not a public issue.
+
+## Requirements
+
+Core build requirements:
+
+- A C++17 compiler
+- CMake 3.16 or newer
+- OpenSSL, zlib, X11, and pkg-config development files
+
+Optional integrations:
+
+- GTK 3 and Ayatana AppIndicator for the tray/controller
+- libei, GLib/GIO, and libinput for Wayland input capture and gestures
+- `wl-clipboard` on Wayland, or `xclip`/`xsel` on X11
+- systemd for user-service integration
+
+Ubuntu/Debian build dependencies:
-InputFlow is a native C++17 Linux companion for [Microsoft PowerToys "Mouse Without Borders"](https://learn.microsoft.com/en-us/windows/powertoys/mouse-without-borders), enabling seamless cursor and keyboard sharing between Linux and Windows.
+```bash
+sudo apt-get install -y build-essential cmake pkg-config libssl-dev zlib1g-dev \
+ libx11-dev libei-dev libinput-dev python3-gi gir1.2-gtk-3.0 \
+ libayatana-appindicator3-dev
+```
-## 🚀 Quick Start (Tray & UI Setup)
+Fedora build dependencies:
-Recommended first-run flow for most users. (First build the client — see
-[Build & Installation](#️-build--installation) below — then:)
+```bash
+sudo dnf install -y gcc-c++ cmake make pkgconf-pkg-config openssl-devel \
+ zlib-devel libX11-devel libei-devel libinput-devel python3-gobject gtk3 \
+ libayatana-appindicator3-devel
+```
-1. **Install Prerequisites:**
- - **Fedora:** `sudo dnf install python3-gobject gtk3 libayatana-appindicator3`
- - **Ubuntu/Debian:** `sudo apt install python3-gi gir1.2-gtk-3.0 libayatana-appindicator3-0.1`
-2. **Launch Setup UI:** Run `./mwb-desktop-ui.sh menu`
-3. **Configure:**
- - Go to **Settings** → enter your Windows host and Security Key. Prefer the
- peer's **name** over a hardcoded IP (or click **Discover…** to pick it) so
- InputFlow can follow the peer automatically if its IP changes.
-4. **Use PowerToys layout for normal setups:**
- - If this Linux/Fedora machine has one monitor, do not configure topology. Let Windows PowerToys Mouse Without Borders own the Linux/Windows machine placement.
- - If topology was enabled while testing, choose **Use PowerToys Layout Only** to set `topology_enabled=false`.
-5. **Pair with Windows:**
- - In the same UI, use the **Export Helper** option.
- - Run the exported `.ps1` script on your Windows machine to register the Linux peer.
-6. **Start:** Choose **Start Service** or launch the tray with `./build/mwb_tray`.
-7. **Advanced layouts only:** Open **Advanced Topology/Layout** if you have multiple Linux monitors, stacked/asymmetric edges, wrap behavior, or wrong-edge handoff problems.
+Package names can differ by distribution release. Missing optional
+integrations are reported during CMake configuration and by `mwb_client
+doctor`.
-For the full beta setup, health-check, diagnostics, connection-quality, and packaging-verification workflow, see [docs/beta-workflow.md](docs/beta-workflow.md).
+## Build from source
----
+```bash
+git clone https://github.com/daredoole/inputflow-linux.git
+cd inputflow-linux
+cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
+cmake --build build --parallel
+ctest --test-dir build --output-on-failure
+```
-## 🛠️ Build & Installation
+The primary binaries are:
-### 1. Prerequisites
+- `build/mwb_client` — service, CLI, diagnostics, and combined GUI entry point.
+- `build/mwb_tray` — standalone system-tray controller.
-**Ubuntu / Debian:**
-```bash
-sudo apt-get install -y build-essential cmake pkg-config libssl-dev zlib1g-dev \
- libx11-dev python3-gi gir1.2-gtk-3.0 libayatana-appindicator3-dev
-```
+## First run
+
+### 1. Allow rootless input injection
+
+The repository includes a narrowly scoped udev rule and system group:
-**Fedora:**
```bash
-sudo dnf install -y gcc-c++ cmake make pkgconf-pkg-config openssl-devel zlib-devel \
- libX11-devel python3-gobject gtk3 libayatana-appindicator3-devel
+sudo groupadd --system --force inputflow
+sudo install -Dm0644 \
+ packaging/usr/lib/udev/rules.d/70-mwb-client-uinput.rules \
+ /etc/udev/rules.d/70-mwb-client-uinput.rules
+sudo modprobe uinput
+sudo udevadm control --reload-rules
+sudo udevadm trigger --subsystem-match=misc --action=change
+sudo usermod -aG inputflow "$USER"
```
-> Clipboard sync also needs a runtime helper: `wl-clipboard` on Wayland, or
-> `xclip`/`xsel` on X11. `mwb_client doctor` reports what's missing.
+Log out and back in after changing group membership. Do not grant broad access
+to the system `input` group.
-### 2. Compile
+### 2. Create configuration
```bash
-cmake -S . -B build -DMWB_BUILD_TRAY=ON
-cmake --build build -j$(nproc)
+./build/mwb_client init-config \
+ --config ~/.config/mwb-client/config.ini
```
-### 3. Setup `/dev/uinput` (Crucial for Mouse/Keyboard)
+For most users, open the guided controller:
```bash
-sudo modprobe uinput
-sudo groupadd -r inputflow
-sudo usermod -aG inputflow $USER
-echo 'KERNEL=="uinput", GROUP="inputflow", MODE="0660", OPTIONS+="static_node=uinput"' | sudo tee /etc/udev/rules.d/99-inputflow-uinput.rules
-sudo udevadm control --reload-rules && sudo udevadm trigger
+./mwb-desktop-ui.sh menu
```
-*Logout and back in for group changes to take effect.*
----
+Enter the Windows host and shared security key in **Settings**. Prefer a peer
+name over a fixed address so rediscovery can follow network changes. Use a
+keyring secret ID or protected key file for long-lived configuration.
-## ✨ Features
+### 3. Pair Windows
-- **Absolute Cursor Movement:** Precise pointer control across screens.
-- **Keyboard Sync:** Full keyboard sharing with media key support.
-- **Rich Clipboard:** Text, HTML, and **Image** synchronization (plain text stays plain).
-- **Systemd Integration:** Runs as a lightweight user service.
-- **Tray + Dashboard:** Connection status, live peer list, LAN peer discovery, and settings that apply on save.
-- **Self-Healing Reconnect:** Follows a peer across IP changes (DHCP/VPN/resume) without a restart — configure peers by **name**.
-- **Lock on Disconnect:** Optionally lock the Linux session when the controlling peer drops.
-- **Android Peer:** Control an Android device as a screen, with no-root (Accessibility/Shizuku) or root native-grade input injection. See [docs/android.md](docs/android.md).
+Use **Export Windows Pairing Helper** in the controller, or run:
----
+```bash
+./build/mwb_client export-windows-pair \
+ --config ~/.config/mwb-client/config.ini \
+ --output inputflow-windows-pair.ps1
+```
-## ⚠️ Public Beta Status
+Review the generated script, transfer it securely, and run it on the intended
+Windows peer.
-InputFlow is usable today but is still stabilizing. Expect rough edges around reconnection and desktop-environment edge cases.
+### 4. Install and start the user service
-**What is working well:**
-- Windows-to-Linux keyboard/mouse input.
-- Full Clipboard sync (Text/HTML/Images).
-- `systemd` service management.
-- Windows pairing-helper for easy setup.
-- Self-healing reconnect across peer IP changes.
-- Android peer relay (no-root and, optionally, Shizuku/root native injection).
+```bash
+./build/mwb_client install-user-service \
+ --config ~/.config/mwb-client/config.ini
+systemctl --user daemon-reload
+systemctl --user enable --now mwb-client.service
+```
-See the [CHANGELOG](CHANGELOG.md) for what's new and [docs/](docs/) for the full
-guides (compatibility, topology, Android, beta workflow, MWB-parity roadmap).
+Open the tray:
----
+```bash
+./build/mwb_tray
+```
-## 📖 Advanced Usage & CLI
+On KDE Plasma, the InputFlow icon may initially appear in the tray overflow.
+On Wayland, approve the single desktop input-capture request if topology
+handoff is enabled. A timeout or dismissal does not trigger repeated prompts;
+restart InputFlow deliberately to request permission again.
-For power users who prefer manual control:
+Run a health check after setup:
```bash
-./build/mwb_client run --config ~/.config/mwb-client/config.ini
-./build/mwb_client discover
-./build/mwb_client doctor --config ~/.config/mwb-client/config.ini
+./build/mwb_client doctor \
+ --config ~/.config/mwb-client/config.ini
```
-See the full [documentation section](#detailed-documentation) for environment variables and protocol details.
-
-User-facing beta operations:
-
-- [Guided Windows pairing and export helper](docs/beta-workflow.md#guided-pairing-and-export-helper)
-- [Topology/layout wizard](docs/beta-workflow.md#topologylayout-wizard)
-- [Health checks and diagnostics bundle](docs/beta-workflow.md#health-check)
-- [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)
+The complete guided procedure is in
+[Beta workflow](docs/beta-workflow.md).
+
+## Connection modes
+
+| Mode | Purpose |
+| --- | --- |
+| `powertoys` | Default. Connect to a Windows PowerToys MWB peer. |
+| `inputflow` | Run native InputFlow services, primarily the Android relay in this beta. |
+| `hybrid` | Keep the PowerToys connection while enabling native InputFlow peers. |
+
+Linux-to-Linux native transport is not yet a supported release path.
+
+## Command line
+
+```text
+mwb_client run [--config PATH]
+mwb_client discover [--state PATH]
+mwb_client doctor [--config PATH] [--state PATH]
+mwb_client android-pair [--config PATH] [--generate] [--ip]
+mwb_client topology explain [PATH] [--config PATH]
+mwb_client init-config [--config PATH] [--force]
+mwb_client export-windows-pair [options]
+mwb_client install-user-service [--config PATH] [--unit PATH] [--force]
+mwb_client secret-store --secret-id ID [--key-file PATH | --stdin]
+mwb_client secret-clear [--secret-id ID]
+mwb_client gui [--config PATH] [--state PATH]
+```
-
-## Detailed Documentation
+Run `mwb_client --help` for every option. Avoid passing secrets directly on
+the command line because process listings and shell history may expose them.
-### Attribution
-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.
+## Testing and release builds
-### Configuration (`config.ini`)
-Supports `connection_mode`, `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`.
+Run the native regression suite:
-`connection_mode=powertoys` is the default Windows PowerToys/MWB compatibility path. `connection_mode=inputflow` runs native InputFlow peer services without requiring a Windows host/key. `connection_mode=hybrid` enables both paths at once.
+```bash
+ctest --test-dir build --output-on-failure
+```
-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.
+Run the complete production gate:
-Windows PowerToys still owns the Windows-side machine layout. InputFlow topology does not edit PowerToys per-display geometry; it only tells Linux which local display edge should hand off back to Windows. Keep the PowerToys machine position and the InputFlow topology links consistent.
+```bash
+scripts/release-gate.sh
+```
-### Screen Sizing
-The client detects screen size in this order:
-1. Config/CLI overrides
-2. KDE logical geometry (`kscreen-doctor`)
-3. DRM connector modes (`/sys/class/drm`)
-4. 1920x1080 fallback
+The release gate performs repository/privacy checks, shell validation, RPM
+metadata validation, a clean Release build, 18 native regression tests,
+portable-archive checksum and smoke tests, Android release assembly and unit
+tests, Android lint, and generation of an SBOM, provenance, and SHA-256
+checksums.
-### Network & Protocol
-- **Port:** 15101 (Input), 15100 (Clipboard).
-- **Encryption:** AES-256-CBC (PowerToys compatible).
+Generated release artifacts are placed under
+`build-release-gate/release/`. The Android APK is intentionally unsigned and
+must be signed and verified outside the repository before publication.
----
+Preview diagnostics collection without creating an archive:
-## License
-GNU GPL v3.0 — see [LICENSE](LICENSE).
+```bash
+scripts/inputflow-diagnostics-bundle.sh --preview
+```
-InputFlow is independent and not affiliated with Microsoft. Interoperability is based on the open-source [microsoft/PowerToys](https://github.com/microsoft/PowerToys) implementation.
+## Documentation
+
+| Document | Purpose |
+| --- | --- |
+| [Beta workflow](docs/beta-workflow.md) | Guided setup, pairing, diagnostics, and packaging verification. |
+| [Compatibility](docs/compatibility.md) | Desktop, transport, key-storage, and network caveats. |
+| [Security and privacy](docs/security-privacy.md) | Trust boundaries, encryption, data handling, and diagnostics. |
+| [Public repository audit](docs/public-repository-audit.md) | Secret, personal-data, device-data, history, and image-metadata assessment. |
+| [Android](docs/android.md) | Android app, relay, pairing, and injection backends. |
+| [Topology](docs/topology.md) | Optional multi-display topology contract and examples. |
+| [Migration](docs/migration.md) | Migration from other keyboard/mouse sharing tools. |
+| [Release checklist](docs/release-checklist.md) | Required signing, device matrix, soak, and publication checks. |
+| [Release readiness](docs/release-readiness-report.md) | Current automated evidence and remaining external checks. |
+| [Roadmap](docs/roadmap-mwb-parity.md) | PowerToys parity and future work. |
+| [Packaging](packaging/README.md) | Distribution integration and user-service packaging. |
+| [Changelog](CHANGELOG.md) | Version history. |
+
+## Support and contributing
+
+- Search existing [issues](https://github.com/daredoole/inputflow-linux/issues)
+ before opening a bug or feature request.
+- Use the repository issue templates and attach only the redacted diagnostics
+ bundle. Review it before sharing.
+- Follow [CONTRIBUTING.md](CONTRIBUTING.md) for build, test, and pull-request
+ requirements.
+- Participation is governed by the
+ [Code of Conduct](CODE_OF_CONDUCT.md).
+- Security reports follow [SECURITY.md](SECURITY.md).
+
+## License and attribution
+
+InputFlow is licensed under the
+[GNU General Public License v3.0](LICENSE).
+
+This project began as a fork of
+[chrischip/mwb-client-linux](https://github.com/chrischip/mwb-client-linux)
+and has been substantially expanded. InputFlow is independent and is not
+affiliated with Microsoft. PowerToys interoperability is based on the
+open-source [Microsoft PowerToys](https://github.com/microsoft/PowerToys)
+implementation.
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index d3b1aa3..b3c574d 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -1,4 +1,5 @@
-
+
@@ -11,9 +12,12 @@
+ android:enabled="true"
+ tools:ignore="Instantiatable" />
diff --git a/android/app/src/main/aidl/com/inputflow/android/IInjectorService.aidl b/android/app/src/main/aidl/com/inputflow/android/IInjectorService.aidl
index f1a5d95..5f4df61 100644
--- a/android/app/src/main/aidl/com/inputflow/android/IInjectorService.aidl
+++ b/android/app/src/main/aidl/com/inputflow/android/IInjectorService.aidl
@@ -7,5 +7,6 @@ import android.view.InputEvent;
// be injected at system level via InputManager.injectInputEvent.
interface IInjectorService {
boolean ping();
- boolean inject(in InputEvent event);
+ // mode = InputManager.INJECT_INPUT_EVENT_MODE_* (0=ASYNC, 2=WAIT_FOR_FINISH).
+ boolean inject(in InputEvent event, int mode);
}
diff --git a/android/app/src/main/java/com/inputflow/android/InputFlowAccessibilityService.kt b/android/app/src/main/java/com/inputflow/android/InputFlowAccessibilityService.kt
index 53d3913..aae25d1 100644
--- a/android/app/src/main/java/com/inputflow/android/InputFlowAccessibilityService.kt
+++ b/android/app/src/main/java/com/inputflow/android/InputFlowAccessibilityService.kt
@@ -72,7 +72,7 @@ class InputFlowAccessibilityService : AccessibilityService() {
val x = frame.optInt("x")
val y = frame.optInt("y")
val mouseData = frame.optInt("mouseData")
- if (loggedMouseFrames < 5) {
+ if (wParam == WM_MOUSEMOVE && loggedMouseFrames < 5) {
Log.i(TAG, "mouse frame wParam=$wParam x=$x y=$y overlay=${cursorView != null}")
loggedMouseFrames += 1
}
@@ -538,7 +538,10 @@ class InputFlowAccessibilityService : AccessibilityService() {
lineTo(toX, toY)
}
val stroke = GestureDescription.StrokeDescription(path, 0, durationMs)
- dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), null, null)
+ dispatchGesture(
+ GestureDescription.Builder().addStroke(stroke).build(),
+ null,
+ null)
}
private fun scroll(mouseData: Int) {
diff --git a/android/app/src/main/java/com/inputflow/android/InputFlowImeService.kt b/android/app/src/main/java/com/inputflow/android/InputFlowImeService.kt
index 391ffd2..ba37943 100644
--- a/android/app/src/main/java/com/inputflow/android/InputFlowImeService.kt
+++ b/android/app/src/main/java/com/inputflow/android/InputFlowImeService.kt
@@ -32,7 +32,10 @@ class InputFlowImeService : InputMethodService() {
super.onDestroy()
}
- override fun onEvaluateInputViewShown(): Boolean = false
+ override fun onEvaluateInputViewShown(): Boolean {
+ super.onEvaluateInputViewShown()
+ return false
+ }
override fun onCreateInputView(): View {
return View(this)
@@ -83,7 +86,6 @@ class InputFlowImeService : InputMethodService() {
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
}
diff --git a/android/app/src/main/java/com/inputflow/android/LayoutEditorView.kt b/android/app/src/main/java/com/inputflow/android/LayoutEditorView.kt
index bfbc6b0..bec75ed 100644
--- a/android/app/src/main/java/com/inputflow/android/LayoutEditorView.kt
+++ b/android/app/src/main/java/com/inputflow/android/LayoutEditorView.kt
@@ -2,13 +2,13 @@ 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 androidx.core.content.ContextCompat
import kotlin.math.roundToInt
data class DeviceRect(
@@ -34,13 +34,17 @@ class LayoutEditorView @JvmOverloads constructor(
}
private val gridCellPx get() = (64 * resources.displayMetrics.density).toInt()
+ private val deviceRect = RectF()
+ private val cornerRadius = 12f * resources.displayMetrics.density
+ private val remoteDeviceColor = ContextCompat.getColor(context, R.color.colorEditorDevice)
+ private val localDeviceColor = ContextCompat.getColor(context, R.color.colorEditorDeviceLocal)
private val bgPaint = Paint().apply {
- color = Color.parseColor("#0A1628")
+ color = ContextCompat.getColor(context, R.color.colorEditorBg)
style = Paint.Style.FILL
}
private val gridPaint = Paint().apply {
- color = Color.parseColor("#1A2840")
+ color = ContextCompat.getColor(context, R.color.colorEditorGrid)
style = Paint.Style.STROKE
strokeWidth = 1f
}
@@ -54,14 +58,14 @@ class LayoutEditorView @JvmOverloads constructor(
isAntiAlias = true
}
private val labelPaint = Paint().apply {
- color = Color.WHITE
+ color = ContextCompat.getColor(context, R.color.colorEditorText)
textSize = 14f * resources.displayMetrics.density
isAntiAlias = true
typeface = Typeface.DEFAULT_BOLD
textAlign = Paint.Align.CENTER
}
private val badgePaint = Paint().apply {
- color = Color.parseColor("#1A2840")
+ color = ContextCompat.getColor(context, R.color.colorEditorText)
textSize = 10f * resources.displayMetrics.density
isAntiAlias = true
textAlign = Paint.Align.CENTER
@@ -113,25 +117,18 @@ class LayoutEditorView @JvmOverloads constructor(
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
+ deviceRect.set(px.toFloat(), py.toFloat(), (px + pw).toFloat(), (py + ph).toFloat())
- deviceFillPaint.color = if (device.isLocal)
- Color.parseColor("#1B5E20")
- else
- Color.parseColor("#0D47A1")
+ deviceFillPaint.color = if (device.isLocal) localDeviceColor else remoteDeviceColor
deviceFillPaint.alpha = 200
- canvas.drawRoundRect(rect, radius, radius, deviceFillPaint)
+ canvas.drawRoundRect(deviceRect, cornerRadius, cornerRadius, deviceFillPaint)
- deviceStrokePaint.color = if (device.isLocal)
- Color.parseColor("#66BB6A")
- else
- Color.parseColor("#42A5F5")
- canvas.drawRoundRect(rect, radius, radius, deviceStrokePaint)
+ deviceStrokePaint.color = if (device.isLocal) localDeviceColor else remoteDeviceColor
+ canvas.drawRoundRect(deviceRect, cornerRadius, cornerRadius, deviceStrokePaint)
// Label
- val cx = rect.centerX()
- val cy = rect.centerY()
+ val cx = deviceRect.centerX()
+ val cy = deviceRect.centerY()
val lineH = labelPaint.textSize
if (device.isLocal) {
canvas.drawText(device.name, cx, cy - lineH * 0.2f, labelPaint)
@@ -171,14 +168,26 @@ class LayoutEditorView @JvmOverloads constructor(
invalidate()
return true
}
- MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
+ MotionEvent.ACTION_UP -> {
draggingId = null
invalidate()
+ performClick()
+ return true
+ }
+ MotionEvent.ACTION_CANCEL -> {
+ draggingId = null
+ invalidate()
+ return true
}
}
return super.onTouchEvent(event)
}
+ override fun performClick(): Boolean {
+ super.performClick()
+ return true
+ }
+
fun getLayoutJson(): org.json.JSONArray {
val arr = org.json.JSONArray()
for (device in devices) {
diff --git a/android/app/src/main/java/com/inputflow/android/MainActivity.kt b/android/app/src/main/java/com/inputflow/android/MainActivity.kt
index 512582c..86e3f51 100644
--- a/android/app/src/main/java/com/inputflow/android/MainActivity.kt
+++ b/android/app/src/main/java/com/inputflow/android/MainActivity.kt
@@ -5,6 +5,7 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
+import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
@@ -14,6 +15,7 @@ import android.view.MenuItem
import android.view.inputmethod.InputMethodManager
import android.widget.LinearLayout
import android.widget.TextView
+import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.button.MaterialButton
@@ -65,17 +67,33 @@ class MainActivity : AppCompatActivity() {
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, ""))
+ portField.setText(getString(R.string.port_number, prefs.getInt(RelayForegroundService.KEY_PORT, 15102)))
+ secretField.setText(PairingSecretStore.read(this))
+
+ // Auto-connect on launch when already configured and not already running,
+ // mirroring the desktop client's auto_connect. Saves the user a tap after
+ // every app start (and after a reinstall) and keeps the relay self-healing.
+ val savedHost = prefs.getString(RelayForegroundService.KEY_HOST, "").orEmpty()
+ val savedSecret = PairingSecretStore.read(this)
+ if (savedHost.isNotBlank() && savedSecret.isNotBlank() &&
+ RelayForegroundService.instance == null
+ ) {
+ startRelayService(Intent(this, RelayForegroundService::class.java))
+ }
findViewById(R.id.btnConnect).setOnClickListener {
+ val secret = secretField.text.toString().trim()
+ if (!PairingSecretStore.save(this, secret)) {
+ secretField.error = getString(R.string.secret_storage_error)
+ return@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))
+ promptMissingPermissions()
}
findViewById(R.id.btnRelease).setOnClickListener {
@@ -114,6 +132,11 @@ class MainActivity : AppCompatActivity() {
registerReceiver(statusReceiver, filter)
}
refreshStatus()
+ // Returning from a settings screen: re-check and surface the next missing
+ // permission, but only once the user has actually started a connection.
+ if (RelayForegroundService.currentState != RelayForegroundService.STATE_DISCONNECTED) {
+ promptMissingPermissions()
+ }
}
override fun onPause() {
@@ -140,8 +163,8 @@ class MainActivity : AppCompatActivity() {
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, ""))
+ portField.setText(getString(R.string.port_number, prefs.getInt(RelayForegroundService.KEY_PORT, 15102)))
+ secretField.setText(PairingSecretStore.read(this))
}
private fun refreshStatus() {
@@ -212,18 +235,108 @@ class MainActivity : AppCompatActivity() {
}
}
+ private data class MissingPermission(
+ val label: String,
+ val rationale: String,
+ val open: () -> Unit,
+ )
+
+ private fun isAccessibilityEnabled(): Boolean {
+ val flat = Settings.Secure.getString(
+ contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES
+ ) ?: return false
+ return flat.split(':').any {
+ it.startsWith(packageName) && it.contains("InputFlowAccessibilityService")
+ }
+ }
+
+ private fun isNotificationListenerEnabled(): Boolean {
+ val flat = Settings.Secure.getString(
+ contentResolver, "enabled_notification_listeners"
+ ) ?: return false
+ return flat.split(':').any {
+ it.startsWith(packageName) && it.contains("InputFlowNotificationListenerService")
+ }
+ }
+
+ /** Required grants that Android cannot prompt for inline (deep-link only). */
+ private fun missingRequiredPermissions(): List {
+ val prefs = getSharedPreferences(RelayForegroundService.PREFS, Context.MODE_PRIVATE)
+ val missing = mutableListOf()
+
+ if (!isAccessibilityEnabled()) {
+ missing += MissingPermission(
+ "Accessibility access",
+ "Lets InputFlow show the remote cursor and deliver taps from your computer.",
+ ) { startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) }
+ }
+
+ if (prefs.getBoolean(RelayForegroundService.KEY_NOTIFICATION_SYNC_ENABLED, false) &&
+ !isNotificationListenerEnabled()
+ ) {
+ missing += MissingPermission(
+ "Notification access",
+ "Lets InputFlow mirror this device's notifications to your computer.",
+ ) { startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) }
+ }
+
+ if (Build.VERSION.SDK_INT >= 33 &&
+ checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) !=
+ PackageManager.PERMISSION_GRANTED
+ ) {
+ missing += MissingPermission(
+ "Show notifications",
+ "Lets InputFlow display your computer's notifications on this device.",
+ ) { requestNotificationPermission() }
+ }
+
+ return missing
+ }
+
+ private var permissionDialogShowing = false
+
+ /**
+ * Surfaces the first still-missing required permission as a dialog that
+ * deep-links to the relevant settings page. Re-runs on [onResume], so the
+ * user is walked through each missing grant one screen at a time.
+ */
+ private fun promptMissingPermissions() {
+ if (permissionDialogShowing) return
+ val first = missingRequiredPermissions().firstOrNull() ?: return
+ val remaining = missingRequiredPermissions().size
+ permissionDialogShowing = true
+ AlertDialog.Builder(this)
+ .setTitle("Permission needed")
+ .setMessage(
+ "${first.label}\n\n${first.rationale}" +
+ if (remaining > 1) "\n\n${remaining - 1} more after this." else ""
+ )
+ .setPositiveButton("Open settings") { d, _ ->
+ permissionDialogShowing = false
+ d.dismiss()
+ first.open()
+ }
+ .setNegativeButton("Not now") { d, _ ->
+ permissionDialogShowing = false
+ d.dismiss()
+ }
+ .setOnCancelListener { permissionDialogShowing = false }
+ .show()
+ }
+
private fun applyPairingUri(intent: Intent?) {
val data = intent?.data ?: return
+ intent.data = null
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
+ if (!PairingSecretStore.save(this, secret)) return
getSharedPreferences(RelayForegroundService.PREFS, Context.MODE_PRIVATE)
.edit()
.putString(RelayForegroundService.KEY_HOST, host)
.putInt(RelayForegroundService.KEY_PORT, port)
- .putString(RelayForegroundService.KEY_SECRET, secret)
.apply()
}
diff --git a/android/app/src/main/java/com/inputflow/android/NativeInjector.kt b/android/app/src/main/java/com/inputflow/android/NativeInjector.kt
index 2610aa8..f96dcec 100644
--- a/android/app/src/main/java/com/inputflow/android/NativeInjector.kt
+++ b/android/app/src/main/java/com/inputflow/android/NativeInjector.kt
@@ -26,11 +26,20 @@ class NativeInjector(
private var primaryDownTime: Long = 0L
private var secondaryDownTime: Long = 0L
private var tertiaryDownTime: Long = 0L
+ private var leftDown = false
+ private var touchDownTime: Long = 0L
fun ping(): Boolean = try { service.ping() } catch (_: Throwable) { false }
- private fun inject(event: android.view.InputEvent): Boolean =
- try { service.inject(event) } catch (_: Throwable) { false }
+ private fun inject(
+ event: android.view.InputEvent,
+ mode: Int = SystemInject.MODE_ASYNC,
+ ): Boolean =
+ try {
+ service.inject(event, mode)
+ } catch (_: Throwable) {
+ false
+ }
fun handleMouse(frame: JSONObject): Boolean {
val m = metricsProvider()
@@ -48,10 +57,28 @@ class NativeInjector(
val cy = m.heightPixels / 2f
px = (cx + (baseX - cx) * sens).coerceIn(0f, m.widthPixels - 1f)
py = (cy + (baseY - cy) * sens).coerceIn(0f, m.heightPixels - 1f)
- inject(pointerEvent(MotionEvent.ACTION_HOVER_MOVE, 0))
+ // While the left button is held, a move is a drag (touch move).
+ if (leftDown) inject(touchEvent(MotionEvent.ACTION_MOVE)) else true
+ }
+ // Left click = a real touchscreen tap (down/up) — the reliable path that
+ // registers in every app, exactly like `input tap`. Down→move→up = drag.
+ // Synchronous injection (WAIT_FOR_FINISH) guarantees the DOWN is fully
+ // dispatched before the UP, and a minimum down→up interval is enforced so
+ // the framework's tap detector never sees a zero-duration (ignored) tap.
+ WM_LBUTTONDOWN -> {
+ leftDown = true
+ touchDownTime = SystemClock.uptimeMillis()
+ inject(touchEvent(MotionEvent.ACTION_DOWN), SystemInject.MODE_WAIT_FOR_FINISH)
+ }
+ WM_LBUTTONUP -> {
+ val held = SystemClock.uptimeMillis() - touchDownTime
+ if (held < MIN_TAP_DURATION_MS) {
+ try { Thread.sleep(MIN_TAP_DURATION_MS - held) } catch (_: InterruptedException) {}
+ }
+ val ok = inject(touchEvent(MotionEvent.ACTION_UP), SystemInject.MODE_WAIT_FOR_FINISH)
+ leftDown = false
+ ok
}
- WM_LBUTTONDOWN -> button(MotionEvent.BUTTON_PRIMARY, down = true)
- WM_LBUTTONUP -> button(MotionEvent.BUTTON_PRIMARY, down = false)
WM_RBUTTONDOWN -> button(MotionEvent.BUTTON_SECONDARY, down = true)
WM_RBUTTONUP -> button(MotionEvent.BUTTON_SECONDARY, down = false)
WM_MBUTTONDOWN -> button(MotionEvent.BUTTON_TERTIARY, down = true)
@@ -61,6 +88,20 @@ class NativeInjector(
}
}
+ private fun touchEvent(action: Int): MotionEvent {
+ val now = SystemClock.uptimeMillis()
+ val props = MotionEvent.PointerProperties().apply {
+ id = 0; toolType = MotionEvent.TOOL_TYPE_FINGER
+ }
+ val coords = MotionEvent.PointerCoords().apply {
+ x = px; y = py; pressure = 1f; size = 1f
+ }
+ return MotionEvent.obtain(
+ touchDownTime, now, action, 1,
+ arrayOf(props), arrayOf(coords), 0, 0, 1f, 1f, 0, 0,
+ InputDevice.SOURCE_TOUCHSCREEN, 0)
+ }
+
private fun button(button: Int, down: Boolean): Boolean {
val now = SystemClock.uptimeMillis()
return if (down) {
@@ -187,5 +228,8 @@ class NativeInjector(
private const val LLKHF_UP = 0x80
// Trackpad pixel-delta → scroll-unit scale; smaller = faster scroll.
private const val SCROLL_DIVISOR = 40f
+ // Minimum DOWN→UP interval (ms) so a tap is never zero-duration. AOSP's
+ // monkey tool uses ~5ms; we use a slightly safer floor.
+ private const val MIN_TAP_DURATION_MS = 12L
}
}
diff --git a/android/app/src/main/java/com/inputflow/android/NotificationSyncBridge.kt b/android/app/src/main/java/com/inputflow/android/NotificationSyncBridge.kt
index b22c78a..1a26c8f 100644
--- a/android/app/src/main/java/com/inputflow/android/NotificationSyncBridge.kt
+++ b/android/app/src/main/java/com/inputflow/android/NotificationSyncBridge.kt
@@ -4,11 +4,21 @@ import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
+import android.app.RemoteInput
import android.content.Context
+import android.content.Intent
import android.content.pm.PackageManager
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.drawable.Drawable
import android.os.Build
+import android.os.Bundle
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
+import android.util.Base64
+import android.util.Log
+import java.io.ByteArrayOutputStream
+import java.util.concurrent.ConcurrentHashMap
import org.json.JSONObject
import kotlin.math.absoluteValue
@@ -30,6 +40,13 @@ object NotificationSyncBridge {
RegexOption.IGNORE_CASE
)
+ // Live reply actions keyed by the synced notification's stable_id, so a reply
+ // typed on the desktop can be fired back into the originating app. Bounded so a
+ // long-running listener can't grow unbounded; oldest entries are evicted.
+ private const val MAX_REPLY_ENTRIES = 200
+ private val replyActions = ConcurrentHashMap()
+ private val replyOrder = ArrayDeque()
+
fun sendAndroidNotification(context: Context, sbn: StatusBarNotification) {
if (!isEnabled(context) || sbn.packageName == context.packageName) return
@@ -53,19 +70,97 @@ object NotificationSyncBridge {
if (title.isBlank() && body.isBlank()) return
if (isSensitive(title, body)) return
- RelayForegroundService.instance?.sendNotificationUpsert(
- stableId = stableId(sbn),
+ val sid = stableId(sbn)
+ val replyAction = findReplyAction(notification)
+ if (replyAction != null) {
+ rememberReplyAction(sid, replyAction)
+ }
+
+ RelayForegroundService.sendNotificationUpsert(
+ stableId = sid,
app = appLabel(context, sbn.packageName),
packageName = sbn.packageName,
title = title,
body = body,
- postedAtMs = sbn.postTime
+ postedAtMs = sbn.postTime,
+ iconPng = appIconBase64(context, sbn.packageName),
+ canReply = replyAction != null
)
}
+ /** First notification action carrying a free-text RemoteInput (quick reply). */
+ private fun findReplyAction(notification: Notification): Notification.Action? {
+ val actions = notification.actions ?: return null
+ return actions.firstOrNull { action ->
+ action.remoteInputs?.any { it.allowFreeFormInput } == true
+ }
+ }
+
+ private fun rememberReplyAction(stableId: String, action: Notification.Action) {
+ synchronized(replyOrder) {
+ if (replyActions.put(stableId, action) == null) {
+ replyOrder.addLast(stableId)
+ while (replyOrder.size > MAX_REPLY_ENTRIES) {
+ replyActions.remove(replyOrder.removeFirst())
+ }
+ }
+ }
+ }
+
+ /**
+ * Fires a desktop-typed reply back into the originating app via its
+ * RemoteInput. Returns true if the reply was dispatched.
+ */
+ fun replyToNotification(context: Context, stableId: String, text: String): Boolean {
+ val action = replyActions[stableId] ?: run {
+ Log.w("NotificationSync", "No live reply action is available")
+ return false
+ }
+ val remoteInputs = action.remoteInputs?.takeIf { it.isNotEmpty() } ?: return false
+ return try {
+ val intent = Intent()
+ val results = Bundle()
+ for (ri in remoteInputs) {
+ results.putCharSequence(ri.resultKey, text)
+ }
+ RemoteInput.addResultsToIntent(remoteInputs, intent, results)
+ action.actionIntent.send(context, 0, intent)
+ // A reply consumes the action; drop it so a stale id can't re-fire.
+ synchronized(replyOrder) {
+ if (replyActions.remove(stableId) != null) replyOrder.remove(stableId)
+ }
+ true
+ } catch (_: Exception) {
+ Log.w("NotificationSync", "Failed to send notification reply")
+ false
+ }
+ }
+
+ /** App launcher icon as a base64 PNG, so the desktop can show the real icon. */
+ private fun appIconBase64(context: Context, packageName: String): String? {
+ return try {
+ val drawable: Drawable = context.packageManager.getApplicationIcon(packageName)
+ val size = 96
+ val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(bitmap)
+ drawable.setBounds(0, 0, size, size)
+ drawable.draw(canvas)
+ val out = ByteArrayOutputStream()
+ bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
+ bitmap.recycle()
+ Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
+ } catch (_: Exception) {
+ null
+ }
+ }
+
fun sendAndroidNotificationDismiss(context: Context, sbn: StatusBarNotification) {
if (!isEnabled(context) || sbn.packageName == context.packageName) return
- RelayForegroundService.instance?.sendNotificationDismiss(stableId(sbn), sbn.packageName)
+ val sid = stableId(sbn)
+ synchronized(replyOrder) {
+ if (replyActions.remove(sid) != null) replyOrder.remove(sid)
+ }
+ RelayForegroundService.sendNotificationDismiss(sid, sbn.packageName)
}
fun showMirroredNotification(context: Context, frame: JSONObject) {
diff --git a/android/app/src/main/java/com/inputflow/android/PairingSecretStore.kt b/android/app/src/main/java/com/inputflow/android/PairingSecretStore.kt
new file mode 100644
index 0000000..34ef1b0
--- /dev/null
+++ b/android/app/src/main/java/com/inputflow/android/PairingSecretStore.kt
@@ -0,0 +1,114 @@
+package com.inputflow.android
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyProperties
+import android.util.Base64
+import android.util.Log
+import java.security.KeyStore
+import javax.crypto.Cipher
+import javax.crypto.KeyGenerator
+import javax.crypto.SecretKey
+import javax.crypto.spec.GCMParameterSpec
+
+/**
+ * Stores the Android relay pairing secret with an app-owned Android Keystore
+ * key. Existing plaintext preferences are migrated once and then removed.
+ */
+object PairingSecretStore {
+ private const val TAG = "PairingSecretStore"
+ private const val KEY_ALIAS = "inputflow_pairing_secret_v1"
+ private const val LEGACY_SECRET_KEY = "secret"
+ private const val CIPHERTEXT_KEY = "pairing_secret_ciphertext_v1"
+ private const val IV_KEY = "pairing_secret_iv_v1"
+ private const val TRANSFORMATION = "AES/GCM/NoPadding"
+
+ @Synchronized
+ fun read(context: Context): String {
+ val prefs = context.getSharedPreferences(
+ RelayForegroundService.PREFS,
+ Context.MODE_PRIVATE,
+ )
+ val ciphertext = prefs.getString(CIPHERTEXT_KEY, null)
+ val iv = prefs.getString(IV_KEY, null)
+ if (!ciphertext.isNullOrBlank() && !iv.isNullOrBlank()) {
+ return try {
+ val cipher = Cipher.getInstance(TRANSFORMATION)
+ cipher.init(
+ Cipher.DECRYPT_MODE,
+ encryptionKey(),
+ GCMParameterSpec(128, Base64.decode(iv, Base64.NO_WRAP)),
+ )
+ cipher.updateAAD(context.packageName.toByteArray(Charsets.UTF_8))
+ String(
+ cipher.doFinal(Base64.decode(ciphertext, Base64.NO_WRAP)),
+ Charsets.UTF_8,
+ )
+ } catch (_: Exception) {
+ Log.e(TAG, "Stored pairing secret could not be decrypted")
+ ""
+ }
+ }
+
+ val legacy = prefs.getString(LEGACY_SECRET_KEY, "").orEmpty()
+ if (legacy.isBlank()) return ""
+ if (!save(context, legacy)) {
+ Log.e(TAG, "Plaintext pairing secret migration failed")
+ return ""
+ }
+ return legacy
+ }
+
+ @Synchronized
+ @SuppressLint("ApplySharedPref")
+ fun save(context: Context, secret: String): Boolean {
+ val prefs = context.getSharedPreferences(
+ RelayForegroundService.PREFS,
+ Context.MODE_PRIVATE,
+ )
+ if (secret.isBlank()) {
+ return prefs.edit()
+ .remove(CIPHERTEXT_KEY)
+ .remove(IV_KEY)
+ .remove(LEGACY_SECRET_KEY)
+ .commit()
+ }
+
+ return try {
+ val cipher = Cipher.getInstance(TRANSFORMATION)
+ cipher.init(Cipher.ENCRYPT_MODE, encryptionKey())
+ cipher.updateAAD(context.packageName.toByteArray(Charsets.UTF_8))
+ val ciphertext = cipher.doFinal(secret.toByteArray(Charsets.UTF_8))
+ prefs.edit()
+ .putString(CIPHERTEXT_KEY, Base64.encodeToString(ciphertext, Base64.NO_WRAP))
+ .putString(IV_KEY, Base64.encodeToString(cipher.iv, Base64.NO_WRAP))
+ .remove(LEGACY_SECRET_KEY)
+ .commit()
+ } catch (_: Exception) {
+ Log.e(TAG, "Pairing secret could not be protected")
+ false
+ }
+ }
+
+ private fun encryptionKey(): SecretKey {
+ val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
+ (keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
+
+ val generator = KeyGenerator.getInstance(
+ KeyProperties.KEY_ALGORITHM_AES,
+ "AndroidKeyStore",
+ )
+ generator.init(
+ KeyGenParameterSpec.Builder(
+ KEY_ALIAS,
+ KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
+ )
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+ .setKeySize(256)
+ .build(),
+ )
+ return generator.generateKey()
+ }
+}
diff --git a/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt b/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt
index 81b4f7c..68e029e 100644
--- a/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt
+++ b/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt
@@ -11,7 +11,6 @@ import android.os.IBinder
import android.util.Log
import org.json.JSONArray
import org.json.JSONObject
-import java.io.DataOutputStream
import java.net.Socket
import java.util.concurrent.atomic.AtomicBoolean
@@ -19,9 +18,6 @@ class RelayForegroundService : Service() {
private val running = AtomicBoolean(false)
private var worker: Thread? = null
- @Volatile
- private var output: DataOutputStream? = null
- private val outputLock = Any()
@Volatile
private var remoteControlActive = false
private var deliveredMouseFrames = 0
@@ -38,7 +34,9 @@ class RelayForegroundService : Service() {
if (instance === this) instance = null
running.set(false)
worker?.interrupt()
- output = null
+ // Do NOT null activeSession here: under START_STICKY a stale instance can be
+ // destroyed while a newer instance owns the live stream. The owning
+ // relayLoop clears it on its own disconnect.
deactivateRemoteControl()
broadcastStatus(STATE_DISCONNECTED, "Stopped")
super.onDestroy()
@@ -88,38 +86,6 @@ class RelayForegroundService : Service() {
writeFrame(JSONObject().put("type", "topology_update").put("layout", layout))
}
- fun isConnectedForWrites(): Boolean =
- currentState == STATE_CONNECTED && output != null
-
- fun sendNotificationUpsert(
- stableId: String,
- app: String,
- packageName: String,
- title: String,
- body: String,
- postedAtMs: Long
- ): Boolean =
- writeFrame(
- JSONObject()
- .put("type", "notification_upsert")
- .put("stable_id", stableId)
- .put("origin", "android")
- .put("app", app)
- .put("package", packageName)
- .put("title", title)
- .put("body", body)
- .put("posted_at_ms", postedAtMs)
- )
-
- fun sendNotificationDismiss(stableId: String, packageName: String): Boolean =
- writeFrame(
- JSONObject()
- .put("type", "notification_dismiss")
- .put("stable_id", stableId)
- .put("origin", "android")
- .put("package", packageName)
- )
-
private fun startRelay() {
if (!running.compareAndSet(false, true)) return
InjectorManager.start(this)
@@ -132,19 +98,25 @@ class RelayForegroundService : Service() {
when (frame.optString("type")) {
"control" -> setRemoteControlActive(frame.optBoolean("active", false))
"mouse" -> if (remoteControlActive) {
- // Prefer native injection (Shizuku/root); fall back to accessibility.
+ // Native (Shizuku/root) injects ALL mouse input — moves, clicks, wheel —
+ // as real events. The accessibility overlay still draws the visible
+ // cursor on moves (native pointer injection isn't rendered by Android).
+ val handledNative = InjectorManager.handleMouse(frame)
val accessibility = InputFlowAccessibilityService.instance
val isMove = frame.optInt("wParam") == WM_MOUSEMOVE
- if (isMove && InjectorManager.handleMouse(frame)) {
- // Native pointer motion succeeded.
- } else if (accessibility != null) {
- deliveredMouseFrames += 1
- accessibility.handleMouse(frame)
- } else if (!InjectorManager.handleMouse(frame)) {
- if (droppedMouseFrames < 5) {
- Log.w(TAG, "mouse frame dropped: no injector active")
+ if (isMove) {
+ accessibility?.handleMouse(frame)
+ if (!handledNative && accessibility == null) {
+ if (droppedMouseFrames < 5) {
+ Log.w(TAG, "mouse frame dropped: no injector active")
+ }
+ droppedMouseFrames += 1
+ } else {
+ deliveredMouseFrames += 1
}
- droppedMouseFrames += 1
+ } else if (!handledNative) {
+ // No native injector → accessibility handles the click.
+ accessibility?.handleMouse(frame)
}
}
"keyboard" -> {
@@ -166,16 +138,23 @@ class RelayForegroundService : Service() {
}
}
"gesture" -> if (remoteControlActive) {
- val accessibility = InputFlowAccessibilityService.instance
- if (accessibility != null) {
- accessibility.handleGesture(frame)
- } else if (frame.optString("kind") == "scroll") {
- InjectorManager.handleScroll(frame)
+ // Native continuous scroll first (smooth, no gesture cancellation);
+ // multi-finger swipes/pinch still go through accessibility.
+ val kind = frame.optString("kind")
+ if (kind == "scroll" && InjectorManager.handleScroll(frame)) {
+ // native scroll
+ } else {
+ InputFlowAccessibilityService.instance?.handleGesture(frame)
}
}
"devices_info" -> handleDevicesInfo(frame)
"notification_upsert" -> NotificationSyncBridge.showMirroredNotification(this, frame)
"notification_dismiss" -> NotificationSyncBridge.cancelMirroredNotification(this, frame)
+ "notification_reply" -> NotificationSyncBridge.replyToNotification(
+ this,
+ frame.optString("stable_id"),
+ frame.optString("text")
+ )
}
}
@@ -183,7 +162,7 @@ class RelayForegroundService : Service() {
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 secret = PairingSecretStore.read(this)
val port = prefs.getInt(KEY_PORT, 15102)
if (host.isBlank() || secret.isBlank()) {
startRelayForeground("Missing pairing settings")
@@ -197,46 +176,52 @@ class RelayForegroundService : Service() {
// the Linux box across IP changes (DHCP/VPN), same self-healing
// model as the desktop client. An IP literal resolves to itself.
val address = java.net.InetAddress.getByName(host)
- Log.i(TAG, "relay connecting: $host -> ${address.hostAddress}:$port")
+ Log.i(TAG, "relay connection attempt started")
java.net.Socket().use { socket ->
socket.tcpNoDelay = true
socket.connect(java.net.InetSocketAddress(address, port), 8000)
- val device = "${Build.MANUFACTURER} ${Build.MODEL}".trim()
- val streams = RelayProtocol.authenticate(socket, secret, device)
- output = streams.second
- deliveredMouseFrames = 0
- droppedMouseFrames = 0
- Log.i(TAG, "relay connected to $host:$port as $device")
- deactivateRemoteControl()
- startRelayForeground("Connected to $host:$port")
- broadcastStatus(STATE_CONNECTED, "$host:$port")
- while (running.get()) {
- var frame = RelayProtocol.readFrame(streams.first)
- // Collapse a backlog of mouse-move frames to the newest so the
- // cursor tracks live with no lag under load. Non-move frames are
- // dispatched in order; only intermediate moves are dropped.
- while (frame.optString("type") == "mouse" &&
- frame.optInt("wParam") == WM_MOUSEMOVE &&
- streams.first.available() > 4
- ) {
- val next = RelayProtocol.readFrame(streams.first)
- if (next.optString("type") == "mouse" &&
- next.optInt("wParam") == WM_MOUSEMOVE
+ val device = getString(R.string.default_device_name)
+ val session = RelayProtocol.authenticate(socket, secret, device)
+ synchronized(activeSessionLock) { activeSession = session }
+ try {
+ deliveredMouseFrames = 0
+ droppedMouseFrames = 0
+ Log.i(TAG, "encrypted relay connection authenticated")
+ deactivateRemoteControl()
+ startRelayForeground("Connected securely")
+ broadcastStatus(STATE_CONNECTED, "Connected securely")
+ while (running.get()) {
+ var frame = session.readFrame()
+ // Collapse a backlog of mouse-move frames to the newest so the
+ // cursor tracks live with no lag under load. Non-move frames are
+ // dispatched in order; only intermediate moves are dropped.
+ while (frame.optString("type") == "mouse" &&
+ frame.optInt("wParam") == WM_MOUSEMOVE &&
+ session.hasBufferedFrame()
) {
- frame = next
- } else {
- dispatchFrame(frame, prefs)
- frame = next
- break
+ val next = session.readFrame()
+ if (next.optString("type") == "mouse" &&
+ next.optInt("wParam") == WM_MOUSEMOVE
+ ) {
+ frame = next
+ } else {
+ dispatchFrame(frame, prefs)
+ frame = next
+ break
+ }
}
+ dispatchFrame(frame, prefs)
}
- dispatchFrame(frame, prefs)
+ } finally {
+ deactivateRemoteControl()
+ synchronized(activeSessionLock) {
+ if (activeSession === session) activeSession = null
+ }
+ session.destroy()
}
- deactivateRemoteControl()
}
- } catch (e: Exception) {
- Log.w(TAG, "relay disconnected; retrying", e)
- output = null
+ } catch (_: Exception) {
+ Log.w(TAG, "relay disconnected; retrying")
deactivateRemoteControl()
startRelayForeground("Disconnected; retrying")
broadcastStatus(STATE_DISCONNECTED, "Retrying…")
@@ -248,31 +233,17 @@ class RelayForegroundService : Service() {
private fun handleDevicesInfo(frame: JSONObject) {
val json = frame.toString()
cachedDevicesJson = json
- sendBroadcast(Intent(ACTION_DEVICES_BROADCAST).putExtra(EXTRA_DEVICES_JSON, json))
+ sendBroadcast(
+ Intent(ACTION_DEVICES_BROADCAST)
+ .setPackage(packageName)
+ .putExtra(EXTRA_DEVICES_JSON, json),
+ )
}
private fun sendRelease() {
writeFrame(JSONObject().put("type", "release"))
}
- private fun writeFrame(frame: JSONObject): Boolean {
- return try {
- synchronized(outputLock) {
- output?.let {
- RelayProtocol.writeFrame(it, frame)
- true
- } ?: run {
- Log.w(TAG, "failed to write relay frame type=${frame.optString("type")}: no relay output stream")
- false
- }
- }
- } catch (e: Exception) {
- Log.w(TAG, "failed to write relay frame type=${frame.optString("type")}", e)
- output = null
- broadcastStatus(STATE_DISCONNECTED, "Retrying…")
- false
- }
- }
private fun setRemoteControlActive(active: Boolean) {
val changed = remoteControlActive != active
@@ -284,7 +255,10 @@ class RelayForegroundService : Service() {
if (active && accessibility == null) {
Log.w(TAG, "remote control requested but accessibility service is not active")
}
- accessibility?.setRemoteControlActive(active && !InjectorManager.hasNativeInjector())
+ // Always show the accessibility overlay cursor while controlled — native
+ // pointer injection moves the logical pointer but Android doesn't render it,
+ // so the overlay is what the user actually sees.
+ accessibility?.setRemoteControlActive(active)
if (!active) {
InputFlowImeService.restorePreviousKeyboard()
}
@@ -335,7 +309,6 @@ class RelayForegroundService : Service() {
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 KEY_NOTIFICATION_SYNC_ENABLED = "notification_sync_enabled"
const val KEY_STATUS_STATE = "status_state"
@@ -357,6 +330,83 @@ class RelayForegroundService : Service() {
private const val TAG = "InputFlowRelay"
@Volatile var instance: RelayForegroundService? = null
+ // The live encrypted relay session, shared statically so the notification
+ // listener (a separate service) can always reach the connected socket
+ // regardless of which RelayForegroundService instance it resolves via
+ // [instance] — START_STICKY can leave more than one instance around.
+ @Volatile var activeSession: RelayProtocol.Session? = null
+ val activeSessionLock = Any()
+
+ /** True when the live relay stream is available for outbound writes. */
+ fun isConnectedForWrites(): Boolean =
+ currentState == STATE_CONNECTED && activeSession != null
+
+ // Serializes all outbound relay writes onto a single background thread.
+ // Notification-listener callbacks fire on the MAIN thread, where a socket
+ // write throws NetworkOnMainThreadException; the read loop runs on its own
+ // worker. A single-thread executor both moves writes off the main thread and
+ // guarantees frames never interleave on the socket.
+ private val relayWriteExecutor: java.util.concurrent.ExecutorService =
+ java.util.concurrent.Executors.newSingleThreadExecutor()
+
+ /**
+ * Queues a frame for the live relay stream. Static so callers (the separate
+ * notification-listener service, the settings test button) never depend on a
+ * resolvable RelayForegroundService [instance] — under START_STICKY that can
+ * be null even while the connection is up. Returns true if a connection was
+ * available to enqueue onto; the write itself happens asynchronously.
+ */
+ fun writeFrame(frame: JSONObject): Boolean {
+ val haveStream = synchronized(activeSessionLock) { activeSession != null }
+ if (!haveStream) {
+ Log.w(TAG, "no relay output stream for ${frame.optString("type")}")
+ return false
+ }
+ relayWriteExecutor.execute {
+ try {
+ synchronized(activeSessionLock) {
+ val session = activeSession ?: return@execute
+ session.writeFrame(frame)
+ }
+ } catch (e: Exception) {
+ // Don't tear down activeSession: the owning relayLoop detects a dead
+ // socket via readFrame and reconnects, re-seating the stream.
+ Log.w(TAG, "failed to write relay frame ${frame.optString("type")}", e)
+ }
+ }
+ return true
+ }
+
+ fun sendNotificationUpsert(
+ stableId: String,
+ app: String,
+ packageName: String,
+ title: String,
+ body: String,
+ postedAtMs: Long,
+ iconPng: String? = null,
+ canReply: Boolean = false,
+ ): Boolean = writeFrame(
+ JSONObject()
+ .put("type", "notification_upsert")
+ .put("stable_id", stableId)
+ .put("origin", "android")
+ .put("app", app)
+ .put("package", packageName)
+ .put("title", title)
+ .put("body", body)
+ .put("posted_at_ms", postedAtMs)
+ .put("can_reply", canReply)
+ .apply { if (!iconPng.isNullOrEmpty()) put("icon_png", iconPng) }
+ )
+
+ fun sendNotificationDismiss(stableId: String, packageName: String): Boolean = writeFrame(
+ JSONObject()
+ .put("type", "notification_dismiss")
+ .put("stable_id", stableId)
+ .put("origin", "android")
+ .put("package", packageName)
+ )
@Volatile var currentState: String = STATE_DISCONNECTED
@Volatile var currentDetail: String? = null
@Volatile var cachedDevicesJson: String? = 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
index 4fd59a4..10a3857 100644
--- a/android/app/src/main/java/com/inputflow/android/RelayProtocol.kt
+++ b/android/app/src/main/java/com/inputflow/android/RelayProtocol.kt
@@ -4,47 +4,182 @@ import org.json.JSONObject
import java.io.DataInputStream
import java.io.DataOutputStream
import java.net.Socket
+import java.nio.ByteBuffer
+import java.util.Arrays
+import javax.crypto.Cipher
import javax.crypto.Mac
+import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
object RelayProtocol {
private const val MAX_FRAME_BYTES = 64 * 1024
+ private const val ENCRYPTED_FRAME_OVERHEAD = 4 + 8 + 16
+ private const val MAX_WIRE_FRAME_BYTES = MAX_FRAME_BYTES + ENCRYPTED_FRAME_OVERHEAD
+ private val FRAME_MAGIC = byteArrayOf('I'.code.toByte(), 'F'.code.toByte(), 'P'.code.toByte(), '1'.code.toByte())
+ internal val SERVER_TO_CLIENT_IV = byteArrayOf('I'.code.toByte(), 'F'.code.toByte(), 'S'.code.toByte(), 'O'.code.toByte())
+ internal val CLIENT_TO_SERVER_IV = byteArrayOf('I'.code.toByte(), 'F'.code.toByte(), 'C'.code.toByte(), 'O'.code.toByte())
+ private val NONCE_PATTERN = Regex("[0-9a-f]{32}")
- 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))
- }
+ class Session internal constructor(
+ private val input: DataInputStream,
+ private val output: DataOutputStream,
+ private val readKey: ByteArray,
+ private val writeKey: ByteArray,
+ ) {
+ private var readSequence = 0L
+ private var writeSequence = 0L
- 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 readFrame(): JSONObject {
+ require(readSequence != Long.MAX_VALUE) { "Relay receive sequence exhausted" }
+ val envelope = readWireFrame(input, MAX_WIRE_FRAME_BYTES)
+ val plaintext = decryptPayload(
+ envelope,
+ readKey,
+ readSequence,
+ SERVER_TO_CLIENT_IV,
+ )
+ readSequence += 1
+ return JSONObject(plaintext.toString(Charsets.UTF_8))
+ }
+
+ @Synchronized
+ fun writeFrame(json: JSONObject) {
+ require(writeSequence != Long.MAX_VALUE) { "Relay send sequence exhausted" }
+ val plaintext = json.toString().toByteArray(Charsets.UTF_8)
+ require(plaintext.size in 1..MAX_FRAME_BYTES) {
+ "Invalid frame length ${plaintext.size}"
+ }
+ val envelope = encryptPayload(
+ plaintext,
+ writeKey,
+ writeSequence,
+ CLIENT_TO_SERVER_IV,
+ )
+ writeSequence += 1
+ writeWireFrame(output, envelope)
+ }
+
+ fun hasBufferedFrame(): Boolean = input.available() > Int.SIZE_BYTES
+
+ fun destroy() {
+ Arrays.fill(readKey, 0)
+ Arrays.fill(writeKey, 0)
+ }
}
- fun authenticate(socket: Socket, secret: String, deviceName: String): Pair {
+ fun authenticate(socket: Socket, secret: String, deviceName: String): Session {
val input = DataInputStream(socket.getInputStream().buffered())
val output = DataOutputStream(socket.getOutputStream().buffered())
- val hello = readFrame(input)
+ val hello = readPlainFrame(input)
require(hello.optString("type") == "hello") { "Expected hello frame" }
+ require(hello.optInt("version") == 2) { "Unsupported relay protocol" }
+ require(hello.optString("cipher") == "AES-256-GCM") { "Unsupported relay cipher" }
val nonce = hello.getString("nonce")
+ require(NONCE_PATTERN.matches(nonce)) { "Invalid relay nonce" }
val auth = JSONObject()
.put("type", "auth")
.put("device", deviceName)
.put("hmac", hmacSha256Hex(secret, nonce))
- writeFrame(output, auth)
- val ready = readFrame(input)
+ writePlainFrame(output, auth)
+
+ val session = Session(
+ input,
+ output,
+ deriveSessionKey(secret, nonce, "server-to-client"),
+ deriveSessionKey(secret, nonce, "client-to-server"),
+ )
+ val ready = session.readFrame()
require(ready.optString("type") == "ready") { "Expected ready frame" }
- return input to output
+ return session
}
- private fun hmacSha256Hex(secret: String, message: String): String {
+ private fun readPlainFrame(input: DataInputStream): JSONObject =
+ JSONObject(readWireFrame(input, MAX_FRAME_BYTES).toString(Charsets.UTF_8))
+
+ private fun writePlainFrame(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}" }
+ writeWireFrame(output, bytes)
+ }
+
+ private fun readWireFrame(input: DataInputStream, maximumLength: Int): ByteArray {
+ val length = input.readInt()
+ require(length in 1..maximumLength) { "Invalid relay frame length" }
+ return ByteArray(length).also(input::readFully)
+ }
+
+ private fun writeWireFrame(output: DataOutputStream, bytes: ByteArray) {
+ output.writeInt(bytes.size)
+ output.write(bytes)
+ output.flush()
+ }
+
+ internal fun deriveSessionKey(secret: String, nonce: String, direction: String): ByteArray =
+ hmacSha256(secret, "inputflow-relay-v1/$direction/$nonce")
+
+ internal fun encryptPayload(
+ plaintext: ByteArray,
+ key: ByteArray,
+ sequence: Long,
+ ivPrefix: ByteArray,
+ ): ByteArray {
+ val header = ByteBuffer.allocate(12)
+ .put(FRAME_MAGIC)
+ .putLong(sequence)
+ .array()
+ val cipher = Cipher.getInstance("AES/GCM/NoPadding")
+ cipher.init(
+ Cipher.ENCRYPT_MODE,
+ SecretKeySpec(key, "AES"),
+ GCMParameterSpec(128, buildIv(ivPrefix, sequence)),
+ )
+ cipher.updateAAD(header)
+ return header + cipher.doFinal(plaintext)
+ }
+
+ internal fun decryptPayload(
+ envelope: ByteArray,
+ key: ByteArray,
+ expectedSequence: Long,
+ ivPrefix: ByteArray,
+ ): ByteArray {
+ require(envelope.size in (ENCRYPTED_FRAME_OVERHEAD + 1)..MAX_WIRE_FRAME_BYTES) {
+ "Invalid encrypted relay frame"
+ }
+ require(envelope.copyOfRange(0, FRAME_MAGIC.size).contentEquals(FRAME_MAGIC)) {
+ "Invalid encrypted relay frame"
+ }
+ val header = envelope.copyOfRange(0, 12)
+ val sequence = ByteBuffer.wrap(header, FRAME_MAGIC.size, Long.SIZE_BYTES).long
+ require(sequence == expectedSequence) { "Invalid relay sequence" }
+
+ val cipher = Cipher.getInstance("AES/GCM/NoPadding")
+ cipher.init(
+ Cipher.DECRYPT_MODE,
+ SecretKeySpec(key, "AES"),
+ GCMParameterSpec(128, buildIv(ivPrefix, sequence)),
+ )
+ cipher.updateAAD(header)
+ return cipher.doFinal(envelope, header.size, envelope.size - header.size)
+ }
+
+ private fun buildIv(prefix: ByteArray, sequence: Long): ByteArray =
+ ByteBuffer.allocate(12).put(prefix).putLong(sequence).array()
+
+ private fun hmacSha256(secret: String, message: String): ByteArray {
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) }
+ return mac.doFinal(message.toByteArray(Charsets.UTF_8))
+ }
+
+ private fun hmacSha256Hex(secret: String, message: String): String {
+ val digits = "0123456789abcdef"
+ return buildString(64) {
+ for (byte in hmacSha256(secret, message)) {
+ val value = byte.toInt() and 0xff
+ append(digits[value ushr 4])
+ append(digits[value and 0x0f])
+ }
+ }
}
}
diff --git a/android/app/src/main/java/com/inputflow/android/RootInjectorService.kt b/android/app/src/main/java/com/inputflow/android/RootInjectorService.kt
index 3da1833..b16ea26 100644
--- a/android/app/src/main/java/com/inputflow/android/RootInjectorService.kt
+++ b/android/app/src/main/java/com/inputflow/android/RootInjectorService.kt
@@ -15,9 +15,9 @@ class RootInjectorService : RootService() {
SystemInject.exempt()
return object : IInjectorService.Stub() {
override fun ping(): Boolean = true
- override fun inject(event: InputEvent?): Boolean {
+ override fun inject(event: InputEvent?, mode: Int): Boolean {
val e = event ?: return false
- return SystemInject.inject(this@RootInjectorService, e)
+ return SystemInject.inject(this@RootInjectorService, e, mode)
}
}
}
diff --git a/android/app/src/main/java/com/inputflow/android/SettingsActivity.kt b/android/app/src/main/java/com/inputflow/android/SettingsActivity.kt
index 27fc7ef..93558b6 100644
--- a/android/app/src/main/java/com/inputflow/android/SettingsActivity.kt
+++ b/android/app/src/main/java/com/inputflow/android/SettingsActivity.kt
@@ -161,25 +161,25 @@ class SettingsActivity : AppCompatActivity() {
private fun requestShizuku() {
try {
if (!Shizuku.pingBinder()) {
- injectStatusText.text = "Shizuku not running — install and start the Shizuku app first."
+ injectStatusText.setText(R.string.shizuku_not_running)
return
}
if (Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) {
updateInjectStatus()
} else {
Shizuku.requestPermission(SHIZUKU_REQ)
- injectStatusText.text = "Shizuku permission requested — approve, then reopen Settings."
+ injectStatusText.setText(R.string.shizuku_permission_requested)
}
- } catch (t: Throwable) {
- injectStatusText.text = "Shizuku error: ${t.message}"
+ } catch (_: Throwable) {
+ injectStatusText.setText(R.string.shizuku_error)
}
}
private fun requestRoot() {
- injectStatusText.text = "Requesting root…"
+ injectStatusText.setText(R.string.root_requesting)
Shell.getShell { shell ->
runOnUiThread {
- injectStatusText.text = if (shell.isRoot) "Root granted." else "Root denied."
+ injectStatusText.setText(if (shell.isRoot) R.string.root_granted else R.string.root_denied)
}
}
}
@@ -190,8 +190,11 @@ class SettingsActivity : AppCompatActivity() {
Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
} catch (_: Throwable) { false }
val root = try { Shell.isAppGrantedRoot() == true } catch (_: Throwable) { false }
- injectStatusText.text =
- "Shizuku: ${if (shizuku) "ready" else "unavailable"} Root: ${if (root) "ready" else "unavailable"}"
+ injectStatusText.text = getString(
+ R.string.injection_status,
+ getString(if (shizuku) R.string.injection_ready else R.string.injection_unavailable),
+ getString(if (root) R.string.injection_ready else R.string.injection_unavailable),
+ )
}
private fun sendTestNotification() {
@@ -201,14 +204,13 @@ class SettingsActivity : AppCompatActivity() {
return
}
- val relay = RelayForegroundService.instance
- if (relay?.isConnectedForWrites() != true) {
+ if (!RelayForegroundService.isConnectedForWrites()) {
Toast.makeText(this, R.string.test_notification_not_connected, Toast.LENGTH_SHORT).show()
return
}
val now = System.currentTimeMillis()
- val sent = relay.sendNotificationUpsert(
+ val sent = RelayForegroundService.sendNotificationUpsert(
stableId = "inputflow-test:$now",
app = getString(R.string.app_name),
packageName = packageName,
diff --git a/android/app/src/main/java/com/inputflow/android/ShizukuInjectorService.kt b/android/app/src/main/java/com/inputflow/android/ShizukuInjectorService.kt
index 3f78fe0..2436595 100644
--- a/android/app/src/main/java/com/inputflow/android/ShizukuInjectorService.kt
+++ b/android/app/src/main/java/com/inputflow/android/ShizukuInjectorService.kt
@@ -15,9 +15,9 @@ class ShizukuInjectorService(private val context: Context) : IInjectorService.St
override fun ping(): Boolean = true
- override fun inject(event: InputEvent?): Boolean {
+ override fun inject(event: InputEvent?, mode: Int): Boolean {
val e = event ?: return false
- return SystemInject.inject(context, e)
+ return SystemInject.inject(context, e, mode)
}
// Called by Shizuku when the user service is torn down.
diff --git a/android/app/src/main/java/com/inputflow/android/SystemInject.kt b/android/app/src/main/java/com/inputflow/android/SystemInject.kt
index 0bc8bcf..8906942 100644
--- a/android/app/src/main/java/com/inputflow/android/SystemInject.kt
+++ b/android/app/src/main/java/com/inputflow/android/SystemInject.kt
@@ -1,6 +1,7 @@
package com.inputflow.android
import android.content.Context
+import android.os.Build
import android.view.InputEvent
import org.lsposed.hiddenapibypass.HiddenApiBypass
@@ -11,20 +12,32 @@ import org.lsposed.hiddenapibypass.HiddenApiBypass
* AccessibilityService gesture path.
*/
object SystemInject {
- // INJECT_INPUT_EVENT_MODE_ASYNC
- private const val MODE_ASYNC = 0
+ // InputManager.INJECT_INPUT_EVENT_MODE_*
+ const val MODE_ASYNC = 0
+ const val MODE_WAIT_FOR_FINISH = 2
fun exempt() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return
try {
HiddenApiBypass.addHiddenApiExemptions("")
} catch (_: Throwable) {
}
}
- fun inject(context: Context, event: InputEvent): Boolean {
+ fun inject(context: Context, event: InputEvent, mode: Int = MODE_ASYNC): Boolean {
return try {
val im = context.getSystemService(Context.INPUT_SERVICE) ?: return false
- HiddenApiBypass.invoke(im.javaClass, im, "injectInputEvent", event, MODE_ASYNC)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ HiddenApiBypass.invoke(im.javaClass, im, "injectInputEvent", event, mode)
+ } else {
+ val method = im.javaClass.getDeclaredMethod(
+ "injectInputEvent",
+ InputEvent::class.java,
+ Int::class.javaPrimitiveType,
+ )
+ method.isAccessible = true
+ method.invoke(im, event, mode)
+ }
true
} catch (_: Throwable) {
false
diff --git a/android/app/src/main/res/layout/activity_key_mapper.xml b/android/app/src/main/res/layout/activity_key_mapper.xml
index 07c2305..826148b 100644
--- a/android/app/src/main/res/layout/activity_key_mapper.xml
+++ b/android/app/src/main/res/layout/activity_key_mapper.xml
@@ -4,8 +4,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
- android:orientation="vertical"
- android:background="?attr/colorSurface">
+ android:orientation="vertical">
+ android:layout_height="match_parent">
+ android:layout_height="match_parent">
@@ -246,6 +247,8 @@
@@ -270,6 +273,8 @@
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginEnd="14dp"
+ android:contentDescription="@null"
+ android:importantForAccessibility="no"
android:src="@drawable/ic_keyboard"
app:tint="@color/colorPrimary" />
@@ -283,6 +288,8 @@
@@ -307,6 +314,8 @@
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginEnd="14dp"
+ android:contentDescription="@null"
+ android:importantForAccessibility="no"
android:src="@drawable/ic_notification"
app:tint="@color/colorPrimary" />
@@ -320,6 +329,8 @@
diff --git a/android/app/src/main/res/layout/activity_settings.xml b/android/app/src/main/res/layout/activity_settings.xml
index 19c0fc2..d54cd6e 100644
--- a/android/app/src/main/res/layout/activity_settings.xml
+++ b/android/app/src/main/res/layout/activity_settings.xml
@@ -3,8 +3,7 @@
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:background="@color/colorSurface">
+ android:layout_height="match_parent">
@@ -186,7 +185,7 @@
@@ -435,7 +434,7 @@
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:layout_marginStart="4dp"
- android:text="Input method"
+ android:text="@string/section_input_method"
style="@style/SectionHeader" />
@@ -501,14 +500,14 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
- android:text="Grant Shizuku" />
+ android:text="@string/grant_shizuku" />
+ android:text="@string/grant_root" />
@@ -523,7 +522,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
- android:text="Allow native input injection (system-level control)"
+ android:text="@string/native_input_consent"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
diff --git a/android/app/src/main/res/layout/row_shortcut.xml b/android/app/src/main/res/layout/row_shortcut.xml
index 2ffe4e0..ef5c6ee 100644
--- a/android/app/src/main/res/layout/row_shortcut.xml
+++ b/android/app/src/main/res/layout/row_shortcut.xml
@@ -31,7 +31,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/Widget.Material3.Chip.Assist"
- android:text="None" />
+ android:text="@string/shortcut_none" />
diff --git a/android/app/src/main/res/menu/main_menu.xml b/android/app/src/main/res/menu/main_menu.xml
index 631b51e..54c147f 100644
--- a/android/app/src/main/res/menu/main_menu.xml
+++ b/android/app/src/main/res/menu/main_menu.xml
@@ -5,7 +5,7 @@
diff --git a/android/app/src/main/res/values-night/colors.xml b/android/app/src/main/res/values-night/colors.xml
index 08ebed4..e23c65f 100644
--- a/android/app/src/main/res/values-night/colors.xml
+++ b/android/app/src/main/res/values-night/colors.xml
@@ -1,19 +1,22 @@
- #4C8FD6
- #FFFFFF
- #101418
- #202832
- #0B1014
- #6FCF89
- #173322
- #EF9A9A
- #3B1515
- #FFCC80
- #3D2600
- #0A1628
- #1A2840
- #4A90D9
- #4CAF50
- #FFFFFF
- #34404D
+ #72D4C6
+ #092F35
+ #F2BE7A
+ #2D1D09
+ #0D1D22
+ #163039
+ #F4FBF9
+ #07171C
+ #72D4A4
+ #12372B
+ #FF9EA5
+ #431E24
+ #F2BE7A
+ #412D15
+ #07171C
+ #16444D
+ #72D4C6
+ #F2BE7A
+ #F4FBF9
+ #355A61
diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml
new file mode 100644
index 0000000..aad222e
--- /dev/null
+++ b/android/app/src/main/res/values-night/styles.xml
@@ -0,0 +1,16 @@
+
+
+
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
index 1d55475..35edb30 100644
--- a/android/app/src/main/res/values/colors.xml
+++ b/android/app/src/main/res/values/colors.xml
@@ -1,19 +1,22 @@
- #1664C0
+ #0B7F7A
#FFFFFF
- #F8FAFF
- #E8F0FE
- #0A1628
- #2E7D32
- #E8F5E9
- #C62828
- #FFEBEE
- #E65100
- #FFF3E0
- #0A1628
- #1A2840
- #1664C0
- #2E7D32
- #FFFFFF
- #D0DEF5
+ #A86316
+ #FFFFFF
+ #F3F8F7
+ #E4F0ED
+ #092F35
+ #092F35
+ #167C5A
+ #DFF3EA
+ #B64750
+ #FCE8EA
+ #A86316
+ #FCEFD9
+ #092F35
+ #16444D
+ #0B7F7A
+ #E6A15A
+ #F4FBF9
+ #BCD7D2
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 2686304..7a7ad44 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -1,4 +1,6 @@
+ Android device
+ Could not protect the pairing secret on this device.
InputFlow
InputFlow controlled peer
InputFlow keyboard
@@ -73,4 +75,26 @@
Apply Layout
This Device
Connect to InputFlow first to load device list
+ 0.5×
+ 3×
+ Input method
+ Auto (best available)
+ Accessibility (no root, basic)
+ Shizuku (no root, native)
+ Root (native)
+ Grant Shizuku
+ Grant Root
+ Allow native input injection (system-level control)
+ Shizuku is not running. Install and start Shizuku first.
+ Shizuku permission requested. Approve it, then reopen Settings.
+ Shizuku could not be reached.
+ Requesting root…
+ Root granted.
+ Root denied.
+ ready
+ unavailable
+ Shizuku: %1$s Root: %2$s
+ %1$d
+ Settings
+ None
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
index 23f9639..1d1c765 100644
--- a/android/app/src/main/res/values/styles.xml
+++ b/android/app/src/main/res/values/styles.xml
@@ -2,13 +2,22 @@
diff --git a/android/app/src/main/res/xml/backup_rules.xml b/android/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..ce0b324
--- /dev/null
+++ b/android/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..491c90f
--- /dev/null
+++ b/android/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/test/java/com/inputflow/android/RelayProtocolTest.kt b/android/app/src/test/java/com/inputflow/android/RelayProtocolTest.kt
new file mode 100644
index 0000000..bff519e
--- /dev/null
+++ b/android/app/src/test/java/com/inputflow/android/RelayProtocolTest.kt
@@ -0,0 +1,72 @@
+package com.inputflow.android
+
+import javax.crypto.AEADBadTagException
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertThrows
+import org.junit.Test
+
+class RelayProtocolTest {
+ private val secret = "inputflow-test-only-not-a-real-credential"
+ private val nonce = "00112233445566778899aabbccddeeff"
+
+ @Test
+ fun encryptedFrameRoundTripsWithoutPlaintextLeakage() {
+ val key = RelayProtocol.deriveSessionKey(secret, nonce, "server-to-client")
+ val plaintext = """{"type":"keyboard","vkCode":65,"flags":0}""".toByteArray()
+ val encrypted = RelayProtocol.encryptPayload(
+ plaintext,
+ key,
+ 0,
+ RelayProtocol.SERVER_TO_CLIENT_IV,
+ )
+
+ assertFalse(encrypted.toString(Charsets.UTF_8).contains("keyboard"))
+ assertArrayEquals(
+ plaintext,
+ RelayProtocol.decryptPayload(
+ encrypted,
+ key,
+ 0,
+ RelayProtocol.SERVER_TO_CLIENT_IV,
+ ),
+ )
+ }
+
+ @Test
+ fun directionalKeysAreSeparated() {
+ val serverKey = RelayProtocol.deriveSessionKey(secret, nonce, "server-to-client")
+ val clientKey = RelayProtocol.deriveSessionKey(secret, nonce, "client-to-server")
+ assertNotEquals(serverKey.toList(), clientKey.toList())
+ }
+
+ @Test
+ fun tamperingAndReplaySequenceAreRejected() {
+ val key = RelayProtocol.deriveSessionKey(secret, nonce, "server-to-client")
+ val encrypted = RelayProtocol.encryptPayload(
+ """{"type":"ready"}""".toByteArray(),
+ key,
+ 7,
+ RelayProtocol.SERVER_TO_CLIENT_IV,
+ )
+ encrypted[encrypted.lastIndex] = (encrypted.last().toInt() xor 1).toByte()
+
+ assertThrows(AEADBadTagException::class.java) {
+ RelayProtocol.decryptPayload(
+ encrypted,
+ key,
+ 7,
+ RelayProtocol.SERVER_TO_CLIENT_IV,
+ )
+ }
+ assertThrows(IllegalArgumentException::class.java) {
+ RelayProtocol.decryptPayload(
+ encrypted,
+ key,
+ 8,
+ RelayProtocol.SERVER_TO_CLIENT_IV,
+ )
+ }
+ }
+}
diff --git a/assets/hicolor/16x16/status/inputflow-tray-attention.png b/assets/hicolor/16x16/status/inputflow-tray-attention.png
index 3d4e2525a5c045258bfca7a91e3246b7ecd3c190..b32dcbdef9020befc68e70165bddd6ae9713d664 100644
GIT binary patch
delta 479
zcmV<50U-YK0{H`wB!9q3L_t(|oTZXaD1%`f$G^}17&d05aF97UX>oCI;DB}zWZxd1)`!r;#^sQaJ!l4ipQaCY%rBm
zQ_y;F083{l6MvA6M)5H_%T!KPfqaFg`FXW%iTt3+ot^q$pg2AY3bF-@;IO1)ZQ-)(^hF1!>uw{1_`rREH1z7$4(3Y2vU0YjIIf38rg|3Gg
z-JFUvFDq*$002ovPDHLkV1j>MoqB!4fu~E9fWyl$F{@O6C=3`+@QFvt2|*RL(i5miA38yoH-Co+YU0pK
z6LUnDV_JZ=K%a6#wNeSK8#O3_1^$OIZ&bwro-op{JpG{b9J6+vgJS1`kY=$NhfNJo
zExlze&M6TDvQcfVyP_PhJnaDJ@JU8!{zWyl;wtYc2P{vUD{iE*Nrk2q^``FeQ}-zS
q+-JksbU{i7c>H=ney&qkGw}t%_hk|EW9_H_0000OH@%D$G>Opo$E|a%}|?+$XO%>1~DxPqR>{=
zB5lzkD0xDi~agCMR1L2#iCDk}J3bfXI&<73pBd*>d%=O9-G
z9Tok+x%c}WIG_0)&J{Q(o?xdj5_Mt!l2Sx*3_{415B}f5kALYDh!&{LdFhM@$O!9+
zNQkr{%$SmBK_*_>1mHcFMp$9Mia>77v$2QDfm|9gx6bC#T>2^VK`~$5BeyNIna9eFnbEl`Md}GK$@<
zhs6a7t11rT`ZFeOSRR6L?FsCWzihnL*biiTUoHinc663$K_(vR0Awv=0cPb_J~_Z*
zKZ3ObaHdrSD2XfW
xIsnxG%DE2V3s+1EBQPP?roO4oAfDi?>;Y;0z7b$K>Fodj002ovPDHLkV1j8f>^cAd
delta 347
zcmV-h0i^zw1oi@uB!4|gL_t(|oXwH3O2j}EMek&{vhf2fbW5AfEF_EN#MVMf5y9ve
z_y^)2SXgUBuotv7Q;G?gY-6bwT5Bn|Gu~NYF`C7|HhbXl?q%T4n|T6LGNS>qAFZ*?
z7kB9eyYpLY@ATK~6>0)n#
txYYv6h(njp;1u@MG{fEQX-nj
z7C}sjn^`C->OyegaUr@<6q$uCL{Z$RsQ7~}L>J;l(P8ixT@2!ggFg%^Ix{NcdyYCX
z=!ob8-#zbl;Xdb{9}U+eAjl~YF?&7~@l6aa!VkwyJ&G&Kgnt7>W1W0F!DwV%1~(<~
zQlbPId7_@?|BQ;TFayF_>lkBqT9MuUxlcqPcp6+?dDE2HAg>lb|b;aqlc1fD;O=ImbBCuiB6
zVyzI`xdKVL`F}SeBEp$~?d=`ZHMTMZbD^NPFk7u%BKM-Gq>3q+kOH}cR8ulM9xpq<
zV7P_0&i>0UA>qS2r2b4{I;E>;0A8Pu39wJiaZfy<1@ah6MLu-88|;An{R6mG*KpSu
zsg8Haxq`Ban$QAC+TUMz^)i+TSX^`>F7_!}JNne$J$-rn5Z2nJl}wBIkrMD8bZ{`S
zf=K$3z$Y-j;6Op1g$d~GAHuifL{!%{c4b-q{ZNAAt88P8WSxuoj|m?+yJU3S?rUyo
zj|*I7iIVI=z>h=#(X2B(xF0p6*XyQTtFC(k00005LPMeP(AW>ffBHI9j1Dj>JOy>;|ad-L^;80qE&l>AXNKS(C3$bLIf?b
z7-XqFrYletpbQXGPG~k7zOkbYB{0YTq4hged4R(Y)f-grQ?Wo$P;(6L}x-%i*{EH
uxWW#loBMp2=srm80Eas-$n-gdH51?K_-GNf=f-IO0000QGKCEe%1Br75B%C>MpMhUoSflp3@^s0gk$_)Pnb=2c5Y>Ua5{^L-c2
z$9eo!_$Nw0qsyU3G;T!|^e9IXK4@y~vccj5QL!?MW3g)?S$~xvL#-YZA(?=4`#ykK
zRvK+h9kjJ|cnZsOKu3Keg7FA~i3pv-fo!tQWdkHm{j4tHs(4PcCwW}>wRDSr1KFLc02lMbOo6sNBqx*QBp6%7#V_D!Q%T~7tP
z$HG{+J)$b4Y=3|>x(pr9F7Wd%1}?U!3Mm;NjINu(ycy~;dM+$z~
zY}IE2PB=}n3Ruz&a|E{immGhQ*GQRGmMFEXN(i!*2QQRX7JP@;xV3SppyRJ%h+CAn
g*d`W(C;@-v3jpDo5pO_=LjV8(07*qoM6N<$f~!KtRR910
delta 277
zcmV+w0qXwG1Fr&*BYyz)Nkl;YShG9S$c900000NkvXXu0mjfvjur7
diff --git a/assets/hicolor/22x22/status/inputflow-tray-attention.png b/assets/hicolor/22x22/status/inputflow-tray-attention.png
index a69ecca6a55a42b7197c746a008eb7ac7d3febd8..2362dfc760e69a75413c291484e6083793efc403 100644
GIT binary patch
delta 604
zcmV-i0;Bz<1oQ-uBYy%rNklEZ&FlN0IuhNgW<9UXmdh|_5U9x=qGrto-phJA-e=)Od+uBl@GH=3M!(CmtXV#aO
z(sAK0=1#d|=F2G{oD$}2EiS4H^uS+i@7RmN$_k>x!+&b7IpgKMJ^KXyV$!y;fpAZc
zR86Ya*9f(@E2$0T_p`I8-QEThNBaAbTwYe$5@uaFJ40k(KvsaYumF8+O)B8w;DGb~
zO#H%=wn;24sTW810=d)3;GnEzEt%W-S58k+$YwvKOf%OI2lxn4MtJ1+lINLS#}@3=kYK`BOQ+ew1?>#T
qnRg-xns$R^GRNb~%E2Z6SNsAezY!+VKm`u~0000L!BYy$xNklBl!{jD*J>;G#^cEbWt}nN1Gije9rHtYPcYE2X!<1zM}mL1m4oM1L;FWsL^mVmdUyFH=|v
zN~MB{!XP7q;$tY@38kR)0K=7|)nM>kAq8TD<@i6eJ;U&?X;;2AVv7+jq*tF+x|(L#
zfE76cYvTdfuP;HDzca&~_&ea+c>&JXaX8tB%rv5vClN~I?;7EyZ1DA5MCIcUto2d8
z?=b^-xA(#Ml7Hd*H0-yRnQ3C)j%9^aXfv#J`{3#9M)_5e8Nk2$DyrY!bH~r23)9Rr
z;YL{S_uc@M1^e9!fmlJ+7V?`
zM~YT@a*|OC+z5-ef%psKsrW63ub$2>%#Q43ev+9c+QrZ@pxQS;wS~EwLzi-x
zeb5V?&$<(~HpS4k|Gx275U(rQNAN}t(H34BYZa^clUDV?p1KKpB3Tz+>Pj!*kKTfJ
z+q}o0hJTpopzvYiy~N;k7vC${QIwyaIvz~qhmEkCAtWQ_(+r?-O>5$UngT@Asny~Pv;xhe2T>9LZ9<7NA4Z?!h
vQiO9%RbBp?Jp47m8kXa8S!0p#zv36APcP8x5SIBR>9V-ZF!vwtZQXXK5^j#-3{3d=rSexc!k5
zQN8h1p_0V*17$7~jc4m-RTNEy?2F(8s!vp~x*OXCzMacN<7&O}MO8mD9U&qsN!(s!
znoFUv-wHkCjDLa0lq1-5AP+g?X}7{}Knk>jP@0-*izP@POX*P81E7&@$~c6POJ>a3
zc2!XU$#MztBH6ZEER97NxfG0It5lkK)9()!Da(u=AJ}rSb)n1B5uw24Erp!7mJ?_RsaA107*qo
IM6N<$f?e100ssI2
diff --git a/assets/hicolor/22x22/status/inputflow-tray-offline.png b/assets/hicolor/22x22/status/inputflow-tray-offline.png
index 2497e97ed33eb0619c8af4bd87b261f8d11d7ba8..77adb1378e6691733c9206685e2c912331cb034f 100644
GIT binary patch
delta 670
zcmV;P0%8581hoZ_BYy&XNklO=3AtEoLKL|=*
zs1-yJQ4wT>(OMAR?d+~D8mop-K~ACzqhgaVCP@}vEV?l>FiKe9N`p)cGKlLoSbKY|dED~vAr&5+5UyUZigdI6x8i?kvFn>cnrKUJdVDL-O>hicv
zmOumF*Ea>D?mZPGVH0_iFc##F*H2Fl~nS8gN1}?7YhWuY#r;s2E5N>{dc;1A>m7fd
zoZ?C<6lNC-hYvKu$TRS$_bF%4F_^O^?%eI>EFB|Pab4|pG&a<-O48lahu*#cR_Rcf
zoA~VMqdMb1kwd%D(Zwnq3bTuC)>5>&TA1ksikY`C$$wm}WhJ=iy^gWiI9F1kFuPcG
zDm#|bmo5;bOQc(_w0G+6m~T5VF?I`u*~P+zb0@Ld
zwu)7f*vC&OvJ|p{77HT3#CRB0Zequ#bvSk6P@-q0w})Q8K~?2?#u3mF$)ypVJd5B#
zD4Y~$x_{DfjvQ>JU^6Sp_$9CPQhnWJmm=T2qejc6TW$ur_WCcYpcXrGc!cDQ5ai7e
z(SF`QLFE11sT`=>&Vj*5@(~jL7RP+Foo~@sJ&tt9)%H#u14u$F{Ht8zuvtsk&l7kx
zJd%(KDPqCNSbk2U*#zv!0RxCGDMZl6-{&*|*B2LuU;H&CCN}r#TmS$707*qoM6N<$
Ef=HW5$^ZZW
delta 526
zcmV+p0`dK|1*Qa$BYy$yNkl1+;mB<^@oB
z0tn&+f|61RPKZAbfQop5_P{w?sRzJ0ta}D2k6wJ|*kiQ~0gz6m?Z0^Oj$LUh$3OC!Gx2pP@Zh?qwB=KO4
zZYh<*zAN-UYkv&fW>~Pko&;!DITX
zobr>O^ETvr^YP-@gaYJ7?b?_9@zGbhE3`BaiUYk<@ooO*T|Unzd=`Gx9>qS=^`x{<
zv_4PpjGf~B#e_Yqy<_d0baQsNTZ2;;7WVi{%HMMS20VKqT}Zf!OA|f@B2~E;%_f$Y
Q00000Ne4wvM6N<$f>sg#(*OVf
diff --git a/assets/hicolor/22x22/status/inputflow-tray.png b/assets/hicolor/22x22/status/inputflow-tray.png
index 670f9007e54b15644e6cbf3dcc1c6468252affbf..890cda2beb0247784aa684422624fc354c85bc2f 100644
GIT binary patch
delta 559
zcmV+~0?_@K1IGlABYy%8Nkl$^p+$>Y6fH_xDbivhl45!+f{YAO5l0t3!MQpM#n<@#k2BX9
z=U(UP=npREyJzv^obR4{6xN9>v9GnkhpF)@MnBvUMWMIZEPt;tnJp9_3tu=?$HBnr
zFw9_Gh@(dF(NJmt5OF?)C^tZ~IJx~8WBwF+i=~zY@yGfbNF-h{W^i%G5nR}I07mC0
zjLl4P1}NIN8TU%c@IBUt55xaCvjnAXniaLL6{BMf4@%DAZT}wxiudVJxVq;!BC%dQ
zKI#6#nJjc2ihsdTymyNaXDL1A*@cH4Va@=L4_BZ)K7?g$=pI
zsTJFi*NTPK)3
z@w~JOSCSpSW5;*eo0*&_xNHU=H7O=nmrxP!2qAT6IFfz|3Cq>70(1=^mRQJBZb^j<
xv7l?2jwoMZ=YF_BsVFvinq>QYv&a&!74rh2;}^UtMmPWf002ovPDHLkV1m{N2`&Ht
delta 382
zcmV-^0fGL<1egPmBYy#3NklLB&Viu3B1@St@z<)tAP2o+(wvPfz#q6vL
z#Z*-elW};;E4ZEDK}4VUvc*3c@oFih;tba}_X5#43eOqg`N5q+C5h}AV
z-HL1jQXq6Tq<5>SUh@j#$W{8IBL@f;o3aeyDP$q$Y^SPdfh_9(iR&6g2G{L^9S+!O
z!U)zb>mJwbma&jZvA1tpI+ujIyUoZ4@*xd%;}<(c36x)-=8`O80*~iCfNyL`IC^_=8ww#ZKW~Txnb_XYfQ*t71iQN#
zvE&1@`2Kf6SAQ2GOG`#3*PEM+09jR4#*=3^HX=Md&4?vx0+~$8W3R16cxs9fz}erA
z$nrAM^Yd}Dv%`obY68JIG6I|1jmxLIVRFr9@re=<7pPWhHwi*W23&
zkB+jJ5;cJ==&Qv=q&b}&naINbZfd$;cnj17_WF9{bboX}KRJP|pnwtF_b+E>akaY2
zh$U)*oX$?z>*^319E78%hY=t=HU{VLFs|0ta5*!>h$U)*?3NZ}H#f7FOk`of*kPIF
z+mT3f&7uB?1y2&N$R
z!3h9(&8CFBvbD)cLn6rJL7(qJKRtaTPtTAk5IgQ9pYeoD1Sy}uH|!UF2R_9o7aj=W
o+^vZlPeIduBu*U1Qt+Jo0m&CIC?%NTH2?qr07*qoM6N<$f;`DmJ^%m!
delta 518
zcmV+h0{Q*f1)l_vBYy$rNkl)$LQIa>K%!-1
zE3iXbF)^{RAqxKhR4i+LIjNWY1>M*Xk=mtd@AczU>5o)*j?z|L`jO6l{>FY^
z`}rL+g%-nZ6>2~}xx&wl)YOeS4wZ{xAi5fIA_kYn^+5d5_6A9sY3qZXHlKC7d|Kpw1*wv_!F@MV?#Kc&
zAKI((1W9yDzNt!XugGfg+iyq>QG-AuZT%wcBg*oRA)(%)ZTE6NPhZP;2AS)KN=kT$
zk(~NxM72Z>l7GxSFIBz}p1>M;QFQ!QsUd2>?pN6TBFSc{yB9D<9H#56{=_MkkSZD5
zfTUdvnn=N5KRyeCsuHoVZ>OdnsP5jyYz`?Hw};=#5az{cr0y@>9VYkbIgs2i#=cZ3
zn+z6_4jDNWYvMGr)>~f9_e>rb1lXh7qs)hP9Y+4-LX`~APmIo}BUXHpuBhy1xP^&FDU5z^&f+84+P!uO=z=MyPdY^#lr*Phw5RnWaElZdr4Ms!0NAsTfY-lAJ!I3?r
zCJlJc4O8nQX~VrF08{+|<9jPt@i*PRuK<~aq89-Wj5V5ae
zc-EeR`sM^>>^L^y!HmInNO_es*m24LO3_Vd?Ux{YIf#9EOc^_l4H#wn1GuvCpuRm#
z5v^*&Sr|iIh(6m(QEKL7AaAXMw7i0ph2C2a_0?fgsef^7zz6Cr@1pktiY!cZ7!&vN
z=skJxvvF)7tv&{M<9RC64l2!j${?xL)@taTS14o0v4Onl0;E+(5q-W7a^M0}L8JY3MLWrRb=LYWVT~Lbe
zkh0LbZ&MQA7cJcFbs>?wt$0mCL^rhbD|8RmeCwvRCeUN@!-=}=;DW`;om
qJ`>t$dYt2h)HZ$K1St*vCBFaz>slxoNo$7y00003wBYy$rNkl%t7{{L(42>F%v9P4Fyjzl8W2|Olr-@=`
zhuSL;D=RxYBl-oD*w`tB^-XEKP1w6nKxb#XL>nUe@Egc-1hPAq146?unR#Ar=K0Ut
zo{>}LTp-HX3v@I01tWvLe~j5D
zDs{uYUm?{uhFj$3%`JEWio-x^+hGc9b=^6
z{4=~(q5?_go_|y-A90UhjXW*;{*$y26=3zNtbU1N^PIaEFh(4r8_a&sE|y6(GO++f
z2MBaY1%W;PBm|m{h>0URGjm6E`zB%!Nd@8c`dg`CT%1noeqnAfc!Xs`a>W??Or`1)
zERh;>XjjaM)5-EzJk2)@-W3S2TF1^da0z6MJ}58JhkwhT=kiNjewvW_E^x!7=PGg?
z_ccwdNRE+8q=yOdO8tc!+Eg688w%!_vA}`nYiLuEICoY5%BFH3g!@o%WG=~E>HQzV
z5Kj-gNO(cM7NOWE+zqQQ33nlPeE7$80-U>-L!5{}8>cR?0}3;<86QsMuK)l507*qo
IM6N<$g2}4#G5`Po
diff --git a/assets/hicolor/24x24/status/inputflow-tray-offline.png b/assets/hicolor/24x24/status/inputflow-tray-offline.png
index 6f27009d7fbc1a65ba524ca871f60ac7de13edbf..74677bd6ce75b3f6711c0ed9a7f1a8e01590ff5f 100644
GIT binary patch
delta 762
zcmVp+5
z&Am(AE&RdZ&J5i7pEGmjUO&t+UIlbVq}Zt`|7B#VCLmz?lYhQSGQopc0y;_3*e$4a
z0Quzl3?(xJhM-I@NfYD>DCR^*$tNxNNT!C?|1dEx1abNq5w}2jrPSp3$DMmZNe$H(
zq%CB!O}nR>PyQ6USFk)j7FL@L!y{($WTK-Y5F8YQ!66g8L8l1iQwV|bLz31pjdn5>
zl1epuOc5oj1vuI8s2Le)DX72eOtz=E0Ih9Z2n*HV{fB;vC{ZoI?BBZ+
zOTrh!_~;pVKwjLjvcUVp#sL*1Q+WO+xm05|mHF&*8Q
z`Q*u1Z9j1QwBFr#PgDzJW~5?UUN%NX#}O47P7%262gavpf7wkDC8`Aq@;5=7z82SO
z8&O;&W(T(#ny{~|5FMSbaNqERB1%*X7hoacr>3-&p&z_0)daqStd|K~5Gh*=tmcYXcTaGRJH7ggu4VZ>}!
zFOZy=KsDb%mJCldC;d*h=+f0XaT(ZW_yiqY-Lk-+K6Wnn1e}E7GAs(yV0U32d2oy5
zWNEQ7DFL?|jd(NAD}U^mgO!qj9?myZAm-y}g>!f^EW&WYQ)jDN&YU=$OQ!GzI_D3^
z1YjX8RYXTdi2k@lc<3(I-266X8H>@=?kOY-FgmA59eg+m!{uWJhfGHL50vU>k~6ae
sTyRh9cErL50vDk7_rxyzXw50V0Qq`RDAetijsO4v07*qoM6N<$f=-}oBLDyZ
delta 522
zcmV+l0`>io2BHLzBYy$uNklAqmY|8?mTw(hA{pJCA@iwmYxvvUwYJ^Ltt9Hwl$eFL1-Hu#3V*Z`_aF~?hxs4Y=wUYS?^Qvq0K4C0_v<9PM%}%@7&%0@S^b7n^azzQ
zw*X0p2s8;rfj#>u1jWLVsQCDY0KPKf~nJoD{vljEf!z
z=`!YXo-qkKxzyu*7hhJ|D86v!F2+o6_C$hSNLt0-zrKqLICnAp(oUcsxREhWOlZtG
ztz_O8{^D?o7B|QjA|#u{-mv=&cM7q?!#}PQPUd}s|x=aS%Kj1HX)TVQm7n_CMG&MqJNk~fk5zI5Rgn<1^%>B
zS_LXEw7R{bS3%7X{R(}cxtbfr5S!!w=;=1FHRTP#L^eowk^N|4T4MyDj^qMNb&IL;m_!+}t
zvmFQD-J^;ON(J03nD~l)h81wX9i$3yrEx2IBF|u$Dd7IMFjZtwDq#AT?SQ3jHabTh
zQU-J`T8r?n?`VH@i?R|_BDieZf^GAy6iM9v9K!zK9e;{Spj5yEebeHGUOj^{2_Ago
z(~J4R7ok+(GB)GPVkbg02di!_RgjO5z4yTr8={H~N(J2(Ck_~zv1_;=9&7d-x8KY9$dbgqGDQ}_tHc&kp07ng3cDLsSu~#n|SvWO9JMfqz~1<$pAIqlWWct2y$d*
ol1`gY3F^AxiH&o}3Z|34v`-o+NFq7m00000Ne4wvM6N<$f;b~L0ssI2
delta 392
zcmV;30eAkT1)&3wBYy#CNklspY!EJ7WgUca_{~~t|(xixo9NZ7u@5@J$U-PwPG-SpC
zFaIxS3VDM-d8#%L29$Mc*;JvERSYZf3IZWdIcJKnTlS>xP=BIuQMQONYmAjes`gN-
z#489R*=7xXYkGDk*U3Ig)QsX2-eBjpXVx>VIw)1*7c9v+^Ff=Kxy&bN4JESgbZsqZ
zK0#O6qw10aub?D|;s|?yaGIZ2T#9?%@)viMSwAqkUnGe8BWngsgl|PY??#RM5z>f<$oa<
zdW%YeNWLJfXh&HkFn6dHLDb`pah%pJ)LuQGYC9yD}U$vvvIsdQyXs0jpX~+DMd!2hUJ^y>}
zx%b=}@XY+b0xlaHU%}()6tfynV;4rFJ$GZ{2O*rDZvVSsFfWc&fYjF|YlN0eq<%%l*
zfN=jg<4ADk+=fS>UqEJC8!|gP#R|t|x5KrxBvxGX3E)Y{i4)*T1jac5M4y0I%dg>H
zS;5Wpv{*6GC%`;00b^+?+{??j-QQQo#L?S}w8~0kw6?-?bfk{s@H7G5(^GhlkMCE5
z$(LGOjDOU^!sH6zZI@P2Ay#y54-V87%4lg(uV=zz%dO$x0Qv-Q#>|6QxYyT_(bObX
zI6R_UEkU0Ek|TvCrJz78xe=u>5f&hFcX5G~ygae|+?}0CFQ+H)d2%HMP5k-=q}SFW
zdtg8;|I!j|sd*dnhSXqDA}nGWtd!!{FM!uElYfy37Yq~A=XAnYQX(Ti5=i<`0n8Dp
zq!9&J5$8do!3&9B%A-ekBpHl8=81-TZ4HL3ETq@e$jA>9R7$!{1Xv*$q^Tt0Nygy8
zNvd5)Sv@_{`%x2dXD9qgMiTKTeK9+$zMdk%SJJh`G9{1F_cBxo6~Je?qU?$Ye^%*$
zIaI=uttF<0y7!Zl-{AGWjruGn0TG`>#FJKntH4G$b4k)E`C(!W4o<=ZXasVi3yJw@
lHm~jZpF|b#uXuI=zX1Z8p)lOknR@^L002ovPDHLkV1gEVpdSDL
delta 712
zcmV;(0yq8f2HypcBYy&>Nkl616vn?@o-6Eu#NHt`CQ$6rS%Lq6fe4|29e)F^Ic~+wCU-F=_e4_
z%z@6_6w9+pIDd$80{lNc(wZ}}JZ^4x$IcVhOfA`nBCWej4Kt~QJToDb65#ONPn7h6d`P$?iTwXQ6S6{4(p$?
z{#DkxOS?{}sSBFK7Kl~xhbJJ<0QNVAC%_jX(*6$jk1*L`G%+#lPEPK*izP_}#4T2M
ze|U(8;|o!CY`#(nZ_zCf$vlv8)0xv6-2#!(VqQISN(t~(>vON2b1hsy*b1DKc%LJ<
zn4M~m(0@6^zR~#mw`tCa#x>P@Obu2Fp9RYCzU!+6QDmNEnY)X-g5|U@&!rf9q^VyR
ztTeoQV$!^dJBxX?Zcgga_alA8&tskz=2^jVE|qJ84GaoZM^4XhFQ6!Kt@Ouw3)Mgc
zkeLRVS;IXil3viL_vG|%#DNO%t!>V;BYy*ONkl9h26vzK}=FNX)y}+`cm2<6-*noU
zH*Mau>X*FRbI)|%{l9b1J@1UdIP?Ds=(!o5Z6RK&HP#{rlz&2v4@Py~zK-w6K$-x3
zGaUW{^CC3LFq$Y--)N3GJ{6eby{^=#~Q-BIhhFM<$tNjDLnbA?_~Q>B>QC>
zh9f5dl8Axg)3nkt>(AKdaA2T#gOT7T}yDX}80-US$Qce38!pw$;zAsOnH
zN34)rX)zMai&P|ff?0nav4#ro0;C*=BpI1-sYqgKGXnp#zVa*!r4lGU#gW38BheST
z(*=yAA#sp`BscW)=m`lPNm)>QPMwf>D)O0NHeH2TcOtzZ8I6|AKW{HWd@T#s?-sy$
zDSt6)zKKON`Vvupy_epS38T@J%A0ZM_BhBk^*mg8nc+Xmsm&k({cU*-fTg^=5e!2b
z7AG+sWbb2djx^>+yGmT!a-mRp
s_|r`DrFa`t>Et^)oQ<1XMYj%dR$;tx_n!2kdN07*qoM6N<$f@>1ItpET3
delta 704
zcmV;x0zdt$2iFCVBYy&(Nkl616vn?SVMOWy9Aw>vZ6
zy_tD?M*o;A0apV4F9B)YT6*kfTP~wnB5l}IUX$6jbBZKdKz|tAOFbsorBio!k|2)f
zON=P;T*9+Vd#^??E;3aM}l>~&7;7^os+-}k=!C(jGn$p-45TH*y
z9JLkFy@Y!)MqQ5RGF}wb1k|mQG)t#-teZ=pXe*KCJby(9o2R+SKCp^}apGb9GuFSx
zTKBoH5o+s@A#@_D}6r#
z#N!EtC_ge^sf4#_mPlkC$oT2pYn5h+#Avaoo&}Wz1Zs`BSKj*$Zs_a;Ub4aG2rlL)
z+9h-$$$vFX4IWeaRU>49a=hnJ{wogsR~Wf~0+u5U
mH!P5m_KbUpOA`>wm4F|NsQ)1mcq;<{0000#4Uza%q)eWi*d=WssoH&Ofz5;@gdYeLoA0gkAlX~Mc8L|GcnD;g>h9yLcPLs*
z0~Qd*lLRubY-u?5Cnn%oMHNn_9KyYVQqKP7%&8O{e{VoI
zclsFeA3UMhQHoW-$!rK=yz7%G2T@#7j?@!J^zZnCqJQQL?23zp#+L{$3=2bDLnCJ=
z=ZBq6-H5`MG4BhCnlm5*a`zpdIuR4K9oMt-ISX7#KZmB4Hf-Ip0r?M0bc6n&s5t|I
ztiKeSH>5bF4gx_@^9H044nnlm6s7f3e<&VP|nUb#Unn9nQlb6^ll!WT1w-U-I{
z+>tKn?;kL4AZ}+AQjZ^I#MDluRGx%FV{;o~qqj4fEX-v~mw05XvYLBERjqjgvK`yb
zTlIo-M+$RfbQBxcujL4QHjL;dq&VG8NcY??DD!GS)tDiM0g==uCt464wT&bGXiK|(
zXMb{Dh-aM`crzgd`n;kliw0B>mjPQw@JBLlMJgs76irUDK#)Rw-_+=NBok)hP@0i`
z7OPjSVB|?xPakgP+|A9)R&5;WOfz7UWBDK)0Alzyh17^VvW>ztkhOnY_M&wxWO$F=
z*Rfo%iHRZDgUy6wLC%qaoDaKu`*K4TK{U0yi3=VE45TI|#$W|tL2&S|848Ux{}Z1E
d{uOg>;1|t^yf8hs&Eo(7002ovPDHLkV1foLyAJ>W
delta 710
zcmV;%0y+KA2i*mbBYy&616vn?>Xlb0>wgS1^xr(h!7gs@i)OmxCJwt95E*Rl6Ujw?au6X
zZ)V=E(Ld%|z_oz?OF%khEhBo_hRf)dNb5G0*J!rsoFa)95Pv53QkMtpFsRf2kRXoh
z^UOHT_2y+F`{CX=(uZ~|Kh}IQqz-Lsd&4z#hRM3^a;@c$wP=R4lznx(iXd7*pf+W{
zY;C&7wYvRiZt7*`T#v9)Ye)ToK5T8c=fypQqo^c+G%sk#?8y63c^T5&6|g)_W+GyG
zI`-o%Pn&QQ)qezpf4Z+_Z{&DfV|&xh5Z2_BT}=|L_*_jhqS+!dF;o%|2oIxqiiOcX
z`4wVr@f%nxM8#5B0PFwOd%eOfVT|6f4_@O&Q%OKL3I0eK$L%)V5=^#dZYYgC0RhIu
z!%=IK?s?qHG3szc7x1E}CSb}sNz)8k#X7n4nZ6RK;eRPY*bLou_JLI-%o7jmpRxWm
z)_RL}g-}Z;w1~|TtKnarfFc9f-?%yfp%9Vw_qZ3rWP8c*@Pt1+eBe)(#0rQztn~f}
z5RWGmqWr*oqY~bwTOyHpB;zM?uVuO=5~I1IdKOd?5UAA~Z@l+QxM8ppc*zEzBRH2I
zX@`*Le}DJVJ=}97H#C0sm~vk=LKY~;yS}d$B#C*J=l(wKBBn%YF#FgA*;rzawDg@~
diff --git a/assets/hicolor/32x32/status/inputflow-tray.png b/assets/hicolor/32x32/status/inputflow-tray.png
index f67e68b43f16f8fe1b0ade8a0e04bd4568b87785..482f25f3dd11135f763a2113a3667c4fe8cbd8bd 100644
GIT binary patch
delta 771
zcmV+e1N{8O1e*qsB!A*bL_t(|oXwU!OjA)9hTkSybO5U+_%jHEK?W*yFvciS7D<$7
zG*Ltcm^4aaWKnUUT|`|_6Z~D;L`C9YG-%SPMKFe#Xlpy*K%@-*FleQN(wNluyY}bS
z-uBj8YJHM(d+uq|^PF?O@0?>onfZSOEZ%`y#A01And~Trcz@gnv)LVK?VEuHSppXC
zpal!jL73v*SWMz37b+@yBdxs=zNN?zP&$vzds(%ylmb7@Rjq1?OaYJ61)z(il(-{p
z{oP2A5=gB1dsa)2*<6>5iL`*n=>X8rl9SH$O?(2w9|C-4qxZcECuhL{q89K0XkpRB
zTDcZy>v!VY&wmA_gNZLQP_tuIts<~uNd+!VKSwb7iLE&qqktWxV1W(GEfi?M`6&;s
zH}1nx{|H;t+_QDyWau$2G_+A*471-;ai+!J3kV~dJJIoG7xMMw_{6+_30!KK-aehk27Iu1{OCd;mgl!@se}9;4&x0`m=|Dz5@imP0>IO8w
zyv0^veA{u1&P~C-x(g~{CVA_Yv%zy$g
zCLoy*Xg1BXmOd#L%C%HL0i9V*P4#osVw{&6q${O4VPvR+qPTqy~S8nWK
zP@bsSihmappnTV%IDt^~Gv0Eilk^iL?KW|zY^+>US^?SZc2{p?EjQ2R-ev8Q8W%n1JAC7Su2sgn1rJLKP1nA4*ijrluDL6@=6{k?;9pU8fnSLsTQI|G$w&YI002ovPDHLkV1mBv
BXD|Q&
delta 556
zcmV+{0@MAQ2E_!BB!3J^L_t(|ob8n{Zxb;T$N#>t71T`3?ZnpHkVq`3&L^PV8S(*;
z5+;~v$^;XM#7+kGmQPSuEJ(?4>BiK~OsSw7IrFnn5NZ@;BciI({UqN#KcAof_k4b5
zbi-5vDgn1kK;E>L8M9*S3c5?=Et|`$UQC=*B+&vwa4(Hm;D4M+!|RI#ag29~xXpNH
zmB{PxY#jMRyH(EB&1xEQ@1KobM}M%>vLnX&ey#&k(_!)1T_}QR0f9!-ewt3)qejbq
zFdKSWI5)$s+}gUo(!1%{Jy|@1JBlO$q`9Y>IhXf!c^T3?60kiTBH=MJ&HXmpGr%21
znt<@8-}T-bIe(UGpH18zZtXPfahB=Gm+F|i+FxWQh9m)juo$<;v@rV{S0Uye*TBbx
zs92H(u>Y^U*K^DzjL{pugBO_5Bnb#7!T(UfaoeQ31i>!N9p$knAi$h>IBEmZ-Njsu
z(U2qB!-^tJK+`%&yG%N!4NK{SzA~xpDMGY8x~F^xR)3KYCm!~{VE-HJ^_2S&uD*WJ
z$G4BKfxSKfiww~I#`*+=LPXl%Vy+~Uo~^YIl~?8qmGC~@WsLcnZTJVpItx*w)qd%{
zKg0}^oxn?0V2nAKj!#};MnjA=LKY~;7c9C-_csK(qzrGRynTRq>G1Q(ORoXuE-i4H
u8TI%%6(!h7{F8wHNhP2XPzktk0Y3pT0mLDA*sh`g0000dL_t(|ob8!8a1%)shTmAWNH
zSdO>?Zxu&sDdnizO*kTpD+PsM6D$E&kr-lNHzkrWvcZ;R@Bcrm&1j?9Qjo$
zl?hp0&B#V#S$T=Ya>>V!{}m5B1r$@M3=Jpvr?s2Kq~boGBA#?WjLUPD_3Os-Mh8;>Qx~s$g>s7jV
zQSH-r`G2yC4nX(<;>V6@CTJZFvg_bMon1T3Qu+CF6{c3g%r8EChRsUx2aB-0|aD
zs2uEP)Wd42guD2VL_)T}_Y}nfA9(O!-AVxnUjRv5zph!(_6RuJRy>XfHNLp0Ss|U}I^26msN>1oY~=}7E6obm2d
zYP}l~d#m+9xB}>V`rp9zXjWw2BgXSwf!S^ie2w>f60Lx5=tNv$X)9)ju;b7!T(Nu5
zfqx8bDZmeQ0bB&_g1Oka5Uv0+2+s#$7B#_dXUq(@8+KS5noVp$9E2}`d!McY2N1PR
z`SokDdU`a2UNA%R*{lkx621Tq!mo|WZ{A4Po;`?~v-ISNEPQ|e@CA_G@o|YCJ&I@z
z`q=Ji6O*NvFQt3`enho!wh1T1iLG9u5r04rbIWX?e@(}+$AtFj+PfD~Enm)@!6O#N
zTet97OZmkM-HwB31Q0H0_L*)3v%v+9f}L;;A@;Sxz#8DfhwssU;c)8@yrhIGfT?d*
z3$%jU-%tRBu_-iyT?^!pfvi-qaII4u9!_Js``rft;8uYr4$e$)=DD4qkKNjC`G3_b
zJmNj}?Zaa|D!}t#E5xHk3gO~JxF~UtWMBrEEhlObV6Pq|iz%YSXze
z+gh}=4*LEW2A>xfJOvP~iAp64A)|P@ap2b&{LGqldfrJ>0S0Zc3k!#^P7LAA2O($(
zFNcR^&EP#}(dTERukUYrtMsB}0qx390h`T#)YqX;juYbn00000NkvXXt^-0~f(V;t
AlK=n!
delta 1080
zcmV-81jqZG3e5S#$KR|JLh!-qAdn)(Iix?W!AjBuuOPjp
zzrb>nl(GK;%YVT+Wh%>mfuzc%#$6ICc;!CCmsEkkN#H92(oR0J_TE`PR+hZWtq=5p
z$GqLq&CGY_&3kiJ@`1AwkToj-S+f$5H7fyGvl8Hbyx8=#lT$DKUpwvMrq^Uq?YLdAUmvmCy{@jhUzfLok=eiOYEKf7Sb)3f
z?HMISzT__9w0{!>Qe{yUx~4#E%We?A%x
zRTv_9as6NPmE0lvoei;EK5vHo;j3A{Tb9HEEQ@^EWp-*AQV}p2+a@rFEYzjzEeP|K
zqYf51E6b3MfGE4<)D8#JF1$`k-C%(}z7^<_IxleR9DjJzQk@&a&mdDYmx$~2_UX3#B~$*UN|pR&Pxk7G3f~)OpHjEvykI4
z7A71L|D`CrlEQe&iOv85p7MAvOgeZ}XMi+X7m)*O84gLKbxBAn0!WeeKC~`NKp0(-
zYQMlugnz;kVPpR`Mc$xf&smcb^F}TrQHsT?HQ5N>kfn?q@haqQs(mO5O9U0x<7U~x
z4aq6xU7UB$C{`UK;t4Q`rpT2yO69GQF_p@;C%chR_l`iV(68E`76L3VnzA>wmJ_d5#leKLm_8VG37Y-2NFvK9iWalf-!iH0*g
zGpn`PxeDzVjF}$yUF!IX`+UVC1ia?4fQ>kK$bcG<~>OrWq#pbq%d
zfg2!IQaBYy=)NklDU#K=ECX#52m%ae2
z-R)jyj{k4AL-*oNXV#tVn(#}u{mz`Gng97N=R0Qw{m;1}V1KExEkH)Y;W??@M?!XD
z=pIsxHw(`>5sAexT9icvB%|Tmbjt20N&Qq$1Pv+VI}nJ(ekBVm1;nGv`i0b^FqaG|
zlt?HtJ46+zC?F9HM(e)nm4G*bkwgSm83La&8e!#2spN?=0Y=Qr>@P4I4n_EGFU^TE
z0f{5QAtHJbpMOT=XmEF;p9&}vkS~?lYcLy@X5m{|SYnF=R1^Cm)JkgC8?cD77hWg*
z>W{E0qpZ4ve0RMHizxHmD7)fTr_7&sks2AFpT^?qoiFLrrgiRFtdPn
z{VwvX>!b8&j}bop?)M?O4KvPgMS!>cIZ{@3Q0Dv|^5P{ut=q7=#mULZu1$?l4&u~J
zgQQ=ZpxorUm~n__TqpO#*+nPI!4)X_U2)X
z7H~xX_dcGMhhf=AyZkxoZ?kcVvw}}==`VMp?#3{3NkBDWr2X~|wL7rE(ADT8+0H31Bt%($tu!f4`r%Zpyli1RZ&tLBDc;&gB
z;JRO0b{z?@y4b4a)glW~R(HU{#(#8W0*N~UD-U~sy=9`~QY>|<-FRt+1vKrLBxPAE
zMtN8`d(ZM}k*UY!u4mmY+)50>vQL^X%q|_XzT9Q%b-m8LlHC^EY`tw=uQSzL@;Zh0
zg{JM_80FUrZnsmX?;$;L+1?i%CiMsM%$KsL028CclD3(2*(n8u@u(A@
wS~4yq#gM7v=uy8aER|N+7SO2N5U|wz1LvOOPd-(*&Hw-a07*qoM6N<$f=+0xMgRZ+
delta 1061
zcmV+=1ls$63%Ll8BYy+{Nklw$eNXatXTE6&+GM;XF+R3SB{$K6W;-+_kQ>XJ*!OlKnx4Ru(b$=*t2Sc-e)zO|LAh7^<
z)7vvjip=CL;eWIf1X9JR3QbcWw&gf-Lf9O$a!1NY*YD^CP4_0bfFHeeGY~&t$Uhkl
z25J-{d2#(e^|g!l$9*u0g?P?W-1wI&c@0%lwz
z7D9-1^K0_+KU%*c1s_ngM=4Oo>ET_SE64BHb4f)&c-J>ER)GQg`Ooq{4L={e;P5-)
zZ#-E5-)j!N>bygb_H=Md^NdW*CYn4Sd%kj7jp_l
zNq-H(0Y7h`;}1@UHqSokv=Gf)ad2dI#-h*kKtPMfHf1IZ)+!Q
f1Z2%hK-Rnmz)tKyb=A1`00000NkvXXu0mjfL%06W
diff --git a/assets/hicolor/48x48/status/inputflow-tray-offline.png b/assets/hicolor/48x48/status/inputflow-tray-offline.png
index 23e247daa3ae06d08dd83a2198dcb60f459b0eef..7749d52fccef4c0ea01f9a09d9c19f7e4900819f 100644
GIT binary patch
delta 1407
zcmV-_1%Ud?2$2hrBYy>0Nkl
zLY!{V7V~gXHl=ZbS(K&^rbTnmVWCU9;5gcZttQ^M>?WuioLxYo(y&EK%q{|~76k2_
zzTXF4csYmnJ@0$=9yo4(HgkuT4lmp_JQd~t-&C)=CqRR>JX903(oN<~EXe8;?2UMg8x
zOSB{TtMcT~(MG&$P3`_l8Te^X?#{}@l~0;m+VJWeIDcH*)S#41gX>u;s#P!&kxH13
zIeZ`>t*6hxsb92npSPWbJt*@*fv=Qq(<1X%JuEv*UXTy#KgEi3;INH=1R}7r5cMG?t{|WHWT4Fl$DmquS35{=XV!$+WC+VUvzwh73aX=E(j==X>dJDA_ADx
zulirocYl@jP*eT7?Au?5l~|4)t&uw1ai$xi^;xJrZp}mkhrJa*%xP4)XO~>K*el%^
zd$2OgrVT~1aYLaro;an`r|jkJa_~qHEAD{9-V1p4nN5@|Uu=fH;99m5CMKnD-5OXTW8?qoyl*@al0<;R
z$q~RDXKFZssTN%$PJzS85sq8+E&lAaK}Q0c@e1ss=yw_2bU=Vo;UL
zbR>43*3%t`RH3XdnCeEv+-jWyhrJcRyl4I!uss?RnQO$zc}`%qTLZqwH8;TFr3ESH>|rLaVXhi}RC8#iIa0*Ad7K$_}Z
ze?(JJU8|qYiV>Nq2b@{{L2WRa+qU{Sw?!H37_MjWdI@Rr6?0ZwXxsW9LFI1$0F1&3=9
z0VUJmdKMh3l;D8j49tY#R)HrK(SMQPo#%Ffd2H2o<+d99v&@H2%pdh9b*m~
zFanI$eDHq_5!_=L#ZTdkp6$^hS%|}3-dWyZD<~qmE3u7sf8S$&xJwr5c)g~2x86QD
zfnw_FPdYnZCy5;Lx`nAxQ?*;SUpqK~>l#kV$ndDMA2KIrGAPW%*!c^j($mm?`Slk0KbFJ`Pb3U55
z0t^CZvH$%g#Sl`2Pi5;@FY!qazH^kXcik(UK~t-=!?A!=<&J>G=6~29&QI+k+)n@i
N002ovPDHLkV1l98uIm5*
delta 1079
zcmV-71jzf53(E+QBYy-ENklOJ7{{MkDTLsIQy`Ec#py_YT7#9O3Acju
zmOg>yCMjco0?VJ^oHCW=Phh!nsd1OY3SPMn=S!+UAQIda0cj_{S!wU|{^DJ3eV`va
z?6W(onfcE=^UP?KeBf*Zq|HV^+H3@*%|<}lYy`Mpd1Vtw-G7KYc9iV<*SanXaEo3w
zFmg)6UFAsC|4lb|d~c{H9)+U!(io{bB3FoLs7I3bweCb>$S4t}F
zYwc2WZ>TFC&vrcRWYi1)*XBiG$2+G}7xQMpjy_^{+bvyjzb@?teRFu*(w@X1wg7j>
zJ1|NLjO0Gyw0{=_QlV32il#tp!*S%CusLJqPL+|i-_j=(-JAFVzVY&=Cw?}UebVps
zR2U*zas6NPos1B@XG1KL&6-}P_iEOA>k?alWtEX#XQtL65do92Z31&lM_r2Ef-qk>
z>S&d2rHq{LC}cF%-WP?%f*sc5WZA?G
z$tdOBTsN=SteQr|6QB{zCRf@nmY(;G(K5slDY0W~i2-%TV^RR4-Ns&!QtL8=M(o-D
zM`Pp=EAS}fVF;KEp*46AA!H$3I%dTb`VRHyZGWrz1SiA}1d0atfqAv6utWsRI7Tdl
z5Nl_*fhTG}z64l>N5puuGqn+c6I5Kx(X&&kSD``2>G
zTDoB7%3Q~w&g58jsACil8O0L>{L5_)3t6ui7>Nq7SP>i^)aaIw50Dr0)XX-(cgt;9ibNVa+mSrN|yMTEhKnq{y
zuqj4Gkf~mhsq!-53IU!>ps@E2GgIaGaf&+=D}swp=c_V=W5+%-S(gb?5ny3-Z3hdu
zOF>YZeZa+?A~gZ4?%s5;s}_90qdYyrjP+XTGO{O3aN#`S6FlnyeOe%!+SdR8002ovPDHLkV1nb@0qXz&
diff --git a/assets/hicolor/48x48/status/inputflow-tray.png b/assets/hicolor/48x48/status/inputflow-tray.png
index 5ad3398d03a1bd18428c753526e22e9fd1475e8b..8f23907c6565a816a946fd52994210d2ea676dbb 100644
GIT binary patch
delta 1165
zcmV;81akYw2A~O$BYy;ENklfD7+0$`0V`C1s3&
zU6+reXh5Umj$jFxW<*eL?_g*lhDl@LlB0
zn1!CZeJIJz!_wBhyqlVHrCZR}+l@K=jKSCYk>7aN5r1LYD1;cJ1vH0uBZ{pIZPQ-5
z6;+)F@gE;b9&YpbKhbup3u93-Hy`_+e^ZHkjbe8etU>AjKeERW76M$%KwUvOT_<*E
z!pDo2p*UwgHgz6iPcq*ZzJ>Pw>-eGPByCsD$_VVZ*u)-3v{1lG#Nbyv#*F~+^v}U}
z)Tl~6w12+(5%P}iVox&NtGBBW7vIZleGvIQZO)8=w*rKC8~ydthd6NU9DZes=e-3>
zv4QQ4(?2Qu>@Qr8;?_Ovag6r@s`8ehkTbEF){%!?H?JTmIVIT--@K^A?W2`CIr96v
zPqN1`-V4yyDLTGeC8|7Z{?TvHH*}Z1V8wX~U4N@TQ=%lnJC<)7v62hC7a$ydd85h)
zDXD^tcJ}m{jDUd$_tmTj{X_SZ@7JAb_SU@5m6xhH)N$*h2#ms_~Po=~=u*%4&H61!C8y@Jmdui%|wd?~;Qz7QbvmR0lJ
zIl0ZXe0PlZ0%Y&=`n)BK?DO}{OKNZA0)OuXxUrE-`v~3(5WanGEh-nj$Y>Zz-1sEc
zu~2{Y3_iVZgpn&UBS3GNHByaq#fgsvm-Szh83BU)GLuc66|#wQ`AcjV1AEM}nr(~}
zo(d?Ln?4uD;eAb*GY~?I1OZ>I2Oj>x&^5AEsN;!4;!McMZ#sWyybn*T4z@fzT7MKF
zMX?QD2RClANAd)#qeX!>YQR)I+!dz$@w(vVkT>Jl{ewf}Gb<%oN0U;3;ES0^G4dLN
z&5~G7gfQ{5(MZpS5tGdaWGG#xdXTW0000~n3
delta 817
zcmV-11J3-Q3C9MIBYy)9Nkl2t;B!7Yf2JR~l8}WA?@%Gfv
zj~$h2@KXd$4>+cXNWH*ftn~$_?%@Z)g8arAgN6yPsub$Ij`leq&w-7Zq~q
z9uymS9uAo{Eq}hb110FiUA@n#sj(OEK3J3i;_^cI)o?ISqZBE_kH6|O+#>y-NwHG7
zEQ5=|`9uH9inRd4Bu_TWo63R{0e5p-64~WKeNNp#VX^Ym&Ln#!3vL9Y)up7?xiQVc
z8!V~ITwqMw2=rMxPiQu{;bR#}%cN5LNVUp>69I;$YJbp#V4Y^l4~~}{hF=_SIId7=
zop$sF9NGw
z`Ex7`+MlUCz%tqZu)~psU6#=XEO8=$CDQami^u@e=!~WIHBBoNhZQFGuS?_=3R~VB
zT5^e#RDYD>V$~d$lS@`9!9H(|xLs{jY6}p6Fo@_5Hh4;Xj63ZwG_9d;cko2!IKY4LpH)%MH#P87Ls)WsT!0jwm4H
z)d@}ojBDx`?3_)U@1luIp-;CM5o{Uu`CS~QLKvhk3K3h3EtiWo(D(CU4mn<6dssci
vvnmSs7Zd{WrVx-fg@C*%1msO2AaDKvnj&35VToW)00000NkvXXu0mjf=?Hyv
diff --git a/assets/hicolor/scalable/status/inputflow-tray-attention.svg b/assets/hicolor/scalable/status/inputflow-tray-attention.svg
index c7c7495..94b697d 100644
--- a/assets/hicolor/scalable/status/inputflow-tray-attention.svg
+++ b/assets/hicolor/scalable/status/inputflow-tray-attention.svg
@@ -1,29 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/hicolor/scalable/status/inputflow-tray-busy.svg b/assets/hicolor/scalable/status/inputflow-tray-busy.svg
index e45873d..7fd9f3e 100644
--- a/assets/hicolor/scalable/status/inputflow-tray-busy.svg
+++ b/assets/hicolor/scalable/status/inputflow-tray-busy.svg
@@ -1,29 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/hicolor/scalable/status/inputflow-tray-offline.svg b/assets/hicolor/scalable/status/inputflow-tray-offline.svg
index 96913be..1a3e23a 100644
--- a/assets/hicolor/scalable/status/inputflow-tray-offline.svg
+++ b/assets/hicolor/scalable/status/inputflow-tray-offline.svg
@@ -1,28 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/hicolor/scalable/status/inputflow-tray.svg b/assets/hicolor/scalable/status/inputflow-tray.svg
index c6301cb..8b9d71a 100644
--- a/assets/hicolor/scalable/status/inputflow-tray.svg
+++ b/assets/hicolor/scalable/status/inputflow-tray.svg
@@ -1,24 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/icons/inputflow-tray-attention.svg b/assets/icons/inputflow-tray-attention.svg
index c7c7495..94b697d 100644
--- a/assets/icons/inputflow-tray-attention.svg
+++ b/assets/icons/inputflow-tray-attention.svg
@@ -1,29 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/icons/inputflow-tray-busy.svg b/assets/icons/inputflow-tray-busy.svg
index e45873d..7fd9f3e 100644
--- a/assets/icons/inputflow-tray-busy.svg
+++ b/assets/icons/inputflow-tray-busy.svg
@@ -1,29 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/icons/inputflow-tray-offline.svg b/assets/icons/inputflow-tray-offline.svg
index 96913be..1a3e23a 100644
--- a/assets/icons/inputflow-tray-offline.svg
+++ b/assets/icons/inputflow-tray-offline.svg
@@ -1,28 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/icons/inputflow-tray.svg b/assets/icons/inputflow-tray.svg
index c6301cb..8b9d71a 100644
--- a/assets/icons/inputflow-tray.svg
+++ b/assets/icons/inputflow-tray.svg
@@ -1,24 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/android.md b/docs/android.md
index bfbae6a..a71a1cc 100644
--- a/docs/android.md
+++ b/docs/android.md
@@ -101,9 +101,14 @@ or root backend is selected — that includes secure fields. The shared
- The relay **refuses to start** and `android-pair` **refuses to emit a URI**
when the secret is weak (`< 16` characters or too little variety).
+- Relay protocol v2 derives separate send/receive keys from each random
+ challenge and protects every post-authentication frame with AES-256-GCM.
+ Sequence numbers reject replayed, reordered, or tampered input frames.
- Generate a strong (256-bit) secret with `android-pair --generate`.
- Keep the relay on a trusted LAN/VPN; never expose its port to the internet.
- Treat the pairing URI/QR like a password — it contains the secret.
+- Android builds from before protocol v2 cannot connect to this release; update
+ both peers together. Existing strong pairing secrets remain valid.
## Current limitations
diff --git a/docs/public-repository-audit.md b/docs/public-repository-audit.md
new file mode 100644
index 0000000..df3e72c
--- /dev/null
+++ b/docs/public-repository-audit.md
@@ -0,0 +1,65 @@
+# Public Repository Privacy Audit
+
+Assessment date: 2026-07-28
+
+## Scope
+
+The assessment covered:
+
+- every tracked file;
+- every untracked file not excluded by `.gitignore`;
+- all 58 commits reachable from local Git references;
+- source, documentation, tests, workflow, packaging, and Android files;
+- image metadata under the public asset and Android resource trees; and
+- Git author metadata.
+
+Build directories and other intentionally ignored local artifacts are not
+publishable repository content and were excluded from content-pattern checks.
+
+## Checks
+
+- Gitleaks scan of Git history with findings redacted.
+- Gitleaks scan of the current working tree with findings redacted.
+- Repository-specific checks for credential/key filenames, private-key
+ material, personal Linux and Windows profile paths, hard-coded runtime user
+ IDs, non-placeholder email addresses, private/link-local addresses, and MAC
+ addresses outside test fixtures.
+- Search for known local usernames and hostnames without printing matched
+ values.
+- EXIF and image-metadata inspection for GPS, author, owner, camera/device,
+ serial-number, location, and user-comment fields.
+- Validation of README local links and table-of-contents anchors.
+
+## Result
+
+- No credentials or private keys were found in the working tree or Git
+ history.
+- No known local username or hostname was found in publishable file content.
+- No personal home-directory or Windows profile path was found.
+- No private/link-local network address or production MAC address was found.
+- No risky personal, location, or device metadata was found in public images.
+- Documentation addresses, example users, placeholder email domains, protocol
+ constants, and test-only fixtures were reviewed as non-personal data.
+
+Git commit objects retain contributor names and author email metadata as normal
+project attribution. Some historical authors used direct addresses rather than
+GitHub noreply addresses. Those fields are not application secrets or device
+data and were not rewritten because changing them would alter published
+history and attribution. Contributors who prefer pseudonymous metadata should
+configure a verified GitHub noreply address before committing.
+
+## Preventive controls
+
+- `scripts/audit-public-repo.py` blocks common personal/device data and
+ sensitive artifacts.
+- `scripts/release-gate.sh` runs the audit before building a release.
+- `.gitignore` excludes local configuration, credentials, diagnostics,
+ signing material, build output, and analysis caches.
+- The pull-request template requires confirmation that secrets, real
+ addresses, and personal information were not added.
+- Diagnostics remain local by default and redact likely personal, device,
+ address, secret, and input-event data.
+
+History rewriting is intentionally not performed automatically. If a real
+credential is ever discovered, revoke or rotate it first, then coordinate any
+history rewrite with maintainers, forks, and existing clones.
diff --git a/docs/release-checklist.md b/docs/release-checklist.md
new file mode 100644
index 0000000..409e63d
--- /dev/null
+++ b/docs/release-checklist.md
@@ -0,0 +1,23 @@
+# Stable release checklist
+
+Run `scripts/release-gate.sh` from a clean release candidate.
+The gate stages the portable archive, unsigned Android release APK, changelog,
+CycloneDX SBOM, provenance statement, and `SHA256SUMS` under
+`build-release-gate/release/`.
+
+- All required CI jobs and the local release gate pass.
+- No unresolved P0/P1 bugs or known critical/high vulnerabilities remain.
+- Medium security findings have an owner, disposition, and target release.
+- Fedora RPM and portable archive pass clean install, upgrade, launch, and
+ removal smoke tests.
+- GNOME/KDE on X11/Wayland and Android API 26/current are smoke-tested.
+- Pairing, reconnect, suspend/resume, tray lifecycle, settings persistence,
+ clipboard, topology, notification sync, and permission revocation pass.
+- Diagnostics are previewed and confirmed free of secrets and personal/device
+ identifiers.
+- Release archives, APK, checksums, SBOM/provenance, changelog, compatibility,
+ privacy, and security documentation are published together.
+- The staged Android APK is unsigned by design. Sign it with the protected
+ release key, verify the signature, and replace the unsigned artifact before
+ publication.
+- The release candidate completes a seven-day reconnect and resource-usage soak.
diff --git a/docs/release-readiness-report.md b/docs/release-readiness-report.md
new file mode 100644
index 0000000..ee2b8e2
--- /dev/null
+++ b/docs/release-readiness-report.md
@@ -0,0 +1,63 @@
+# InputFlow 0.2.0 Release Readiness Report
+
+Assessment date: 2026-07-28
+
+## Outcome
+
+The current source is a release candidate. The automated release gate passes,
+release artifacts are reproducible from the working tree, and generated
+checksums verify. Publication still requires the external checks listed below.
+
+## Implemented controls
+
+- Android relay protocol v2 authenticates sessions with HMAC and protects
+ post-authentication traffic with directional AES-256-GCM keys, ordered
+ sequence numbers, replay rejection, frame limits, and authentication
+ timeouts.
+- Android pairing secrets use Android Keystore-backed AES-GCM storage with
+ migration away from legacy plaintext preferences.
+- Configuration and state use atomic, owner-only file writes and reject
+ symbolic-link targets.
+- Diagnostics are local-only by default, redact secrets and device/input
+ metadata, and require explicit options for journal or network data.
+- GUI-launched helpers use fixed argument vectors rather than shell command
+ strings.
+- Android backups exclude protected application data and lock-screen
+ notifications avoid endpoint details.
+- Linux and Android interfaces share an accessible InputFlow visual system,
+ masked secret fields, descriptive controls, day/night palettes, and
+ responsive settings surfaces.
+
+## Verification evidence
+
+- Nine-stage `scripts/release-gate.sh`: passed.
+- Linux CTest suite: 18/18 passed.
+- Diagnostics privacy regression: passed.
+- Portable archive checksum and extracted-binary smoke test: passed.
+- Android release assembly and JVM unit tests: passed.
+- Android release lint: 0 errors, 23 non-blocking maintenance warnings.
+- Release archive, unsigned APK, SBOM, and provenance checksums: passed.
+- Source whitespace check: passed.
+- Unsafe shell/process API scan: no matches.
+- GTK accessibility inspection: four tabs exposed, status named, secrets
+ masked, and settings actions reachable.
+
+## Publication requirements
+
+- Sign the Android APK with the protected production signing key, run
+ `apksigner verify`, and archive signing evidence. The generated APK is
+ intentionally unsigned.
+- Run the checklist on representative GNOME and KDE systems across X11 and
+ Wayland, plus Android API 26, a current Android release, and at least one
+ OEM device. No Android emulator or physical device was available for this
+ assessment.
+- Perform a multi-device seven-day soak covering reconnects, sleep/wake,
+ clipboard, topology transitions, notifications, and service restarts.
+- Run the gate from a reviewed clean commit in CI. Provenance generated from
+ the current working tree correctly records that the tree is dirty.
+- Review and intentionally accept or fix the remaining Android lint
+ maintenance warnings before the final store submission.
+
+No finite test program can prove an application is perfect or vulnerability
+free. Release approval should be based on the passing evidence above plus the
+external device matrix, soak, signing, and clean-commit checks.
diff --git a/docs/security-privacy.md b/docs/security-privacy.md
new file mode 100644
index 0000000..b6cdadb
--- /dev/null
+++ b/docs/security-privacy.md
@@ -0,0 +1,45 @@
+# Security and privacy model
+
+InputFlow is a remote-input utility for trusted LANs and private VPNs. The
+PowerToys Mouse Without Borders compatibility protocol is encrypted with
+AES-256-CBC but does not provide modern authenticated integrity. Do not expose
+its ports directly to the public internet.
+
+The native Linux-to-Android relay uses a separate protocol. A random challenge
+and the pairing secret derive directional session keys; all post-authentication
+frames use AES-256-GCM with ordered sequence numbers. This protects keyboard,
+pointer, notification, and topology data from passive capture, tampering, and
+replay on the local network.
+
+## Data handled
+
+| Data | Purpose | Storage and sharing |
+| --- | --- | --- |
+| Pairing secrets | Authenticate Linux, Windows, and Android peers | Linux Secret Service or owner-only files; Android Keystore encryption; never diagnostic output |
+| Hostnames and IP addresses | Connect to and display peers | Local config/state only; redacted from support bundles |
+| Keyboard, pointer, and clipboard data | Deliver the requested control stream | Processed in memory; never telemetry or diagnostics |
+| Android notification content | Optional notification synchronization | In memory during the active relay; excluded from diagnostics |
+| Monitor geometry and topology | Route pointer movement | Local config/state; diagnostics report only non-identifying capability summaries |
+| OS/session/package details | Troubleshooting | Local, user-reviewed diagnostic bundle only |
+
+InputFlow has no telemetry or automatic crash/diagnostic upload. Diagnostic
+bundles remain on the device, omit journal and network details by default, and
+include a machine-readable consent manifest. Users must review and attach a
+bundle manually.
+
+## Privileged boundaries
+
+- `/dev/uinput`, Android Accessibility, Shizuku, root injection, notification
+ access, and IME access are separate user/admin grants.
+- Native Android injection requires an additional in-app consent toggle.
+- Revoking a platform permission must disable the corresponding feature without
+ weakening another boundary.
+- Config and state writes are atomic, owner-only, and reject symlink targets.
+
+Security-sensitive logs must describe state transitions without pairing
+secrets, clipboard/notification contents, device models, usernames, hostnames,
+or network addresses.
+
+Input-event metadata is logged only when an explicit debug-input option is
+enabled. Do not enable that option during normal use, and review journal output
+before sharing it.
diff --git a/packaging/portable/README.md b/packaging/portable/README.md
new file mode 100644
index 0000000..96211c7
--- /dev/null
+++ b/packaging/portable/README.md
@@ -0,0 +1,17 @@
+# Portable Linux archive
+
+The `.tar.gz` release is a relocatable user-space bundle. Extract it, then run:
+
+```bash
+./inputflow-*/bin/inputflow-controller
+```
+
+The wrapper locates the bundled client and controller without modifying the
+system. Input injection still requires administrator-approved `/dev/uinput`
+access. Files under `share/inputflow/system-integration/` are reference copies;
+review and install them through the distribution package or an administrator
+rather than copying them automatically.
+
+The RPM remains the supported system-integrated installation for Fedora. The
+portable archive is supported for tested Ubuntu/Fedora desktop sessions but
+does not register autostart, udev, sysusers, or desktop menu entries itself.
diff --git a/packaging/portable/inputflow-controller b/packaging/portable/inputflow-controller
new file mode 100755
index 0000000..a08059d
--- /dev/null
+++ b/packaging/portable/inputflow-controller
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
+PACKAGE_ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
+CONTROLLER="$PACKAGE_ROOT/libexec/inputflow/mwb-desktop-ui.sh"
+
+if [[ ! -x "$CONTROLLER" ]]; then
+ echo "InputFlow controller is missing: $CONTROLLER" >&2
+ exit 1
+fi
+
+PATH="$PACKAGE_ROOT/bin:$PATH" exec "$CONTROLLER" "${@:-menu}"
diff --git a/scripts/audit-public-repo.py b/scripts/audit-public-repo.py
new file mode 100644
index 0000000..3dded02
--- /dev/null
+++ b/scripts/audit-public-repo.py
@@ -0,0 +1,163 @@
+#!/usr/bin/env python3
+"""Reject files and content that should not be published in the repository."""
+
+from __future__ import annotations
+
+import argparse
+import ipaddress
+import pathlib
+import re
+import subprocess
+import sys
+
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+
+SENSITIVE_FILE = re.compile(
+ r"(^|/)(?:"
+ r"\.env(?:\.|$)|"
+ r"id_(?:rsa|ed25519)|"
+ r"config\.ini$|"
+ r"inputflow-diagnostics-.*\.(?:tar\.gz|zip)$|"
+ r".*\.(?:pem|key|p12|pfx|jks|keystore|mobileprovision)$"
+ r")",
+ re.IGNORECASE,
+)
+PRIVATE_KEY = re.compile(r"BEGIN (?:[A-Z ]+ )?PRIVATE KEY")
+HOME_PATH = re.compile(
+ r"(? list[str]:
+ result = subprocess.run(
+ ["git", *arguments, "-z"],
+ cwd=ROOT,
+ check=True,
+ stdout=subprocess.PIPE,
+ )
+ return [value for value in result.stdout.decode().split("\0") if value]
+
+
+def publishable_paths(include_untracked: bool) -> list[str]:
+ paths = set(git_paths(["ls-files"]))
+ if include_untracked:
+ paths.update(git_paths(["ls-files", "--others", "--exclude-standard"]))
+ return sorted(paths)
+
+
+def is_disallowed_ip(value: str) -> bool:
+ try:
+ address = ipaddress.ip_address(value)
+ except ValueError:
+ return False
+ if any(address in network for network in DOCUMENTATION_NETWORKS):
+ return False
+ return address.is_private or address.is_link_local
+
+
+def audit_file(relative: str) -> list[str]:
+ problems: list[str] = []
+ if SENSITIVE_FILE.search(relative):
+ problems.append("sensitive filename")
+
+ path = ROOT / relative
+ if not path.is_file() or path.is_symlink():
+ return problems
+
+ data = path.read_bytes()
+ if b"\0" in data:
+ return problems
+ text = data.decode("utf-8", errors="replace")
+
+ if PRIVATE_KEY.search(text):
+ problems.append("private-key material")
+
+ for match in HOME_PATH.finditer(text):
+ if match.group(1).lower() not in PLACEHOLDER_USERS:
+ problems.append("personal home-directory path")
+ break
+
+ for match in WINDOWS_PROFILE.finditer(text):
+ if match.group(1).lower() not in PLACEHOLDER_USERS:
+ problems.append("personal Windows profile path")
+ break
+
+ if RUNTIME_UID.search(text):
+ problems.append("hard-coded desktop runtime UID")
+
+ for match in EMAIL.finditer(text):
+ domain = match.group(1).rsplit("@", 1)[1].lower()
+ if domain not in PLACEHOLDER_EMAIL_DOMAINS:
+ problems.append("personal email address")
+ break
+
+ if any(is_disallowed_ip(value) for value in IPV4.findall(text)):
+ problems.append("private or link-local IP address")
+
+ if not relative.startswith("tests/") and MAC.search(text):
+ problems.append("MAC address")
+
+ return problems
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(
+ description="Audit publishable repository files for secrets and personal/device data."
+ )
+ parser.add_argument(
+ "--include-untracked",
+ action="store_true",
+ help="also inspect untracked files not excluded by .gitignore",
+ )
+ args = parser.parse_args()
+
+ findings: list[tuple[str, str]] = []
+ paths = publishable_paths(args.include_untracked)
+ for relative in paths:
+ for problem in audit_file(relative):
+ findings.append((relative, problem))
+
+ if findings:
+ for relative, problem in findings:
+ print(f"{relative}: {problem}", file=sys.stderr)
+ print(
+ f"public repository audit failed with {len(findings)} finding(s)",
+ file=sys.stderr,
+ )
+ return 1
+
+ scope = "tracked and untracked" if args.include_untracked else "tracked"
+ print(f"public repository audit passed: {len(paths)} {scope} files")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/generate-release-metadata.py b/scripts/generate-release-metadata.py
new file mode 100644
index 0000000..530781b
--- /dev/null
+++ b/scripts/generate-release-metadata.py
@@ -0,0 +1,205 @@
+#!/usr/bin/env python3
+"""Create a CycloneDX SBOM, provenance statement, and publishable artifact set."""
+
+from __future__ import annotations
+
+import argparse
+import datetime as dt
+import hashlib
+import json
+import pathlib
+import platform
+import re
+import shutil
+import subprocess
+
+
+def sha256(path: pathlib.Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as source:
+ for chunk in iter(lambda: source.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def command_output(arguments: list[str], cwd: pathlib.Path) -> str:
+ result = subprocess.run(
+ arguments,
+ cwd=cwd,
+ check=False,
+ capture_output=True,
+ text=True,
+ timeout=20,
+ )
+ return result.stdout.strip() if result.returncode == 0 else ""
+
+
+def project_version(repo: pathlib.Path) -> str:
+ cmake = (repo / "CMakeLists.txt").read_text(encoding="utf-8")
+ match = re.search(r"project\([^)]*\bVERSION\s+([0-9.]+)", cmake, re.DOTALL)
+ if not match:
+ raise SystemExit("Unable to determine project version")
+ return match.group(1)
+
+
+def gradle_components(repo: pathlib.Path) -> list[dict[str, object]]:
+ gradle = (repo / "android/app/build.gradle").read_text(encoding="utf-8")
+ dependencies: list[dict[str, object]] = []
+ pattern = re.compile(
+ r'^\s*(?:implementation|testImplementation)\s+["\']'
+ r"([^:'\"]+):([^:'\"]+):([^'\"]+)[\"']",
+ re.MULTILINE,
+ )
+ for group, name, version in pattern.findall(gradle):
+ dependencies.append(
+ {
+ "type": "library",
+ "group": group,
+ "name": name,
+ "version": version,
+ "purl": f"pkg:maven/{group}/{name}@{version}",
+ }
+ )
+ return dependencies
+
+
+def native_components(binary: pathlib.Path, repo: pathlib.Path) -> list[dict[str, object]]:
+ output = command_output(["ldd", str(binary)], repo)
+ components: list[dict[str, object]] = []
+ seen: set[str] = set()
+ for line in output.splitlines():
+ match = re.match(r"\s*([^\s]+)\s+=>\s+(/[^\s]+)", line)
+ if not match:
+ continue
+ name, resolved = match.groups()
+ library = pathlib.Path(resolved)
+ if name in seen or not library.is_file():
+ continue
+ seen.add(name)
+ components.append(
+ {
+ "type": "library",
+ "name": name,
+ "hashes": [{"alg": "SHA-256", "content": sha256(library)}],
+ "properties": [
+ {"name": "inputflow:discovery", "value": "runtime-linker"}
+ ],
+ }
+ )
+ return components
+
+
+def copy_artifact(source: pathlib.Path, destination: pathlib.Path) -> pathlib.Path:
+ target = destination / source.name
+ shutil.copy2(source, target)
+ return target
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--build-dir", default="build")
+ parser.add_argument("--android-apk")
+ args = parser.parse_args()
+
+ repo = pathlib.Path(__file__).resolve().parent.parent
+ build_dir = (repo / args.build_dir).resolve()
+ release_dir = build_dir / "release"
+ release_dir.mkdir(parents=True, exist_ok=True)
+ version = project_version(repo)
+
+ archives = sorted(build_dir.glob("inputflow-*.tar.gz"))
+ if len(archives) != 1:
+ raise SystemExit("Expected exactly one portable archive")
+ apk_source = (
+ pathlib.Path(args.android_apk).resolve()
+ if args.android_apk
+ else repo / "android/app/build/outputs/apk/release/app-release-unsigned.apk"
+ )
+ if not apk_source.is_file():
+ raise SystemExit(f"Android release APK is missing: {apk_source}")
+
+ copied = [
+ copy_artifact(archives[0], release_dir),
+ copy_artifact(apk_source, release_dir),
+ copy_artifact(repo / "CHANGELOG.md", release_dir),
+ ]
+ copied[1].rename(release_dir / f"inputflow-android-{version}-unsigned.apk")
+ copied[1] = release_dir / f"inputflow-android-{version}-unsigned.apk"
+
+ timestamp = dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat()
+ native_binary = build_dir / "mwb_client"
+ components = gradle_components(repo)
+ if native_binary.is_file():
+ components.extend(native_components(native_binary, repo))
+
+ sbom_path = release_dir / "inputflow-sbom.cdx.json"
+ sbom = {
+ "bomFormat": "CycloneDX",
+ "specVersion": "1.5",
+ "version": 1,
+ "metadata": {
+ "timestamp": timestamp,
+ "component": {
+ "type": "application",
+ "name": "InputFlow",
+ "version": version,
+ },
+ },
+ "components": components,
+ }
+ sbom_path.write_text(json.dumps(sbom, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+
+ commit = command_output(["git", "rev-parse", "HEAD"], repo)
+ dirty = bool(command_output(["git", "status", "--porcelain"], repo))
+ subject_paths = copied[:2] + [sbom_path]
+ provenance_path = release_dir / "inputflow-provenance.json"
+ provenance = {
+ "_type": "https://in-toto.io/Statement/v1",
+ "subject": [
+ {
+ "name": path.name,
+ "digest": {"sha256": sha256(path)},
+ }
+ for path in subject_paths
+ ],
+ "predicateType": "https://slsa.dev/provenance/v1",
+ "predicate": {
+ "buildDefinition": {
+ "buildType": "inputflow-local-release-gate/v1",
+ "externalParameters": {
+ "version": version,
+ "linuxBuildType": "Release",
+ "androidArtifactSigned": False,
+ },
+ "internalParameters": {"workingTreeDirty": dirty},
+ "resolvedDependencies": (
+ [{"uri": "git+local", "digest": {"gitCommit": commit}}]
+ if commit
+ else []
+ ),
+ },
+ "runDetails": {
+ "builder": {"id": "inputflow-local-release-gate"},
+ "metadata": {
+ "invocationId": f"{platform.system().lower()}-{commit[:12] or 'unknown'}",
+ "startedOn": timestamp,
+ "finishedOn": timestamp,
+ },
+ },
+ },
+ }
+ provenance_path.write_text(
+ json.dumps(provenance, indent=2, sort_keys=True) + "\n",
+ encoding="utf-8",
+ )
+
+ checksum_paths = copied[:2] + [sbom_path, provenance_path]
+ checksum_text = "".join(f"{sha256(path)} {path.name}\n" for path in checksum_paths)
+ (release_dir / "SHA256SUMS").write_text(checksum_text, encoding="utf-8")
+ print(f"release metadata generated in {release_dir}")
+ print("Android APK is unsigned; sign and verify it before publication.")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/inputflow-diagnostics-bundle.sh b/scripts/inputflow-diagnostics-bundle.sh
index 0206407..8249ce1 100755
--- a/scripts/inputflow-diagnostics-bundle.sh
+++ b/scripts/inputflow-diagnostics-bundle.sh
@@ -6,7 +6,7 @@ SCRIPT_NAME="$(basename "$0")"
usage() {
cat <|SECURITY_KEY|security key)[^[:space:]]*/\1[REDACTED]/Ig' \
+ -e 's#/(home|Users)/[^/[:space:]]+#/\1/[REDACTED_USER]#g' \
+ -e 's/^([[:space:]]*[^#;[:space:]]*(host|peer|machine|device|address|username|user_name)[^=]*[[:space:]]*=[[:space:]]*).*/\1[REDACTED]/I' \
+ -e 's/^([[:space:]]*(User|Host|Hostname|Repository|config_path|state_path|Static hostname)[=:][[:space:]]*).*/\1[REDACTED]/I' \
+ -e 's/^((USER|LOGNAME|HOSTNAME)=).*/\1[REDACTED]/' \
+ -e 's/([[:xdigit:]]{2}:){5}[[:xdigit:]]{2}/[REDACTED_MAC]/g' \
+ -e 's/([0-9]{1,3}\.){3}[0-9]{1,3}/[REDACTED_IP]/g' \
+ -e 's/(^|[[:space:]])(vk|key|flags|wParam|mouseData|x|y)=(-?0x[[:xdigit:]]+|-?[0-9]+)/\1\2=[REDACTED_INPUT]/Ig' \
-e 's/[A-Fa-f0-9]{32,}/[REDACTED_HEX]/g'
}
@@ -118,6 +145,16 @@ safe_run() {
} 2>&1 | redact_stream >"$output_file"
}
+safe_run_bounded() {
+ local output_file="$1"
+ shift
+ if have timeout; then
+ safe_run "$output_file" timeout --signal=TERM --kill-after=2s 10s "$@"
+ else
+ safe_run "$output_file" "$@"
+ fi
+}
+
safe_shell() {
local output_file="$1"
local description="$2"
@@ -143,7 +180,7 @@ redacted_copy_or_note() {
else
printf 'present=no\n'
fi
- } >"$output_file"
+ } | redact_stream >"$output_file"
}
json_escape() {
@@ -232,7 +269,7 @@ write_json_summary() {
printf ' "input": {"uinput_present": '; json_bool "$uinput_present"; printf ', "uinput_writable": '; json_bool "$uinput_writable"; printf ', "uinput_module_loaded": '; json_bool "$uinput_module"; printf '},\n'
printf ' "tools": {"wl_copy": '; have wl-copy && printf true || printf false; printf ', "wl_paste": '; have wl-paste && printf true || printf false; printf ', "xclip": '; have xclip && printf true || printf false; printf ', "xsel": '; have xsel && printf true || printf false; printf ', "secret_tool": '; have secret-tool && printf true || printf false; printf ', "systemctl": '; have systemctl && printf true || printf false; printf ', "journalctl": '; have journalctl && printf true || printf false; printf ', "ip": '; have ip && printf true || printf false; printf ', "ss": '; have ss && printf true || printf false; printf '}\n'
printf '}\n'
- } >"$output_file"
+ } | redact_stream >"$output_file"
}
write_config_summary() {
@@ -300,24 +337,47 @@ modified=%y' "$STATE_PATH" 2>/dev/null || true
printf 'peer_lines=%s\n' "${peer_lines:-0}"
printf '\n[redacted state]\n'
redact_stream <"$STATE_PATH"
- } >"$output_file"
+ } | redact_stream >"$output_file"
}
write_manifest() {
- cat >"$BUNDLE_DIR/README.txt" </dev/null || date)
-Host: $(hostname 2>/dev/null || printf 'unknown')
-User: $(id -un 2>/dev/null || printf 'unknown')
-Repository: $REPO_ROOT
+Host: [REDACTED]
+User: [REDACTED]
+Repository: [REDACTED]
+
+This bundle remains on this device until you choose to share it. Security keys,
+tokens, passwords, secret IDs, home-directory usernames, network addresses, and
+hardware addresses are redacted by best effort. Review every file before sharing.
+
+Recent service journal included: $INCLUDE_JOURNAL
+Network details included: $INCLUDE_NETWORK
+EOF
+ } | redact_stream >"$BUNDLE_DIR/README.txt"
+}
-This bundle is redacted by best effort before files are written. Security keys,
-tokens, passwords, key file values, secret IDs, and long hex strings are replaced.
-Review before sharing outside trusted support channels.
+write_consent_manifest() {
+ cat >"$BUNDLE_DIR/consent.json" </dev/null || date
-uname -a
+uname -srmo
printf "\n[/etc/os-release]\n"
test -r /etc/os-release && cat /etc/os-release
printf "\n[session]\n"
-id
+printf "uid=%s\ngid=%s\ngroups=%s\n" "$(id -u)" "$(id -g)" "$(id -G)"
printf "SHELL=%s\nUSER=%s\nLOGNAME=%s\nXDG_SESSION_TYPE=%s\nXDG_CURRENT_DESKTOP=%s\nDESKTOP_SESSION=%s\nDISPLAY=%s\nWAYLAND_DISPLAY=%s\n" "${SHELL:-}" "${USER:-}" "${LOGNAME:-}" "${XDG_SESSION_TYPE:-}" "${XDG_CURRENT_DESKTOP:-}" "${DESKTOP_SESSION:-}" "${DISPLAY:-}" "${WAYLAND_DISPLAY:-}"
if command -v loginctl >/dev/null 2>&1 && test -n "${XDG_SESSION_ID:-}"; then
loginctl show-session "$XDG_SESSION_ID" --no-pager 2>/dev/null
@@ -342,21 +402,23 @@ if ! command -v systemctl >/dev/null 2>&1; then
echo "systemctl not found"
exit 0
fi
-systemctl --user --no-pager status mwb-client.service inputflow.service 2>&1
+systemctl --user --no-pager --lines=0 status mwb-client.service inputflow.service 2>&1
printf "\n[known matching units]\n"
systemctl --user --no-pager list-units "mwb*" "inputflow*" 2>&1
printf "\n[unit files]\n"
systemctl --user --no-pager list-unit-files "mwb*" "inputflow*" 2>&1
'
-safe_shell "$BUNDLE_DIR/journal-user-recent.txt" "collect recent user journal logs" '
+if [[ "$INCLUDE_JOURNAL" -eq 1 ]]; then
+ safe_shell "$BUNDLE_DIR/journal-user-recent.txt" "collect recent user journal logs" '
set +e
if ! command -v journalctl >/dev/null 2>&1; then
echo "journalctl not found"
exit 0
fi
-journalctl --user --no-pager --since "2 hours ago" -u mwb-client.service -u inputflow.service -n 300 2>&1
+journalctl --user --no-pager --output=cat --since "2 hours ago" -u mwb-client.service -u inputflow.service -n 300 2>&1
'
+fi
safe_shell "$BUNDLE_DIR/uinput-state.txt" "collect uinput state" '
set +e
@@ -364,7 +426,7 @@ printf "[device]\n"
ls -l /dev/uinput 2>&1
stat /dev/uinput 2>&1
printf "\n[user groups]\n"
-id
+printf "uid=%s\ngid=%s\ngroups=%s\n" "$(id -u)" "$(id -g)" "$(id -G)"
printf "\n[modules]\n"
grep "^uinput " /proc/modules 2>/dev/null || true
lsmod 2>/dev/null | grep -E "^uinput|uinput" || true
@@ -372,7 +434,8 @@ printf "\n[udev rules]\n"
find /etc/udev/rules.d /usr/lib/udev/rules.d /lib/udev/rules.d -maxdepth 1 \( -iname "*uinput*" -o -iname "*inputflow*" -o -iname "*mwb*" \) -print -exec sh -c '"'"'for f; do echo "--- $f"; sed -n "1,120p" "$f"; done'"'"' sh {} + 2>/dev/null
'
-safe_shell "$BUNDLE_DIR/network-hints.txt" "collect network hints" '
+if [[ "$INCLUDE_NETWORK" -eq 1 ]]; then
+ safe_shell "$BUNDLE_DIR/network-hints.txt" "collect network hints" '
set +e
hostnamectl 2>/dev/null || hostname 2>/dev/null
printf "\n[addresses]\n"
@@ -387,9 +450,8 @@ if command -v ss >/dev/null 2>&1; then
else
echo "ss not found"
fi
-printf "\n[host resolution]\n"
-getent hosts "$(hostname)" 2>/dev/null || true
'
+fi
safe_shell "$BUNDLE_DIR/package-build-info.txt" "collect package and build info" "
set +e
@@ -404,7 +466,6 @@ for f in '$REPO_ROOT/build/mwb_client' '$REPO_ROOT/build/mwb_tray'; do
if test -e \"\$f\"; then
ls -l \"\$f\"
file \"\$f\" 2>/dev/null || true
- \"\$f\" --version 2>&1 || true
else
echo \"missing: \$f\"
fi
@@ -423,15 +484,16 @@ fi
"
if [[ -x "$REPO_ROOT/build/mwb_client" ]]; then
- safe_run "$BUNDLE_DIR/mwb-client-doctor.txt" "$REPO_ROOT/build/mwb_client" doctor --config "$CONFIG_PATH" --state "$STATE_PATH"
+ safe_run_bounded "$BUNDLE_DIR/mwb-client-doctor.txt" "$REPO_ROOT/build/mwb_client" doctor --config "$CONFIG_PATH" --state "$STATE_PATH"
elif command -v mwb_client >/dev/null 2>&1; then
- safe_run "$BUNDLE_DIR/mwb-client-doctor.txt" mwb_client doctor --config "$CONFIG_PATH" --state "$STATE_PATH"
+ safe_run_bounded "$BUNDLE_DIR/mwb-client-doctor.txt" mwb_client doctor --config "$CONFIG_PATH" --state "$STATE_PATH"
else
printf 'mwb_client executable not found at %s or in PATH\n' "$REPO_ROOT/build/mwb_client" >"$BUNDLE_DIR/mwb-client-doctor.txt"
fi
FINAL_PATH="$BUNDLE_DIR"
-if have tar; then
+find "$BUNDLE_DIR" -type f -exec chmod 600 {} + 2>/dev/null || true
+if [[ "$PREVIEW" -eq 0 ]] && have tar; then
ARCHIVE_PATH="$OUTPUT_DIR/$BUNDLE_NAME.tar.gz"
if tar -czf "$ARCHIVE_PATH" -C "$OUTPUT_DIR" "$BUNDLE_NAME" >/dev/null 2>&1; then
rm -rf "$BUNDLE_DIR"
@@ -439,7 +501,7 @@ if have tar; then
else
log "warning: tar failed; leaving bundle directory unarchived"
fi
-else
+elif [[ "$PREVIEW" -eq 0 ]]; then
log "warning: tar not found; leaving bundle directory unarchived"
fi
diff --git a/scripts/release-gate.sh b/scripts/release-gate.sh
new file mode 100755
index 0000000..9f1d3d3
--- /dev/null
+++ b/scripts/release-gate.sh
@@ -0,0 +1,68 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
+BUILD_DIR="${INPUTFLOW_RELEASE_BUILD_DIR:-$REPO_ROOT/build-release-gate}"
+
+cd "$REPO_ROOT"
+
+echo "[1/9] Repository hygiene"
+git diff --check
+python3 scripts/audit-public-repo.py --include-untracked
+if git ls-files | grep -E '(^|/)(\.env($|\.)|.*\.(pem|p12|pfx|key)|inputflow-diagnostics-.*\.tar\.gz)$'; then
+ echo "release gate: tracked secret or diagnostics artifact detected" >&2
+ exit 1
+fi
+if git grep -nE -- 'BEGIN ([A-Z ]+ )?PRIVATE KEY|AKIA[0-9A-Z]{16}' -- \
+ ':!tests/test_diagnostics_privacy.sh'; then
+ echo "release gate: possible embedded credential detected" >&2
+ exit 1
+fi
+
+echo "[2/9] Script and privacy checks"
+bash -n mwb-desktop-ui.sh scripts/*.sh tests/*.sh
+PYTHONPYCACHEPREFIX="$BUILD_DIR/pycache" python3 -m py_compile scripts/generate-release-metadata.py
+tests/test_diagnostics_privacy.sh scripts/inputflow-diagnostics-bundle.sh
+
+echo "[3/9] Packaging metadata"
+scripts/validate-rpm-packaging.sh
+
+echo "[4/9] Configure release build"
+cmake -S . -B "$BUILD_DIR" -DCMAKE_BUILD_TYPE=Release
+
+echo "[5/9] Build Linux targets"
+cmake --build "$BUILD_DIR" --parallel
+
+echo "[6/9] Linux regression suite"
+ctest --test-dir "$BUILD_DIR" --output-on-failure
+
+echo "[7/9] Portable archive"
+cmake --build "$BUILD_DIR" --target package
+PORTABLE_ARCHIVE="$(find "$BUILD_DIR" -maxdepth 1 -name 'inputflow-*.tar.gz' -print -quit)"
+test -n "$PORTABLE_ARCHIVE"
+test -n "$(find "$BUILD_DIR" -maxdepth 1 -name 'inputflow-*.tar.gz.sha256' -print -quit)"
+tests/test_portable_archive.sh "$PORTABLE_ARCHIVE"
+
+echo "[8/9] Android release build, tests, and lint"
+if [[ "${INPUTFLOW_SKIP_ANDROID:-0}" == "1" ]]; then
+ echo "Android gate explicitly skipped"
+elif [[ -n "${JAVA_HOME:-}" && ! -x "${JAVA_HOME}/bin/java" ]]; then
+ env -u JAVA_HOME ./android/gradlew -p android \
+ :app:assembleRelease :app:testDebugUnitTest :app:lintRelease \
+ --no-daemon --stacktrace
+else
+ ./android/gradlew -p android \
+ :app:assembleRelease :app:testDebugUnitTest :app:lintRelease \
+ --no-daemon --stacktrace
+fi
+
+echo "[9/9] SBOM, provenance, and release checksums"
+if [[ "${INPUTFLOW_SKIP_ANDROID:-0}" == "1" ]]; then
+ echo "Release metadata skipped because the Android artifact was skipped"
+else
+ python3 scripts/generate-release-metadata.py \
+ --build-dir "$BUILD_DIR" \
+ --android-apk android/app/build/outputs/apk/release/app-release-unsigned.apk
+fi
+
+echo "InputFlow release gate passed"
diff --git a/scripts/validate-rpm-packaging.sh b/scripts/validate-rpm-packaging.sh
index ad83b17..cacf2d3 100755
--- a/scripts/validate-rpm-packaging.sh
+++ b/scripts/validate-rpm-packaging.sh
@@ -83,7 +83,7 @@ grep -Eq '^Terminal=false$' packaging/usr/share/applications/inputflow.desktop |
grep -Eq '^DIAGNOSTICS_BUNDLE_SCRIPT="\$SCRIPT_DIR/scripts/inputflow-diagnostics-bundle\.sh"$' mwb-desktop-ui.sh || fail "desktop UI must resolve diagnostics bundle next to the packaged controller"
bash -n scripts/inputflow-diagnostics-bundle.sh || fail "diagnostics bundle script must pass bash syntax checks"
grep -Eq 'doctor --config' scripts/inputflow-diagnostics-bundle.sh || fail "diagnostics bundle must collect client doctor output"
-grep -Eq 'systemctl --user --no-pager status mwb-client\.service' scripts/inputflow-diagnostics-bundle.sh || fail "diagnostics bundle must collect user service status"
+grep -Eq 'systemctl --user --no-pager( --[^ ]+)* status mwb-client\.service' scripts/inputflow-diagnostics-bundle.sh || fail "diagnostics bundle must collect user service status"
grep -Eq 'redacted' scripts/inputflow-diagnostics-bundle.sh || fail "diagnostics bundle must redact config/state content"
grep -Eq '%\{_bindir\}/mwb_client' "$spec_file" || fail "spec must package mwb_client"
grep -Eq '%\{_bindir\}/mwb_tray' "$spec_file" || fail "spec must package mwb_tray"
diff --git a/src/AndroidRelay.cpp b/src/AndroidRelay.cpp
index 5d06d71..06c73fa 100644
--- a/src/AndroidRelay.cpp
+++ b/src/AndroidRelay.cpp
@@ -5,25 +5,48 @@
#include
#include
#include
+#include
#include
+#include
+#include
#include
#include
+#include
#include
#include
+#include
#include
#include
+#include
#include
+#include
+#include
#include
#include
#include
+#include
+#include
+#include
#include
#include
+#if defined(MWB_HAVE_LIBEI_INPUT_CAPTURE)
+#include
+#include