diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4a55203..bb2af35 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -47,6 +47,8 @@ jobs:
ccache \
cmake \
ninja-build \
+ libayatana-appindicator3-dev \
+ libgtk-3-dev \
libx11-dev \
libssl-dev \
pkg-config \
@@ -61,6 +63,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 +182,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 +211,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 e4da20e..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/InjectorManager.kt b/android/app/src/main/java/com/inputflow/android/InjectorManager.kt
index b1a347e..d28b72d 100644
--- a/android/app/src/main/java/com/inputflow/android/InjectorManager.kt
+++ b/android/app/src/main/java/com/inputflow/android/InjectorManager.kt
@@ -154,15 +154,22 @@ object InjectorManager {
/** @return true if handled natively; false to let the accessibility path handle it. */
fun handleMouse(frame: JSONObject): Boolean {
val n = native ?: return false
- n.handleMouse(frame)
- return true
+ return n.handleMouse(frame)
}
+ fun hasNativeInjector(): Boolean = native != null
+
fun handleKeyboard(frame: JSONObject): Boolean {
val n = native ?: return false
return n.handleKeyboard(frame)
}
+ /** Native continuous scroll for 2-finger trackpad gestures. */
+ fun handleScroll(frame: JSONObject): Boolean {
+ val n = native ?: return false
+ return n.scrollBy(frame.optDouble("dx", 0.0).toFloat(), frame.optDouble("dy", 0.0).toFloat())
+ }
+
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/InputFlowAccessibilityService.kt b/android/app/src/main/java/com/inputflow/android/InputFlowAccessibilityService.kt
index e6cb1d5..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
}
@@ -290,17 +290,25 @@ class InputFlowAccessibilityService : AccessibilityService() {
}
private fun handleSwipe3(dx: Double, dy: Double) {
+ if (isAccurateHorizontalSwipe(dx, dy)) {
+ dispatchPageSwipe(if (dx < 0) -1f else 1f)
+ 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)) {
+ dispatchPageSwipe(if (dx < 0) -1f else 1f)
+ 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({
@@ -524,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) {
@@ -551,6 +568,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
@@ -561,6 +582,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/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 c24a83e..f96dcec 100644
--- a/android/app/src/main/java/com/inputflow/android/NativeInjector.kt
+++ b/android/app/src/main/java/com/inputflow/android/NativeInjector.kt
@@ -23,20 +23,32 @@ 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
+ 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) {
+ fun handleMouse(frame: JSONObject): Boolean {
val m = metricsProvider()
if (!primed) {
px = m.widthPixels / 2f
py = m.heightPixels / 2f
primed = true
}
- when (frame.optInt("wParam")) {
+ return when (frame.optInt("wParam")) {
WM_MOUSEMOVE -> {
val sens = sensitivityProvider()
val baseX = frame.optInt("x") / 65535f * m.widthPixels
@@ -45,24 +57,104 @@ 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_LBUTTONUP -> click(MotionEvent.BUTTON_PRIMARY)
- WM_RBUTTONUP -> click(MotionEvent.BUTTON_SECONDARY)
- WM_MBUTTONUP -> click(MotionEvent.BUTTON_TERTIARY)
+ 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 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) {
+ 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 click(button: Int) {
- 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()))
+ 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). */
+ fun scrollBy(dx: Float, dy: Float): Boolean {
+ 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)
+ 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
@@ -75,8 +167,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(
@@ -125,10 +218,18 @@ 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
+ // 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
new file mode 100644
index 0000000..1a26c8f
--- /dev/null
+++ b/android/app/src/main/java/com/inputflow/android/NotificationSyncBridge.kt
@@ -0,0 +1,236 @@
+package com.inputflow.android
+
+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
+
+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
+ )
+
+ // 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
+
+ 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
+
+ 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,
+ 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
+ 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) {
+ 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/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 e1aef36..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,8 +18,6 @@ class RelayForegroundService : Service() {
private val running = AtomicBoolean(false)
private var worker: Thread? = null
- @Volatile
- private var output: DataOutputStream? = null
@Volatile
private var remoteControlActive = false
private var deliveredMouseFrames = 0
@@ -37,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()
@@ -84,11 +83,7 @@ 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))
}
private fun startRelay() {
@@ -99,11 +94,75 @@ 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) {
+ // 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) {
+ accessibility?.handleMouse(frame)
+ if (!handledNative && accessibility == null) {
+ if (droppedMouseFrames < 5) {
+ Log.w(TAG, "mouse frame dropped: no injector active")
+ }
+ droppedMouseFrames += 1
+ } else {
+ deliveredMouseFrames += 1
+ }
+ } else if (!handledNative) {
+ // No native injector → accessibility handles the click.
+ accessibility?.handleMouse(frame)
+ }
+ }
+ "keyboard" -> {
+ val cb = keyCaptureCallback
+ if (cb != null) {
+ cb(frame.optInt("vkCode"), frame.optInt("flags"))
+ } else if (remoteControlActive) {
+ val accessibility = InputFlowAccessibilityService.instance
+ if (accessibility?.handleMappedKeyboard(frame) == true) {
+ // 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 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")
+ )
+ }
+ }
+
private fun relayLoop() {
val prefs = getSharedPreferences(PREFS, Context.MODE_PRIVATE)
while (running.get()) {
val host = prefs.getString(KEY_HOST, "").orEmpty()
- val secret = prefs.getString(KEY_SECRET, "").orEmpty()
+ val secret = PairingSecretStore.read(this)
val port = prefs.getInt(KEY_PORT, 15102)
if (host.isBlank() || secret.isBlank()) {
startRelayForeground("Missing pairing settings")
@@ -117,67 +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()) {
- 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
- }
+ 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()
+ ) {
+ val next = session.readFrame()
+ if (next.optString("type") == "mouse" &&
+ next.optInt("wParam") == WM_MOUSEMOVE
+ ) {
+ frame = next
+ } else {
+ dispatchFrame(frame, prefs)
+ frame = next
+ break
}
}
- "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)
- }
- "devices_info" -> handleDevicesInfo(frame)
+ 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…")
@@ -189,17 +233,18 @@ 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() {
- try {
- output?.let {
- RelayProtocol.writeFrame(it, JSONObject().put("type", "release"))
- }
- } catch (_: Exception) {}
+ writeFrame(JSONObject().put("type", "release"))
}
+
private fun setRemoteControlActive(active: Boolean) {
val changed = remoteControlActive != active
remoteControlActive = active
@@ -210,6 +255,9 @@ class RelayForegroundService : Service() {
if (active && accessibility == null) {
Log.w(TAG, "remote control requested but accessibility service is not active")
}
+ // 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()
@@ -257,11 +305,12 @@ 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"
- 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"
@@ -281,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 707b18a..93558b6 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")) {
@@ -146,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)
}
}
}
@@ -175,8 +190,39 @@ 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() {
+ saveSettings()
+ if (!notificationSyncSwitch.isChecked) {
+ Toast.makeText(this, R.string.test_notification_enable_sync, Toast.LENGTH_SHORT).show()
+ return
+ }
+
+ if (!RelayForegroundService.isConnectedForWrites()) {
+ Toast.makeText(this, R.string.test_notification_not_connected, Toast.LENGTH_SHORT).show()
+ return
+ }
+
+ val now = System.currentTimeMillis()
+ val sent = RelayForegroundService.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() {
@@ -207,6 +253,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/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/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
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 45dd989..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 @@
@@ -375,13 +374,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -447,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" />
@@ -469,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 044aabe..7a7ad44 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -1,7 +1,10 @@
+ Android device
+ Could not protect the pairing secret on this device.
InputFlow
InputFlow controlled peer
InputFlow keyboard
+ InputFlow notification sync
Connected
@@ -44,6 +47,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.
@@ -62,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 3d4e252..b32dcbd 100644
Binary files a/assets/hicolor/16x16/status/inputflow-tray-attention.png and b/assets/hicolor/16x16/status/inputflow-tray-attention.png differ
diff --git a/assets/hicolor/16x16/status/inputflow-tray-busy.png b/assets/hicolor/16x16/status/inputflow-tray-busy.png
index f4ae259..51d7d86 100644
Binary files a/assets/hicolor/16x16/status/inputflow-tray-busy.png and b/assets/hicolor/16x16/status/inputflow-tray-busy.png differ
diff --git a/assets/hicolor/16x16/status/inputflow-tray-offline.png b/assets/hicolor/16x16/status/inputflow-tray-offline.png
index 765f5bb..2c4aaa2 100644
Binary files a/assets/hicolor/16x16/status/inputflow-tray-offline.png and b/assets/hicolor/16x16/status/inputflow-tray-offline.png differ
diff --git a/assets/hicolor/16x16/status/inputflow-tray.png b/assets/hicolor/16x16/status/inputflow-tray.png
index 3cb1c87..a1d7fec 100644
Binary files a/assets/hicolor/16x16/status/inputflow-tray.png and b/assets/hicolor/16x16/status/inputflow-tray.png differ
diff --git a/assets/hicolor/22x22/status/inputflow-tray-attention.png b/assets/hicolor/22x22/status/inputflow-tray-attention.png
index a69ecca..2362dfc 100644
Binary files a/assets/hicolor/22x22/status/inputflow-tray-attention.png and b/assets/hicolor/22x22/status/inputflow-tray-attention.png differ
diff --git a/assets/hicolor/22x22/status/inputflow-tray-busy.png b/assets/hicolor/22x22/status/inputflow-tray-busy.png
index 243601d..55ca06e 100644
Binary files a/assets/hicolor/22x22/status/inputflow-tray-busy.png and b/assets/hicolor/22x22/status/inputflow-tray-busy.png differ
diff --git a/assets/hicolor/22x22/status/inputflow-tray-offline.png b/assets/hicolor/22x22/status/inputflow-tray-offline.png
index 2497e97..77adb13 100644
Binary files a/assets/hicolor/22x22/status/inputflow-tray-offline.png and b/assets/hicolor/22x22/status/inputflow-tray-offline.png differ
diff --git a/assets/hicolor/22x22/status/inputflow-tray.png b/assets/hicolor/22x22/status/inputflow-tray.png
index 670f900..890cda2 100644
Binary files a/assets/hicolor/22x22/status/inputflow-tray.png and b/assets/hicolor/22x22/status/inputflow-tray.png differ
diff --git a/assets/hicolor/24x24/status/inputflow-tray-attention.png b/assets/hicolor/24x24/status/inputflow-tray-attention.png
index 3d57946..d5a3d25 100644
Binary files a/assets/hicolor/24x24/status/inputflow-tray-attention.png and b/assets/hicolor/24x24/status/inputflow-tray-attention.png differ
diff --git a/assets/hicolor/24x24/status/inputflow-tray-busy.png b/assets/hicolor/24x24/status/inputflow-tray-busy.png
index 3436096..f93e043 100644
Binary files a/assets/hicolor/24x24/status/inputflow-tray-busy.png and b/assets/hicolor/24x24/status/inputflow-tray-busy.png differ
diff --git a/assets/hicolor/24x24/status/inputflow-tray-offline.png b/assets/hicolor/24x24/status/inputflow-tray-offline.png
index 6f27009..74677bd 100644
Binary files a/assets/hicolor/24x24/status/inputflow-tray-offline.png and b/assets/hicolor/24x24/status/inputflow-tray-offline.png differ
diff --git a/assets/hicolor/24x24/status/inputflow-tray.png b/assets/hicolor/24x24/status/inputflow-tray.png
index 7b7f44c..11f8547 100644
Binary files a/assets/hicolor/24x24/status/inputflow-tray.png and b/assets/hicolor/24x24/status/inputflow-tray.png differ
diff --git a/assets/hicolor/32x32/status/inputflow-tray-attention.png b/assets/hicolor/32x32/status/inputflow-tray-attention.png
index 1c021b9..245bf17 100644
Binary files a/assets/hicolor/32x32/status/inputflow-tray-attention.png and b/assets/hicolor/32x32/status/inputflow-tray-attention.png differ
diff --git a/assets/hicolor/32x32/status/inputflow-tray-busy.png b/assets/hicolor/32x32/status/inputflow-tray-busy.png
index eea9340..2083819 100644
Binary files a/assets/hicolor/32x32/status/inputflow-tray-busy.png and b/assets/hicolor/32x32/status/inputflow-tray-busy.png differ
diff --git a/assets/hicolor/32x32/status/inputflow-tray-offline.png b/assets/hicolor/32x32/status/inputflow-tray-offline.png
index 2ec44c8..ca2738b 100644
Binary files a/assets/hicolor/32x32/status/inputflow-tray-offline.png and b/assets/hicolor/32x32/status/inputflow-tray-offline.png differ
diff --git a/assets/hicolor/32x32/status/inputflow-tray.png b/assets/hicolor/32x32/status/inputflow-tray.png
index f67e68b..482f25f 100644
Binary files a/assets/hicolor/32x32/status/inputflow-tray.png and b/assets/hicolor/32x32/status/inputflow-tray.png differ
diff --git a/assets/hicolor/48x48/status/inputflow-tray-attention.png b/assets/hicolor/48x48/status/inputflow-tray-attention.png
index 44e1312..76ee897 100644
Binary files a/assets/hicolor/48x48/status/inputflow-tray-attention.png and b/assets/hicolor/48x48/status/inputflow-tray-attention.png differ
diff --git a/assets/hicolor/48x48/status/inputflow-tray-busy.png b/assets/hicolor/48x48/status/inputflow-tray-busy.png
index bbe638f..00f329c 100644
Binary files a/assets/hicolor/48x48/status/inputflow-tray-busy.png and b/assets/hicolor/48x48/status/inputflow-tray-busy.png differ
diff --git a/assets/hicolor/48x48/status/inputflow-tray-offline.png b/assets/hicolor/48x48/status/inputflow-tray-offline.png
index 23e247d..7749d52 100644
Binary files a/assets/hicolor/48x48/status/inputflow-tray-offline.png and b/assets/hicolor/48x48/status/inputflow-tray-offline.png differ
diff --git a/assets/hicolor/48x48/status/inputflow-tray.png b/assets/hicolor/48x48/status/inputflow-tray.png
index 5ad3398..8f23907 100644
Binary files a/assets/hicolor/48x48/status/inputflow-tray.png and b/assets/hicolor/48x48/status/inputflow-tray.png differ
diff --git a/assets/hicolor/scalable/apps/inputflow.svg b/assets/hicolor/scalable/apps/inputflow.svg
index 23cb403..62d726a 100644
--- a/assets/hicolor/scalable/apps/inputflow.svg
+++ b/assets/hicolor/scalable/apps/inputflow.svg
@@ -6,7 +6,7 @@
-
+
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-desktop.svg b/assets/icons/inputflow-desktop.svg
index 23cb403..62d726a 100644
--- a/assets/icons/inputflow-desktop.svg
+++ b/assets/icons/inputflow-desktop.svg
@@ -6,7 +6,7 @@
-
+
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 78fe34b..a71a1cc 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:
@@ -82,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 7154784..06c73fa 100644
--- a/src/AndroidRelay.cpp
+++ b/src/AndroidRelay.cpp
@@ -5,24 +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