diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml
new file mode 100644
index 000000000..5b9ce8819
--- /dev/null
+++ b/.github/workflows/build-android.yml
@@ -0,0 +1,90 @@
+name: Build Android Agent
+
+on:
+ push:
+ branches: [master]
+ paths:
+ - 'Desktop.Android/**'
+ - 'Desktop.Shared/**'
+ - 'Desktop.Native/**'
+ - 'Shared/**'
+ pull_request:
+ branches: [master]
+ paths:
+ - 'Desktop.Android/**'
+ - 'Desktop.Shared/**'
+ - 'Desktop.Native/**'
+ - 'Shared/**'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ build-android:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ submodules: recursive
+
+ - name: Install .NET SDK
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.0.x'
+
+ - name: Install Android workload
+ run: dotnet workload install android
+
+ - name: Set version
+ run: |
+ VERSION_STRING=$(git show -s --format=%ci)
+ CURRENT_VERSION=$(date -d "$VERSION_STRING" +"%Y.%m.%d.%H%M" 2>/dev/null || \
+ date -j -f "%Y-%m-%d %H:%M:%S %z" "$VERSION_STRING" +"%Y.%m.%d.%H%M" 2>/dev/null || \
+ echo "1.0.0.0")
+ echo "CurrentVersion=$CURRENT_VERSION" >> $GITHUB_ENV
+ echo "Setting version to $CURRENT_VERSION"
+
+ - name: Restore dependencies
+ run: dotnet restore Desktop.Android/Desktop.Android.csproj
+
+ - name: Build (Debug)
+ run: >
+ dotnet build Desktop.Android/Desktop.Android.csproj
+ --configuration Debug
+ --no-restore
+ /p:Version=${{ env.CurrentVersion }}
+
+ - name: Build Release APK (unsigned)
+ if: ${{ !secrets.ANDROID_KEY_ALIAS }}
+ run: >
+ dotnet publish Desktop.Android/Desktop.Android.csproj
+ --configuration Release
+ --no-restore
+ /p:Version=${{ env.CurrentVersion }}
+ /p:ApplicationVersion=${{ github.run_number }}
+ /p:AndroidCreatePackage=true
+ --output ./android-out/
+
+ - name: Build Release APK (signed)
+ if: ${{ secrets.ANDROID_KEY_ALIAS }}
+ run: >
+ dotnet publish Desktop.Android/Desktop.Android.csproj
+ --configuration Release
+ --no-restore
+ /p:Version=${{ env.CurrentVersion }}
+ /p:ApplicationVersion=${{ github.run_number }}
+ /p:AndroidSigningKeyAlias=${{ secrets.ANDROID_KEY_ALIAS }}
+ /p:AndroidSigningKeyPass=${{ secrets.ANDROID_KEY_PASS }}
+ /p:AndroidSigningStorePass=${{ secrets.ANDROID_STORE_PASS }}
+ /p:AndroidKeyStore=true
+ --output ./android-out/
+
+ - name: Upload APK artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: Remotely-Android-APK
+ path: ./android-out/*.apk
+ if-no-files-found: warn
diff --git a/ANDROID_AGENT_PLAN.md b/ANDROID_AGENT_PLAN.md
new file mode 100644
index 000000000..3ef0e283b
--- /dev/null
+++ b/ANDROID_AGENT_PLAN.md
@@ -0,0 +1,417 @@
+# Plan: Remote Desktop Agent for Android
+
+## Overview
+
+This document outlines the plan to add a native Android remote desktop agent to Remotely.
+The agent will allow Android devices to be remotely controlled by operators through the
+existing Remotely server, following the same architectural patterns used by the Windows
+(`Desktop.Win`) and Linux (`Desktop.Linux`) desktop clients.
+
+Two capabilities are in scope:
+
+| Capability | Description |
+|---|---|
+| **Unattended Agent** | A background service (foreground service on Android) that stays connected to the Remotely server, reports device info, accepts scripts (ADB shell), and initiates screen-cast sessions. |
+| **Remote Control (screen cast)** | Streams the device screen to the operator's browser and relays touch/keyboard input back to the device. |
+
+---
+
+## Architecture
+
+The existing cross-platform desktop layer is structured as follows:
+
+```
+Desktop.Shared – interfaces (IScreenCapturer, IKeyboardMouseInput, …)
+Desktop.Core – platform-agnostic logic (ScreenCaster, Viewer, CasterSocket, DtoMessageHandler)
+Desktop.Win – Windows-specific implementations
+Desktop.Linux – Linux-specific implementations
+```
+
+A new project **`Desktop.Android`** will be added alongside `Desktop.Win` and `Desktop.Linux`.
+It will implement all `Desktop.Shared` abstractions using Android APIs, while reusing all
+of `Desktop.Core` unchanged.
+
+### High-level component diagram
+
+```
+┌──────────────────────────────────────────────────────┐
+│ Remotely Server │
+│ AgentHub (SignalR) RemoteControlHub (SignalR) │
+└──────┬───────────────────────────┬───────────────────┘
+ │ SignalR │ SignalR
+ ▼ ▼
+┌─────────────────┐ ┌──────────────────────────┐
+│ Android Agent │ │ Desktop.Android Client │
+│ (foreground │──────▶ │ (screen cast session) │
+│ service) │ │ │
+└─────────────────┘ │ Desktop.Core (reused) │
+ │ Desktop.Android impls │
+ └──────────────────────────┘
+```
+
+---
+
+## New Project: `Desktop.Android`
+
+### Technology choice
+
+**.NET MAUI** (Multi-platform App UI) is the recommended implementation technology because:
+
+* It is the strategic .NET successor to Xamarin.Android.
+* It enables full code reuse of `Desktop.Core` and `Desktop.Shared` (both pure C# / .NET 8).
+* The agent background service can be implemented as a MAUI project that targets
+ `net8.0-android` without a visible UI (headless service mode).
+* A `ForegroundService` wraps the SignalR hub connection for unattended operation.
+
+### Project file location
+
+```
+Desktop.Android/
+ Desktop.Android.csproj (net8.0-android, MAUI)
+ Platforms/
+ Android/
+ AndroidManifest.xml
+ MainApplication.cs
+ MainActivity.cs
+ Services/
+ AndroidScreenCapturer.cs
+ AndroidKeyboardMouseInput.cs
+ AndroidAudioCapturer.cs
+ AndroidClipboardService.cs
+ AndroidSessionIndicator.cs
+ AndroidShutdownService.cs
+ AndroidCursorIconWatcher.cs (stub – no cursor on mobile)
+ AndroidRemoteControlAccessService.cs
+ AndroidFileTransferService.cs
+ AndroidChatUiService.cs
+ AndroidAppLauncher.cs
+ AndroidDeviceInfoGenerator.cs
+ AndroidUpdater.cs
+ Agent/
+ AndroidAgentForegroundService.cs
+ Startup/
+ MauiProgram.cs
+```
+
+---
+
+## Interface Implementations
+
+Each `Desktop.Shared` abstraction requires an Android-specific implementation.
+
+### `IScreenCapturer` → `AndroidScreenCapturer`
+
+Android 5.0+ (API 21) provides the **MediaProjection API** for screen capture without root.
+
+Key steps:
+1. Request `MediaProjection` via `MediaProjectionManager.createScreenCaptureIntent()` — this
+ shows a one-time system confirmation dialog to the user.
+2. Create a `VirtualDisplay` backed by an `ImageReader` (or `SurfaceTexture`).
+3. Acquire frames from `ImageReader.acquireLatestImage()` and convert to `SKBitmap`
+ via SkiaSharp (already a dependency in `Desktop.Core`).
+4. Implement `GetFrameDiffArea()` using the same pixel-diff logic as the existing desktop
+ implementations, or initially return the full screen bounds.
+5. Store the `MediaProjection` token in a `ForegroundService` so capture survives when
+ the app is in the background (required by Android 10+).
+
+Android permissions required:
+```xml
+
+
+```
+
+### `IKeyboardMouseInput` → `AndroidKeyboardMouseInput`
+
+Input injection on Android requires either:
+
+* **Accessibility Service** (recommended, no root required): Declare an
+ `AccessibilityService` in `AndroidManifest.xml`; use `GestureDescription` to inject
+ swipes, taps, and long-presses. Text input is handled via `AccessibilityNodeInfo` on
+ the focused field, falling back to `InputManager.injectInputEvent` on rooted devices.
+* **Root / ADB** (fallback): Use `Runtime.exec("input tap x y")` for rooted devices.
+
+The `DtoMessageHandler` in `Desktop.Core` already translates incoming mouse/keyboard DTOs
+into calls on `IKeyboardMouseInput`, so no changes to `Desktop.Core` are needed.
+
+Mouse-event mapping:
+
+| Desktop event | Android touch equivalent |
+|---|---|
+| Left click | Single tap |
+| Right click | Long press |
+| Click-and-drag | Long press + drag gesture |
+| Mouse wheel | Scroll gesture |
+| Key press | `AccessibilityNodeInfo.performAction(ACTION_SET_TEXT)` or virtual key injection |
+
+Android permissions required:
+```xml
+
+```
+
+### `IAudioCapturer` → `AndroidAudioCapturer`
+
+Use `AudioRecord` with `MediaRecorder.AudioSource.REMOTE_SUBMIX` (API 21 / Android 5.0+) to capture
+device audio output, then feed PCM samples to the existing audio pipeline in `Viewer.cs`.
+
+Android permissions required:
+```xml
+
+
+```
+
+Note: `CAPTURE_AUDIO_OUTPUT` is a privileged permission and may require the app to be
+installed as a system app or granted via ADB on non-rooted devices. This feature should be
+optional and gracefully disabled when the permission is unavailable.
+
+### `IClipboardService` → `AndroidClipboardService`
+
+Use `ClipboardManager` (`Context.CLIPBOARD_SERVICE`) to read and write plain-text clipboard
+data. Subscribe to `OnPrimaryClipChangedListener` for change notifications.
+
+Android permissions required:
+```xml
+
+```
+
+### `ICursorIconWatcher` → `AndroidCursorIconWatcher` (stub)
+
+Android has no hardware pointer cursor. This interface will be implemented as a no-op stub
+that never raises the `CurrentCursorChanged` event.
+
+### `ISessionIndicator` → `AndroidSessionIndicator`
+
+Display a persistent `Notification` in a foreground service channel to inform the user
+that a remote control session is active. The notification will include an action button
+to end the session immediately.
+
+Android permissions required:
+```xml
+
+```
+
+### `IShutdownService` → `AndroidShutdownService`
+
+Stops the foreground service and disconnects from the hub. On Android, a full device
+shutdown cannot be triggered by a third-party app.
+
+### `IRemoteControlAccessService` → `AndroidRemoteControlAccessService`
+
+Shows a confirmation dialog (via `AlertDialog`) to the device owner before allowing
+a remote control session, consistent with the `EnforceAttendedAccess` server setting.
+
+### `IFileTransferService` → `AndroidFileTransferService`
+
+Uses Android `DownloadManager` or direct file I/O in the app's external files directory
+(`Context.getExternalFilesDir()`).
+
+Android permissions required:
+```xml
+
+
+```
+
+---
+
+## Android Agent Service
+
+The agent component (equivalent to the existing `Agent` project on Windows/Linux) runs as
+an Android **Foreground Service** (`AndroidAgentForegroundService`) so that it can stay
+connected to the Remotely server even when the app is not in the foreground.
+
+### Capabilities
+
+| Capability | Implementation |
+|---|---|
+| Device info reporting | `AndroidDeviceInfoGenerator`: model, manufacturer, OS version, battery, IP, CPU utilization |
+| Remote scripting | `ADB shell` via `Runtime.exec()` (rooted or via USB debugging) or Termux API |
+| Screen cast | Delegates to `Desktop.Android` screen capture stack |
+| Chat | Reuses existing `ChatClientService` from `Desktop.Core` |
+| Wake-on-LAN | Not applicable for Android (no WoL emission) |
+| App launch | `Context.startActivity()` with explicit component intent |
+| Self-update | Download new APK from server update endpoint, invoke `PackageInstaller` |
+| Uninstall | `Intent(Intent.ACTION_UNINSTALL_PACKAGE)` |
+
+### SignalR hub connection
+
+The existing `AgentHubConnection` in the `Agent` project depends on PowerShell SDK
+(`Microsoft.PowerShell.SDK`) which is not available on Android. A new
+`AndroidAgentHubConnection` will be created in `Desktop.Android/Agent/` that:
+
+* Reuses `IAgentHubClient` interface from `Shared`.
+* Replaces PowerShell scripting with shell command execution via `Runtime.exec()`.
+* Otherwise inherits all SignalR connection lifecycle, heartbeat, and device reporting
+ logic.
+
+---
+
+## Server-Side Changes
+
+The Remotely server needs minimal changes because the Android agent uses the same
+`AgentHub` and `RemoteControlHub` SignalR endpoints.
+
+### Required additions
+
+1. **`Platform` enum value** — add `Android` to `Shared/Enums/Platform.cs` alongside
+ `Windows`, `Linux`, `MacOS`, and `Unknown`. The Android agent is functionally
+ equivalent to the existing desktop agent (unattended management + screen cast) and is
+ therefore classified as a platform variant, not a new device category. Using the
+ existing `Platform` enum keeps the model consistent and avoids proliferating device
+ classification enums.
+
+2. **Device icon in Server UI** — add a smartphone icon to the device grid when
+ `Platform == Android`.
+
+3. **Install/update package endpoint** — add an endpoint analogous to the existing
+ Windows/Linux installer download endpoints, serving the `.apk` file for Android
+ agent distribution.
+
+4. **Input-mapping awareness** (optional enhancement) — the viewer's remote control UI
+ already supports mobile-style tap/long-press mappings (documented in README). No code
+ change is required; the existing web viewer will work as-is.
+
+---
+
+## Dependency on Existing Projects
+
+```
+Desktop.Android.csproj
+ ├── ProjectReference → Desktop.Core.csproj (reused entirely)
+ ├── ProjectReference → Desktop.Shared.csproj (interfaces to implement)
+ └── ProjectReference → Shared.csproj (DTOs, models, enums)
+```
+
+No changes to `Desktop.Core` or `Desktop.Shared` are anticipated. If any abstraction
+needs a minor addition to support Android (e.g., a touch-specific input method), it will
+be added as an optional interface member with a default no-op implementation.
+
+---
+
+## Permissions Summary
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+---
+
+## Implementation Phases
+
+### Phase 1 – Unattended Agent (no screen cast)
+
+Goal: Android device appears in the Remotely device list and accepts scripted commands.
+
+- [ ] Create `Desktop.Android` MAUI project targeting `net8.0-android`.
+- [ ] Implement `AndroidAgentForegroundService` with SignalR connection to `AgentHub`.
+- [ ] Implement `AndroidDeviceInfoGenerator` (model, OS, battery, IP, CPU).
+- [ ] Implement basic shell-command execution via `Runtime.exec()`.
+- [ ] Add `DeviceType.Android` to `Shared/Enums/DeviceType.cs`.
+- [ ] Surface Android device icon in the Server device grid.
+- [ ] Add APK download endpoint to the Server.
+- [ ] Write unit tests for `AndroidDeviceInfoGenerator`.
+
+### Phase 2 – Screen Cast (attended)
+
+Goal: Operator can view the Android screen in real time via the browser.
+
+- [ ] Implement `AndroidScreenCapturer` using `MediaProjection` + `VirtualDisplay`.
+- [ ] Wire `AndroidScreenCapturer` into `Desktop.Core.ScreenCaster`.
+- [ ] Implement `AndroidSessionIndicator` (foreground notification).
+- [ ] Implement stub `AndroidCursorIconWatcher`.
+- [ ] Implement `AndroidShutdownService`.
+- [ ] Show user-consent dialog before starting screen cast.
+- [ ] Integration test: full screen-cast session from Android emulator to local server.
+
+### Phase 3 – Remote Input (full remote control)
+
+Goal: Operator can interact with the Android device (touch, text input).
+
+- [ ] Implement `AndroidKeyboardMouseInput` via `AccessibilityService` + `GestureDescription`.
+- [ ] Map incoming `MouseMoveDto`, `MouseButtonDto`, `KeyboardInputDto` from
+ `DtoMessageHandler` to Android gestures.
+- [ ] Implement `AndroidClipboardService`.
+- [ ] Implement `AndroidRemoteControlAccessService` (consent dialog).
+
+### Phase 4 – File Transfer and Audio
+
+Goal: Feature parity with Windows/Linux desktop clients.
+
+- [ ] Implement `AndroidFileTransferService`.
+- [ ] Implement `AndroidAudioCapturer` (gracefully disabled without privileged permission).
+- [ ] Implement `AndroidChatUiService`.
+- [ ] Implement `AndroidUpdater` (APK download + `PackageInstaller` session).
+
+---
+
+## Technical Challenges
+
+| Challenge | Mitigation |
+|---|---|
+| MediaProjection requires user consent on every boot | Persist grant token in encrypted preferences; re-prompt if token is invalid |
+| Background service killed by battery optimisation (Doze, App Standby) | Request exemption via `ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`; document for end users |
+| Accessibility service must be manually enabled by the user | Guide user through Settings > Accessibility on first launch |
+| Input injection restricted to root on many OEMs | Clearly document root-only features; provide graceful degradation |
+| PowerShell not available on Android | Replace with `Runtime.exec()` shell commands for scripting |
+| Screen recording blocked on some vendor ROMs | Document known incompatibilities; test on emulator and major OEM images |
+| APK distribution / auto-update outside Play Store | Serve APK directly from Remotely server; require "Install from unknown sources" |
+| `.NET MAUI` / Android toolchain adds build complexity | Add dedicated `build-android.yml` GitHub Actions workflow |
+| SkiaSharp `ImageReader` ↔ `SKBitmap` conversion performance | Benchmark on low-end hardware; consider hardware JPEG encoder via `MediaCodec` |
+
+---
+
+## Build & CI
+
+A new GitHub Actions workflow `build-android.yml` will be added:
+
+```yaml
+name: Build Android Agent
+on:
+ push:
+ branches: [master]
+ workflow_dispatch:
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.0.x'
+ - name: Install MAUI workload
+ run: dotnet workload install maui-android
+ - name: Build Desktop.Android
+ run: dotnet build Desktop.Android/Desktop.Android.csproj -c Release
+ - name: Run tests
+ run: dotnet test Tests/Desktop.Android.Tests/Desktop.Android.Tests.csproj
+```
+
+---
+
+## References
+
+* [Android MediaProjection API](https://developer.android.com/reference/android/media/projection/MediaProjection)
+* [Android AccessibilityService](https://developer.android.com/reference/android/accessibilityservice/AccessibilityService)
+* [GestureDescription (touch injection)](https://developer.android.com/reference/android/accessibilityservice/GestureDescription)
+* [.NET MAUI Android platform docs](https://learn.microsoft.com/en-us/dotnet/maui/android/)
+* [Foreground services on Android](https://developer.android.com/guide/components/foreground-services)
+* [SkiaSharp on MAUI](https://github.com/mono/SkiaSharp)
+* Existing Windows implementation: `Desktop.Win/`
+* Existing Linux implementation: `Desktop.Linux/`
+* Shared abstractions: `Desktop.Shared/Abstractions/`
+* Core screen-cast logic: `Desktop.Core/Services/ScreenCaster.cs`, `Viewer.cs`
diff --git a/Desktop.Android/Desktop.Android.csproj b/Desktop.Android/Desktop.Android.csproj
new file mode 100644
index 000000000..30de16143
--- /dev/null
+++ b/Desktop.Android/Desktop.Android.csproj
@@ -0,0 +1,41 @@
+
+
+
+ net8.0-android
+ Exe
+ Remotely.Desktop.Android
+ Remotely_Desktop_Android
+ Remotely
+ com.immense.remotely.desktop
+ 1
+ 1.0
+ Platforms\Android\AndroidManifest.xml
+
+ 26
+
+ 33
+ enable
+ enable
+ true
+ Android remote desktop agent for Remotely.
+ Copyright © 2024 Immense Networks
+ Jared Goodwin
+ Immense Networks
+ Remotely Desktop Android
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Desktop.Android/Platforms/Android/AndroidManifest.xml b/Desktop.Android/Platforms/Android/AndroidManifest.xml
new file mode 100644
index 000000000..3c069365b
--- /dev/null
+++ b/Desktop.Android/Platforms/Android/AndroidManifest.xml
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Desktop.Android/Platforms/Android/MainActivity.cs b/Desktop.Android/Platforms/Android/MainActivity.cs
new file mode 100644
index 000000000..62447d780
--- /dev/null
+++ b/Desktop.Android/Platforms/Android/MainActivity.cs
@@ -0,0 +1,92 @@
+using Android.App;
+using Android.Content;
+using Android.Content.PM;
+using Android.Media.Projection;
+using Android.OS;
+using Microsoft.Extensions.DependencyInjection;
+using Remotely.Desktop.Android.Services;
+using Remotely.Desktop.Shared.Abstractions;
+
+namespace Remotely.Desktop.Android;
+
+[Activity(
+ Label = "Remotely",
+ MainLauncher = true,
+ LaunchMode = LaunchMode.SingleTop,
+ ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize)]
+public class MainActivity : Activity
+{
+ // Result code for the MediaProjection permission request.
+ private const int MediaProjectionRequestCode = 100;
+
+ private IAndroidScreenCapturer? _screenCapturer;
+
+ ///
+ /// Gets the current active instance, or null if not started.
+ /// Used by dialog-based services (chat, access prompts).
+ ///
+ public static MainActivity? Current { get; private set; }
+
+ protected override void OnCreate(Bundle? savedInstanceState)
+ {
+ base.OnCreate(savedInstanceState);
+ Current = this;
+
+ _screenCapturer = MainApplication.Services?
+ .GetService();
+
+ // Start the agent foreground service.
+ var serviceIntent = new Intent(this, typeof(AgentForegroundService));
+ if (Build.VERSION.SdkInt >= BuildVersionCodes.O)
+ {
+ StartForegroundService(serviceIntent);
+ }
+ else
+ {
+ StartService(serviceIntent);
+ }
+
+ // Request screen capture permission if not already granted.
+ RequestMediaProjectionPermission();
+ }
+
+ protected override void OnDestroy()
+ {
+ base.OnDestroy();
+ if (Current == this)
+ {
+ Current = null;
+ }
+ }
+
+ protected override void OnActivityResult(int requestCode, Result resultCode, Intent? data)
+ {
+ base.OnActivityResult(requestCode, resultCode, data);
+
+ if (requestCode == MediaProjectionRequestCode)
+ {
+ if (resultCode == Result.Ok && data != null)
+ {
+ _screenCapturer?.OnMediaProjectionGranted((int)resultCode, data);
+ }
+ }
+ }
+
+ private void RequestMediaProjectionPermission()
+ {
+ if (_screenCapturer?.IsProjectionGranted == true)
+ {
+ return;
+ }
+
+ var mediaProjectionManager = (MediaProjectionManager?)
+ GetSystemService(MediaProjectionService);
+
+ if (mediaProjectionManager != null)
+ {
+ StartActivityForResult(
+ mediaProjectionManager.CreateScreenCaptureIntent(),
+ MediaProjectionRequestCode);
+ }
+ }
+}
diff --git a/Desktop.Android/Platforms/Android/MainApplication.cs b/Desktop.Android/Platforms/Android/MainApplication.cs
new file mode 100644
index 000000000..a7a920acd
--- /dev/null
+++ b/Desktop.Android/Platforms/Android/MainApplication.cs
@@ -0,0 +1,37 @@
+using Android.App;
+using Android.Runtime;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Remotely.Desktop.Android.Startup;
+using Remotely.Shared.Services;
+
+namespace Remotely.Desktop.Android;
+
+[Application]
+public class MainApplication : Application
+{
+ private IServiceProvider? _serviceProvider;
+
+ public MainApplication(IntPtr handle, JniHandleOwnership ownership)
+ : base(handle, ownership) { }
+
+ public static IServiceProvider? Services { get; private set; }
+
+ public override void OnCreate()
+ {
+ base.OnCreate();
+
+ var services = new ServiceCollection();
+
+ services.AddRemoteControlAndroid(this);
+
+ services.AddLogging(builder =>
+ {
+ builder.AddConsole();
+ builder.SetMinimumLevel(LogLevel.Debug);
+ });
+
+ _serviceProvider = services.BuildServiceProvider();
+ Services = _serviceProvider;
+ }
+}
diff --git a/Desktop.Android/Resources/values/strings.xml b/Desktop.Android/Resources/values/strings.xml
new file mode 100644
index 000000000..d86bee00d
--- /dev/null
+++ b/Desktop.Android/Resources/values/strings.xml
@@ -0,0 +1,5 @@
+
+
+ Remotely
+ Enables Remotely to inject touch and keyboard input for remote control sessions.
+
diff --git a/Desktop.Android/Resources/xml/accessibility_service_config.xml b/Desktop.Android/Resources/xml/accessibility_service_config.xml
new file mode 100644
index 000000000..215ee083d
--- /dev/null
+++ b/Desktop.Android/Resources/xml/accessibility_service_config.xml
@@ -0,0 +1,10 @@
+
+
diff --git a/Desktop.Android/Services/AgentForegroundService.cs b/Desktop.Android/Services/AgentForegroundService.cs
new file mode 100644
index 000000000..0742c2e4d
--- /dev/null
+++ b/Desktop.Android/Services/AgentForegroundService.cs
@@ -0,0 +1,147 @@
+using Android.App;
+using Android.Content;
+using Android.OS;
+using Android.Runtime;
+using Microsoft.AspNetCore.SignalR.Client;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Remotely.Desktop.Shared.Abstractions;
+using Remotely.Desktop.Shared.Services;
+using Remotely.Shared.Services;
+
+namespace Remotely.Desktop.Android.Services;
+
+///
+/// Android foreground service that keeps the Remotely agent alive while the app
+/// is in the background. It maintains the SignalR connection to the Remotely server,
+/// reports device info, and responds to remote commands.
+///
+[Service(
+ Exported = false,
+ ForegroundServiceType =
+ global::Android.Content.PM.ForegroundService.TypeMediaProjection |
+ global::Android.Content.PM.ForegroundService.TypeRemoteMessaging)]
+public class AgentForegroundService : Service
+{
+ private const string ChannelId = "remotely_agent";
+ private const int ForegroundNotificationId = 1000;
+
+ private IDesktopHubConnection? _hubConnection;
+ private ILogger? _logger;
+ private CancellationTokenSource _cts = new();
+
+ public override IBinder? OnBind(Intent? intent) => null;
+
+ public override void OnCreate()
+ {
+ base.OnCreate();
+
+ _hubConnection = MainApplication.Services?.GetService();
+ _logger = MainApplication.Services?.GetService>();
+
+ CreateNotificationChannel();
+ StartForeground(ForegroundNotificationId, BuildNotification());
+ }
+
+ [return: GeneratedEnum]
+ public override StartCommandResult OnStartCommand(Intent? intent, [GeneratedEnum] StartCommandFlags flags, int startId)
+ {
+ _cts = new CancellationTokenSource();
+ _ = Task.Run(() => ConnectAndRunAsync(_cts.Token));
+ return StartCommandResult.Sticky;
+ }
+
+ public override void OnDestroy()
+ {
+ _cts.Cancel();
+ base.OnDestroy();
+ }
+
+ private async Task ConnectAndRunAsync(CancellationToken cancelToken)
+ {
+ try
+ {
+ if (_hubConnection is null)
+ {
+ _logger?.LogError("Hub connection service not available.");
+ return;
+ }
+
+ _logger?.LogInformation("Connecting to Remotely server...");
+
+ var connected = await _hubConnection.Connect(
+ TimeSpan.FromSeconds(30),
+ cancelToken);
+
+ if (!connected)
+ {
+ _logger?.LogWarning("Failed to connect to server. Retrying in 30 seconds.");
+ await Task.Delay(TimeSpan.FromSeconds(30), cancelToken);
+ await ConnectAndRunAsync(cancelToken);
+ return;
+ }
+
+ _logger?.LogInformation("Connected to Remotely server.");
+
+ // Keep the connection alive until cancellation.
+ while (!cancelToken.IsCancellationRequested
+ && _hubConnection.IsConnected)
+ {
+ await Task.Delay(TimeSpan.FromSeconds(10), cancelToken);
+ }
+ }
+ catch (System.OperationCanceledException)
+ {
+ _logger?.LogInformation("Agent foreground service cancelled.");
+ }
+ catch (Exception ex)
+ {
+ _logger?.LogError(ex, "Error in agent foreground service.");
+ }
+ }
+
+ private void CreateNotificationChannel()
+ {
+ if (Build.VERSION.SdkInt < BuildVersionCodes.O)
+ {
+ return;
+ }
+
+ var notificationManager = (NotificationManager?)
+ GetSystemService(NotificationService);
+
+ if (notificationManager?.GetNotificationChannel(ChannelId) != null)
+ {
+ return;
+ }
+
+ var channel = new NotificationChannel(
+ ChannelId,
+ "Remotely Agent",
+ NotificationImportance.Low)
+ {
+ Description = "Keeps the Remotely agent connected in the background."
+ };
+
+ notificationManager?.CreateNotificationChannel(channel);
+ }
+
+ private Notification BuildNotification()
+ {
+ var intent = new Intent(this, typeof(MainActivity));
+ intent.SetFlags(ActivityFlags.SingleTop);
+ var pendingIntent = PendingIntent.GetActivity(
+ this,
+ 0,
+ intent,
+ PendingIntentFlags.Immutable);
+
+ return new Notification.Builder(this, ChannelId)
+ .SetContentTitle("Remotely Agent")
+ .SetContentText("Running in background.")
+ .SetSmallIcon(global::Android.Resource.Drawable.IcMenuMyCalendar)
+ .SetContentIntent(pendingIntent)
+ .SetOngoing(true)
+ .Build()!;
+ }
+}
diff --git a/Desktop.Android/Services/AndroidAppStartup.cs b/Desktop.Android/Services/AndroidAppStartup.cs
new file mode 100644
index 000000000..550e9fdd4
--- /dev/null
+++ b/Desktop.Android/Services/AndroidAppStartup.cs
@@ -0,0 +1,123 @@
+using Android.Content;
+using Microsoft.Extensions.Logging;
+using Remotely.Desktop.Shared.Abstractions;
+using Remotely.Desktop.Shared.Enums;
+using Remotely.Desktop.Shared.Services;
+
+namespace Remotely.Desktop.Android.Services;
+
+///
+/// Coordinates the Android remote control client startup sequence.
+///
+internal class AndroidAppStartup : IAppStartup
+{
+ private readonly IAppState _appState;
+ private readonly IKeyboardMouseInput _inputService;
+ private readonly IDesktopHubConnection _desktopHub;
+ private readonly IClipboardService _clipboardService;
+ private readonly IChatHostService _chatHostService;
+ private readonly ICursorIconWatcher _cursorIconWatcher;
+ private readonly IIdleTimer _idleTimer;
+ private readonly IShutdownService _shutdownService;
+ private readonly IBrandingProvider _brandingProvider;
+ private readonly ILogger _logger;
+
+ public AndroidAppStartup(
+ IAppState appState,
+ IKeyboardMouseInput inputService,
+ IDesktopHubConnection desktopHub,
+ IClipboardService clipboardService,
+ IChatHostService chatHostService,
+ ICursorIconWatcher cursorIconWatcher,
+ IIdleTimer idleTimer,
+ IShutdownService shutdownService,
+ IBrandingProvider brandingProvider,
+ ILogger logger)
+ {
+ _appState = appState;
+ _inputService = inputService;
+ _desktopHub = desktopHub;
+ _clipboardService = clipboardService;
+ _chatHostService = chatHostService;
+ _cursorIconWatcher = cursorIconWatcher;
+ _idleTimer = idleTimer;
+ _shutdownService = shutdownService;
+ _brandingProvider = brandingProvider;
+ _logger = logger;
+ }
+
+ public async Task Run()
+ {
+ await _brandingProvider.Initialize();
+
+ if (_appState.Mode is AppMode.Unattended or AppMode.Attended)
+ {
+ _clipboardService.BeginWatching();
+ _inputService.Init();
+ // Android has no hardware cursor; no event subscription needed.
+ }
+
+ switch (_appState.Mode)
+ {
+ case AppMode.Unattended:
+ await StartScreenCasting();
+ break;
+
+ case AppMode.Attended:
+ // Attended mode for Android: wait for incoming session requests
+ // via SignalR without broadcasting a session ID.
+ await StartScreenCasting();
+ break;
+
+ case AppMode.Chat:
+ await _chatHostService
+ .StartChat(_appState.PipeName, _appState.OrganizationName)
+ .ConfigureAwait(false);
+ break;
+
+ default:
+ break;
+ }
+ }
+
+ private async Task StartScreenCasting()
+ {
+ if (!await _desktopHub.Connect(TimeSpan.FromSeconds(30), CancellationToken.None))
+ {
+ _logger.LogError("Failed to connect to Remotely server.");
+ await _shutdownService.Shutdown();
+ return;
+ }
+
+ var result = await _desktopHub.SendUnattendedSessionInfo(
+ _appState.SessionId,
+ _appState.AccessKey,
+ global::Android.OS.Build.Model ?? "Android Device",
+ _appState.RequesterName,
+ _appState.OrganizationName);
+
+ if (!result.IsSuccess)
+ {
+ _logger.LogError(result.Exception, "Failed to send session info to server.");
+ await _shutdownService.Shutdown();
+ return;
+ }
+
+ try
+ {
+ if (_appState.ArgDict.ContainsKey("relaunch"))
+ {
+ _logger.LogInformation("Resuming after relaunch.");
+ await _desktopHub.NotifyViewersRelaunchedScreenCasterReady(_appState.RelaunchViewers);
+ }
+ else
+ {
+ await _desktopHub.NotifyRequesterUnattendedReady();
+ }
+ }
+ finally
+ {
+ _idleTimer.Start();
+ }
+ }
+}
diff --git a/Desktop.Android/Services/AndroidAudioCapturer.cs b/Desktop.Android/Services/AndroidAudioCapturer.cs
new file mode 100644
index 000000000..9586b0e61
--- /dev/null
+++ b/Desktop.Android/Services/AndroidAudioCapturer.cs
@@ -0,0 +1,32 @@
+using Microsoft.Extensions.Logging;
+using Remotely.Desktop.Shared.Abstractions;
+
+namespace Remotely.Desktop.Android.Services;
+
+///
+/// Audio capturer stub for Android.
+/// Full audio capture requires the CAPTURE_AUDIO_OUTPUT privileged permission,
+/// which is only available to system apps or via ADB grant.
+/// This stub is a safe no-op; a future iteration can enable capture when the
+/// permission has been granted.
+///
+public class AndroidAudioCapturer : IAudioCapturer
+{
+ private readonly ILogger _logger;
+
+#pragma warning disable CS0067
+ public event EventHandler? AudioSampleReady;
+#pragma warning restore CS0067
+
+ public AndroidAudioCapturer(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ public void ToggleAudio(bool toggleOn)
+ {
+ _logger.LogInformation(
+ "Audio capture requested (toggleOn={toggleOn}), but it is not enabled without CAPTURE_AUDIO_OUTPUT permission.",
+ toggleOn);
+ }
+}
diff --git a/Desktop.Android/Services/AndroidChatUiService.cs b/Desktop.Android/Services/AndroidChatUiService.cs
new file mode 100644
index 000000000..5b36323b3
--- /dev/null
+++ b/Desktop.Android/Services/AndroidChatUiService.cs
@@ -0,0 +1,81 @@
+using Android.App;
+using Android.Content;
+using Microsoft.Extensions.Logging;
+using Remotely.Desktop.Shared.Abstractions;
+using Remotely.Shared.Models;
+
+namespace Remotely.Desktop.Android.Services;
+
+///
+/// Provides a simple chat UI on Android by showing an alert dialog for incoming
+/// messages and posting outgoing messages directly to the writer stream.
+/// A future iteration can implement a richer notification-based chat UI.
+///
+public class AndroidChatUiService : IChatUiService
+{
+ private readonly Context _context;
+ private readonly ILogger _logger;
+
+ private StreamWriter? _writer;
+ private bool _windowOpen;
+
+ public event EventHandler? ChatWindowClosed;
+
+ public AndroidChatUiService(
+ Context context,
+ ILogger logger)
+ {
+ _context = context;
+ _logger = logger;
+ }
+
+ public async Task ReceiveChat(ChatMessage chatMessage)
+ {
+ try
+ {
+ var activity = MainActivity.Current;
+ if (activity is null)
+ {
+ _logger.LogWarning("No active Activity. Cannot display chat message.");
+ return;
+ }
+
+ activity.RunOnUiThread(() =>
+ {
+ new AlertDialog.Builder(activity)
+ .SetTitle($"Message from {chatMessage.SenderName}")!
+ .SetMessage(chatMessage.Message)!
+ .SetPositiveButton("OK", (IDialogInterfaceOnClickListener?)null)!
+ .Show();
+ });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error while receiving chat message.");
+ }
+ await Task.CompletedTask;
+ }
+
+ public void ShowChatWindow(string organizationName, StreamWriter writer)
+ {
+ _writer = writer;
+ _windowOpen = true;
+
+ var activity = MainActivity.Current;
+ if (activity is null)
+ {
+ _logger.LogWarning("No active Activity. Cannot show chat window.");
+ return;
+ }
+
+ // On Android, "show chat window" means a minimal prompt dialog for the device owner.
+ activity.RunOnUiThread(() =>
+ {
+ new AlertDialog.Builder(activity)
+ .SetTitle($"Remote Chat – {organizationName}")!
+ .SetMessage("A remote operator has started a chat session.")!
+ .SetPositiveButton("OK", (IDialogInterfaceOnClickListener?)null)!
+ .Show();
+ });
+ }
+}
diff --git a/Desktop.Android/Services/AndroidClipboardService.cs b/Desktop.Android/Services/AndroidClipboardService.cs
new file mode 100644
index 000000000..7540728c4
--- /dev/null
+++ b/Desktop.Android/Services/AndroidClipboardService.cs
@@ -0,0 +1,86 @@
+using Android.Content;
+using Microsoft.Extensions.Logging;
+using Remotely.Desktop.Shared.Abstractions;
+
+namespace Remotely.Desktop.Android.Services;
+
+///
+/// Provides clipboard read/write access on Android using .
+/// Change notifications are polled on a background task because Android's
+/// is not accessible from
+/// a background service without a looper.
+///
+public class AndroidClipboardService : IClipboardService
+{
+ private readonly Context _context;
+ private readonly ILogger _logger;
+ private ClipboardManager? _clipboardManager;
+ private string _lastClipboardText = string.Empty;
+ private Task? _watcherTask;
+ private CancellationTokenSource _cts = new();
+
+ public event EventHandler? ClipboardTextChanged;
+
+ public AndroidClipboardService(
+ Context context,
+ ILogger logger)
+ {
+ _context = context;
+ _logger = logger;
+ _clipboardManager = (ClipboardManager?)_context.GetSystemService(Context.ClipboardService);
+ }
+
+ public void BeginWatching()
+ {
+ if (_watcherTask is { Status: System.Threading.Tasks.TaskStatus.Running })
+ {
+ return;
+ }
+
+ _cts = new CancellationTokenSource();
+ _watcherTask = Task.Run(() => WatchClipboard(_cts.Token), _cts.Token);
+ }
+
+ public async Task SetText(string clipboardText)
+ {
+ try
+ {
+ var clip = ClipData.NewPlainText("Remotely", clipboardText);
+ if (clip != null && _clipboardManager != null)
+ {
+ _clipboardManager.PrimaryClip = clip;
+ }
+ _lastClipboardText = clipboardText;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error while setting clipboard text.");
+ }
+ await Task.CompletedTask;
+ }
+
+ private async Task WatchClipboard(CancellationToken cancelToken)
+ {
+ while (!cancelToken.IsCancellationRequested)
+ {
+ try
+ {
+ if (_clipboardManager?.HasPrimaryClip == true)
+ {
+ var item = _clipboardManager.PrimaryClip?.GetItemAt(0);
+ var text = item?.Text;
+ if (!string.IsNullOrEmpty(text) && text != _lastClipboardText)
+ {
+ _lastClipboardText = text;
+ ClipboardTextChanged?.Invoke(this, text);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error while watching clipboard.");
+ }
+ await Task.Delay(500, cancelToken);
+ }
+ }
+}
diff --git a/Desktop.Android/Services/AndroidCursorIconWatcher.cs b/Desktop.Android/Services/AndroidCursorIconWatcher.cs
new file mode 100644
index 000000000..aa45ae64e
--- /dev/null
+++ b/Desktop.Android/Services/AndroidCursorIconWatcher.cs
@@ -0,0 +1,21 @@
+using Remotely.Desktop.Shared.Abstractions;
+using Remotely.Shared.Models;
+using System.Drawing;
+
+namespace Remotely.Desktop.Android.Services;
+
+///
+/// Cursor icon watcher stub for Android.
+/// Android has no hardware pointer cursor, so this is a permanent no-op.
+///
+public class AndroidCursorIconWatcher : ICursorIconWatcher
+{
+#pragma warning disable CS0067
+ // Obsolete as noted in the interface; never raised on Android.
+ [Obsolete("This should be replaced with a message published by IMessenger.")]
+ public event EventHandler? OnChange;
+#pragma warning restore CS0067
+
+ public CursorInfo GetCurrentCursor() =>
+ new(Array.Empty(), Point.Empty, "default");
+}
diff --git a/Desktop.Android/Services/AndroidFileTransferService.cs b/Desktop.Android/Services/AndroidFileTransferService.cs
new file mode 100644
index 000000000..51ac7e546
--- /dev/null
+++ b/Desktop.Android/Services/AndroidFileTransferService.cs
@@ -0,0 +1,120 @@
+using Android.Content;
+using Microsoft.Extensions.Logging;
+using Remotely.Desktop.Shared.Abstractions;
+using Remotely.Desktop.Shared.Services;
+using Remotely.Desktop.Shared.ViewModels;
+
+namespace Remotely.Desktop.Android.Services;
+
+///
+/// Handles file transfer to/from the Android device's external files directory.
+/// Received files are saved under Android/data/com.immense.remotely.desktop/files/Remotely/.
+///
+public class AndroidFileTransferService : IFileTransferService
+{
+ private static readonly SemaphoreSlim _writeLock = new(1, 1);
+ private static readonly Dictionary _partialTransfers = new();
+
+ private readonly Context _context;
+ private readonly ILogger _logger;
+
+ public AndroidFileTransferService(
+ Context context,
+ ILogger logger)
+ {
+ _context = context;
+ _logger = logger;
+ }
+
+ public string GetBaseDirectory()
+ {
+ var dir = _context.GetExternalFilesDir("Remotely")?.AbsolutePath
+ ?? Path.Combine(Path.GetTempPath(), "Remotely");
+
+ Directory.CreateDirectory(dir);
+ return dir;
+ }
+
+ public void OpenFileTransferWindow(IViewer viewer)
+ {
+ // Android does not have a windowed file transfer UI in this release.
+ // A future iteration can open a notification or activity.
+ _logger.LogInformation("File transfer window requested (not implemented for Android).");
+ }
+
+ public async Task ReceiveFile(
+ byte[] buffer,
+ string fileName,
+ string messageId,
+ bool endOfFile,
+ bool startOfFile)
+ {
+ try
+ {
+ await _writeLock.WaitAsync();
+
+ var baseDir = GetBaseDirectory();
+
+ if (startOfFile)
+ {
+ var filePath = Path.Combine(baseDir, fileName);
+
+ if (File.Exists(filePath))
+ {
+ var count = 0;
+ var ext = Path.GetExtension(fileName);
+ var fileWithoutExt = Path.GetFileNameWithoutExtension(fileName);
+ while (File.Exists(filePath))
+ {
+ filePath = Path.Combine(baseDir, $"{fileWithoutExt}-{count}{ext}");
+ count++;
+ }
+ }
+
+ File.Create(filePath).Close();
+ var fs = new FileStream(filePath, FileMode.OpenOrCreate);
+ _partialTransfers[messageId] = fs;
+ }
+
+ if (_partialTransfers.TryGetValue(messageId, out var fileStream))
+ {
+ if (buffer?.Length > 0)
+ {
+ await fileStream.WriteAsync(buffer);
+ }
+
+ if (endOfFile)
+ {
+ var filePath = fileStream.Name;
+ fileStream.Close();
+ _partialTransfers.Remove(messageId);
+ _logger.LogInformation("File transfer complete. Saved to: {Path}", filePath);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error while receiving file.");
+ }
+ finally
+ {
+ _writeLock.Release();
+ }
+ }
+
+ public async Task UploadFile(
+ FileUpload fileUpload,
+ IViewer viewer,
+ Action progressUpdateCallback,
+ CancellationToken cancelToken)
+ {
+ try
+ {
+ await viewer.SendFile(fileUpload, progressUpdateCallback, cancelToken);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error while uploading file.");
+ }
+ }
+}
diff --git a/Desktop.Android/Services/AndroidKeyboardMouseInput.cs b/Desktop.Android/Services/AndroidKeyboardMouseInput.cs
new file mode 100644
index 000000000..3083ed4fe
--- /dev/null
+++ b/Desktop.Android/Services/AndroidKeyboardMouseInput.cs
@@ -0,0 +1,284 @@
+using Android.AccessibilityServices;
+using Android.Content;
+using Android.Graphics;
+using Android.OS;
+using Android.Views;
+using Android.Views.Accessibility;
+using Microsoft.Extensions.Logging;
+using Remotely.Desktop.Shared.Abstractions;
+using Remotely.Desktop.Shared.Enums;
+using Remotely.Desktop.Shared.Services;
+
+namespace Remotely.Desktop.Android.Services;
+
+///
+/// Injects touch and keyboard input on Android using the AccessibilityService API.
+/// The user must enable "Remotely Input Control" in Android Settings → Accessibility
+/// before this service can inject gestures.
+///
+public class AndroidKeyboardMouseInput : IKeyboardMouseInput
+{
+ private readonly ILogger _logger;
+
+ public AndroidKeyboardMouseInput(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ public void Init()
+ {
+ // Nothing to initialise here. Gesture injection is performed directly
+ // through RemotelyAccessibilityService.Instance when available.
+ }
+
+ public void SendKeyDown(string key)
+ {
+ try
+ {
+ var keyCode = ConvertJavaScriptKeyToKeyCode(key);
+ if (keyCode == Keycode.Unknown)
+ {
+ _logger.LogWarning("Unmapped key: {Key}", key);
+ return;
+ }
+
+ var service = RemotelyAccessibilityService.Instance;
+ if (service is null)
+ {
+ _logger.LogWarning("AccessibilityService not available. Ensure the service is enabled in Settings.");
+ return;
+ }
+
+ service.InjectKeyEvent(new KeyEvent(KeyEventActions.Down, keyCode));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error while sending key down: {Key}", key);
+ }
+ }
+
+ public void SendKeyUp(string key)
+ {
+ try
+ {
+ var keyCode = ConvertJavaScriptKeyToKeyCode(key);
+ if (keyCode == Keycode.Unknown)
+ {
+ _logger.LogWarning("Unmapped key: {Key}", key);
+ return;
+ }
+
+ var service = RemotelyAccessibilityService.Instance;
+ if (service is null)
+ {
+ _logger.LogWarning("AccessibilityService not available.");
+ return;
+ }
+
+ service.InjectKeyEvent(new KeyEvent(KeyEventActions.Up, keyCode));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error while sending key up: {Key}", key);
+ }
+ }
+
+ public void SendMouseButtonAction(
+ int button,
+ ButtonAction buttonAction,
+ double percentX,
+ double percentY,
+ IViewer viewer)
+ {
+ try
+ {
+ var service = RemotelyAccessibilityService.Instance;
+ if (service is null)
+ {
+ _logger.LogWarning("AccessibilityService not available.");
+ return;
+ }
+
+ var bounds = viewer.Capturer.CurrentScreenBounds;
+ var x = (float)(bounds.Width * percentX);
+ var y = (float)(bounds.Height * percentY);
+
+ switch (button)
+ {
+ case 0: // Left (primary)
+ if (buttonAction == ButtonAction.Down)
+ {
+ service.InjectTapDown(x, y);
+ }
+ else
+ {
+ service.InjectTapUp(x, y);
+ }
+ break;
+
+ case 2: // Right – simulate as long-press on Android
+ if (buttonAction == ButtonAction.Down)
+ {
+ service.InjectLongPress(x, y);
+ }
+ break;
+
+ default:
+ break;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error while sending mouse button action.");
+ }
+ }
+
+ public void SendMouseMove(double percentX, double percentY, IViewer viewer)
+ {
+ // Android touch screens do not support hover/mouse-move semantics in the same way
+ // as a desktop pointer. A single-pixel zero-length swipe is a no-op on most devices.
+ // The visual cursor position on the remote viewer is updated via the screen capture
+ // stream; no gesture injection is required for plain hover moves.
+ }
+
+ public void SendMouseWheel(int deltaY)
+ {
+ try
+ {
+ var service = RemotelyAccessibilityService.Instance;
+ if (service is null)
+ {
+ return;
+ }
+
+ // Simulate a vertical swipe to represent scrolling.
+ var centerX = service.ScreenWidth / 2f;
+ var centerY = service.ScreenHeight / 2f;
+ var distance = deltaY > 0 ? -200f : 200f;
+ service.InjectSwipe(centerX, centerY, centerX, centerY + distance, durationMs: 200);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error while sending mouse wheel.");
+ }
+ }
+
+ public void SendText(string transferText)
+ {
+ try
+ {
+ var service = RemotelyAccessibilityService.Instance;
+ if (service is null)
+ {
+ _logger.LogWarning("AccessibilityService not available for text input.");
+ return;
+ }
+
+ service.InjectText(transferText);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error while sending text.");
+ }
+ }
+
+ public void SetKeyStatesUp()
+ {
+ // Not applicable on Android.
+ }
+
+ public void ToggleBlockInput(bool toggleOn)
+ {
+ // Blocking input programmatically is not supported without root on Android.
+ _logger.LogWarning("ToggleBlockInput is not supported on Android without root.");
+ }
+
+ private static Keycode ConvertJavaScriptKeyToKeyCode(string key)
+ {
+ return key switch
+ {
+ " " => Keycode.Space,
+ "ArrowDown" or "Down" => Keycode.DpadDown,
+ "ArrowUp" or "Up" => Keycode.DpadUp,
+ "ArrowLeft" or "Left" => Keycode.DpadLeft,
+ "ArrowRight" or "Right" => Keycode.DpadRight,
+ "Enter" => Keycode.Enter,
+ "Esc" or "Escape" => Keycode.Escape,
+ "Backspace" => Keycode.Del,
+ "Tab" => Keycode.Tab,
+ "Delete" => Keycode.ForwardDel,
+ "Home" => Keycode.Home,
+ "End" => Keycode.MoveEnd,
+ "PageUp" => Keycode.PageUp,
+ "PageDown" => Keycode.PageDown,
+ "Control" => Keycode.CtrlLeft,
+ "Shift" => Keycode.ShiftLeft,
+ "Alt" => Keycode.AltLeft,
+ "Meta" => Keycode.MetaLeft,
+ "CapsLock" => Keycode.CapsLock,
+ "F1" => Keycode.F1,
+ "F2" => Keycode.F2,
+ "F3" => Keycode.F3,
+ "F4" => Keycode.F4,
+ "F5" => Keycode.F5,
+ "F6" => Keycode.F6,
+ "F7" => Keycode.F7,
+ "F8" => Keycode.F8,
+ "F9" => Keycode.F9,
+ "F10" => Keycode.F10,
+ "F11" => Keycode.F11,
+ "F12" => Keycode.F12,
+ _ when key.Length == 1 => MapCharToKeycode(key[0]),
+ _ => Keycode.Unknown
+ };
+ }
+
+ private static Keycode MapCharToKeycode(char c)
+ {
+ return c switch
+ {
+ 'a' or 'A' => Keycode.A,
+ 'b' or 'B' => Keycode.B,
+ 'c' or 'C' => Keycode.C,
+ 'd' or 'D' => Keycode.D,
+ 'e' or 'E' => Keycode.E,
+ 'f' or 'F' => Keycode.F,
+ 'g' or 'G' => Keycode.G,
+ 'h' or 'H' => Keycode.H,
+ 'i' or 'I' => Keycode.I,
+ 'j' or 'J' => Keycode.J,
+ 'k' or 'K' => Keycode.K,
+ 'l' or 'L' => Keycode.L,
+ 'm' or 'M' => Keycode.M,
+ 'n' or 'N' => Keycode.N,
+ 'o' or 'O' => Keycode.O,
+ 'p' or 'P' => Keycode.P,
+ 'q' or 'Q' => Keycode.Q,
+ 'r' or 'R' => Keycode.R,
+ 's' or 'S' => Keycode.S,
+ 't' or 'T' => Keycode.T,
+ 'u' or 'U' => Keycode.U,
+ 'v' or 'V' => Keycode.V,
+ 'w' or 'W' => Keycode.W,
+ 'x' or 'X' => Keycode.X,
+ 'y' or 'Y' => Keycode.Y,
+ 'z' or 'Z' => Keycode.Z,
+ '0' => Keycode.Num0,
+ '1' => Keycode.Num1,
+ '2' => Keycode.Num2,
+ '3' => Keycode.Num3,
+ '4' => Keycode.Num4,
+ '5' => Keycode.Num5,
+ '6' => Keycode.Num6,
+ '7' => Keycode.Num7,
+ '8' => Keycode.Num8,
+ '9' => Keycode.Num9,
+ '.' => Keycode.Period,
+ ',' => Keycode.Comma,
+ '-' => Keycode.Minus,
+ '+' => Keycode.Plus,
+ '=' => Keycode.Equals,
+ _ => Keycode.Unknown
+ };
+ }
+}
diff --git a/Desktop.Android/Services/AndroidRemoteControlAccessService.cs b/Desktop.Android/Services/AndroidRemoteControlAccessService.cs
new file mode 100644
index 000000000..96b3e7987
--- /dev/null
+++ b/Desktop.Android/Services/AndroidRemoteControlAccessService.cs
@@ -0,0 +1,94 @@
+using Android.App;
+using Android.Content;
+using Android.OS;
+using Microsoft.Extensions.Logging;
+using Remotely.Desktop.Shared.Abstractions;
+using Remotely.Shared.Enums;
+
+namespace Remotely.Desktop.Android.Services;
+
+///
+/// Prompts the device owner for consent before allowing a remote control session,
+/// consistent with the server's EnforceAttendedAccess setting.
+///
+public class AndroidRemoteControlAccessService : IRemoteControlAccessService
+{
+ private readonly Context _context;
+ private readonly ILogger _logger;
+ private volatile bool _isPromptOpen;
+
+ public AndroidRemoteControlAccessService(
+ Context context,
+ ILogger logger)
+ {
+ _context = context;
+ _logger = logger;
+ }
+
+ public bool IsPromptOpen => _isPromptOpen;
+
+ public async Task PromptForAccess(
+ string requesterName,
+ string organizationName)
+ {
+ if (_isPromptOpen)
+ {
+ return PromptForAccessResult.Denied;
+ }
+
+ _isPromptOpen = true;
+ var tcs = new TaskCompletionSource();
+
+ try
+ {
+ var activity = MainActivity.Current;
+ if (activity is null)
+ {
+ _logger.LogWarning("No active Activity found. Auto-accepting remote control request.");
+ return PromptForAccessResult.Accepted;
+ }
+
+ activity.RunOnUiThread(() =>
+ {
+ try
+ {
+ var dialog = new AlertDialog.Builder(activity)
+ .SetTitle("Remote Access Request")!
+ .SetMessage($"{requesterName} ({organizationName}) is requesting remote access to your device.")!
+ .SetPositiveButton("Allow", (s, e) =>
+ {
+ tcs.TrySetResult(PromptForAccessResult.Accepted);
+ })!
+ .SetNegativeButton("Deny", (s, e) =>
+ {
+ tcs.TrySetResult(PromptForAccessResult.Denied);
+ })!
+ .SetCancelable(false)!
+ .Create();
+
+ dialog?.Show();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error while showing remote access dialog.");
+ tcs.TrySetResult(PromptForAccessResult.Error);
+ }
+ });
+
+ // Time out after 30 seconds.
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
+ cts.Token.Register(() => tcs.TrySetResult(PromptForAccessResult.TimedOut));
+
+ return await tcs.Task;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error during remote access prompt.");
+ return PromptForAccessResult.Error;
+ }
+ finally
+ {
+ _isPromptOpen = false;
+ }
+ }
+}
diff --git a/Desktop.Android/Services/AndroidScreenCapturer.cs b/Desktop.Android/Services/AndroidScreenCapturer.cs
new file mode 100644
index 000000000..987fb5db5
--- /dev/null
+++ b/Desktop.Android/Services/AndroidScreenCapturer.cs
@@ -0,0 +1,261 @@
+using Android.Content;
+using Android.Graphics;
+using Android.Hardware.Display;
+using Android.Media;
+using Android.Media.Projection;
+using Android.OS;
+using Android.Runtime;
+using Android.Views;
+using Microsoft.Extensions.Logging;
+using Remotely.Desktop.Shared.Abstractions;
+using Remotely.Desktop.Shared.Services;
+using SkiaSharp;
+using System.Drawing;
+using RemotelyResult = Remotely.Shared.Primitives.Result;
+
+namespace Remotely.Desktop.Android.Services;
+
+///
+/// Extended interface to expose Android-specific MediaProjection grant handling.
+///
+public interface IAndroidScreenCapturer : IScreenCapturer
+{
+ bool IsProjectionGranted { get; }
+ void OnMediaProjectionGranted(int resultCode, Intent data);
+}
+
+///
+/// Captures the Android device screen using the MediaProjection API (API 21+).
+/// The user must grant the screen-capture permission via the system dialog (shown
+/// by ) before capture can begin.
+///
+public class AndroidScreenCapturer : IAndroidScreenCapturer
+{
+ private const string DisplayName = "display0";
+
+ private readonly Context _context;
+ private readonly IImageHelper _imageHelper;
+ private readonly ILogger _logger;
+ private readonly object _frameLock = new();
+
+ private MediaProjectionManager? _projectionManager;
+ private MediaProjection? _mediaProjection;
+ private VirtualDisplay? _virtualDisplay;
+ private ImageReader? _imageReader;
+ private SKBitmap? _currentFrame;
+ private SKBitmap? _previousFrame;
+ private bool _needsInit = true;
+
+ public AndroidScreenCapturer(
+ Context context,
+ IImageHelper imageHelper,
+ ILogger logger)
+ {
+ _context = context;
+ _imageHelper = imageHelper;
+ _logger = logger;
+
+ _projectionManager = (MediaProjectionManager?)
+ _context.GetSystemService(Context.MediaProjectionService);
+ }
+
+ public event EventHandler? ScreenChanged;
+
+ public bool CaptureFullscreen { get; set; } = true;
+ public System.Drawing.Rectangle CurrentScreenBounds { get; private set; }
+ public bool IsGpuAccelerated => false;
+ public bool IsProjectionGranted => _mediaProjection != null;
+ public string SelectedScreen { get; private set; } = DisplayName;
+
+ public void Dispose()
+ {
+ try
+ {
+ _virtualDisplay?.Release();
+ _imageReader?.Close();
+ _mediaProjection?.Stop();
+ _currentFrame?.Dispose();
+ _previousFrame?.Dispose();
+ GC.SuppressFinalize(this);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error while disposing AndroidScreenCapturer.");
+ }
+ }
+
+ public IEnumerable GetDisplayNames()
+ {
+ yield return DisplayName;
+ }
+
+ public SKRect GetFrameDiffArea()
+ {
+ if (_currentFrame is null)
+ {
+ return SKRect.Empty;
+ }
+ return _imageHelper.GetDiffArea(_currentFrame, _previousFrame, CaptureFullscreen);
+ }
+
+ public Remotely.Shared.Primitives.Result GetImageDiff()
+ {
+ if (_currentFrame is null)
+ {
+ return RemotelyResult.Fail("Current frame is null.");
+ }
+ return _imageHelper.GetImageDiff(_currentFrame, _previousFrame);
+ }
+
+ public Remotely.Shared.Primitives.Result GetNextFrame()
+ {
+ lock (_frameLock)
+ {
+ try
+ {
+ if (_mediaProjection is null)
+ {
+ return RemotelyResult.Fail("MediaProjection not granted yet.");
+ }
+
+ if (_needsInit)
+ {
+ Init();
+ }
+
+ if (_imageReader is null)
+ {
+ return RemotelyResult.Fail("ImageReader not initialized.");
+ }
+
+ using var image = _imageReader.AcquireLatestImage();
+ if (image is null)
+ {
+ return RemotelyResult.Fail("No new frame available.");
+ }
+
+ var planes = image.GetPlanes();
+ if (planes is null || planes.Length == 0)
+ {
+ return RemotelyResult.Fail("Image planes are empty.");
+ }
+
+ var plane = planes[0];
+ var buffer = plane.Buffer;
+ if (buffer is null)
+ {
+ return RemotelyResult.Fail("Plane buffer is null.");
+ }
+
+ var width = image.Width;
+ var height = image.Height;
+ var pixelStride = plane.PixelStride;
+ var rowStride = plane.RowStride;
+
+ if (_currentFrame != null)
+ {
+ _previousFrame?.Dispose();
+ _previousFrame = _currentFrame;
+ }
+
+ var bitmap = new SKBitmap(width, height, SKColorType.Bgra8888, SKAlphaType.Premul);
+
+ unsafe
+ {
+ var destPtr = (byte*)bitmap.GetPixels().ToPointer();
+ var bytes = new byte[buffer.Remaining()];
+ buffer.Get(bytes);
+
+ for (var row = 0; row < height; row++)
+ {
+ for (var col = 0; col < width; col++)
+ {
+ var srcIndex = row * rowStride + col * pixelStride;
+ var destIndex = (row * width + col) * 4;
+ // RGBA → BGRA conversion
+ destPtr[destIndex + 0] = bytes[srcIndex + 2]; // B
+ destPtr[destIndex + 1] = bytes[srcIndex + 1]; // G
+ destPtr[destIndex + 2] = bytes[srcIndex + 0]; // R
+ destPtr[destIndex + 3] = bytes[srcIndex + 3]; // A
+ }
+ }
+ }
+
+ _currentFrame = bitmap;
+ return RemotelyResult.Ok(_currentFrame);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error while getting next frame.");
+ _needsInit = true;
+ return RemotelyResult.Fail(ex);
+ }
+ }
+ }
+
+ public int GetScreenCount() => 1;
+
+ public System.Drawing.Rectangle GetVirtualScreenBounds() => CurrentScreenBounds;
+
+ public void Init()
+ {
+ try
+ {
+ var displayMetrics = _context.Resources?.DisplayMetrics;
+ if (displayMetrics is null)
+ {
+ _logger.LogError("Failed to get display metrics.");
+ return;
+ }
+
+ var width = displayMetrics.WidthPixels;
+ var height = displayMetrics.HeightPixels;
+ var dpi = (int)displayMetrics.DensityDpi;
+
+ CurrentScreenBounds = new System.Drawing.Rectangle(0, 0, width, height);
+ CaptureFullscreen = true;
+
+ _imageReader?.Close();
+ _virtualDisplay?.Release();
+
+ // PixelFormat.RGBA_8888 = 1. This format is required for MediaProjection screen capture.
+ // The binding's ImageFormatType enum does not expose this value by name, so we cast the
+ // integer constant that matches Android's PixelFormat.RGBA_8888 contract.
+ const int PixelFormatRgba8888 = 1;
+ _imageReader = ImageReader.NewInstance(width, height, (ImageFormatType)PixelFormatRgba8888, 2);
+
+ // MediaProjection.CreateVirtualDisplay signature:
+ // (name, width, height, dpi, DisplayFlags flags, Surface surface, Callback callback, Handler handler)
+ _virtualDisplay = _mediaProjection?.CreateVirtualDisplay(
+ "RemotelyCapture",
+ width,
+ height,
+ dpi,
+ DisplayFlags.None,
+ _imageReader.Surface,
+ null,
+ null);
+
+ _needsInit = false;
+ ScreenChanged?.Invoke(this, CurrentScreenBounds);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error while initializing screen capturer.");
+ }
+ }
+
+ public void OnMediaProjectionGranted(int resultCode, Intent data)
+ {
+ _mediaProjection = _projectionManager?.GetMediaProjection(resultCode, data);
+ _needsInit = true;
+ _logger.LogInformation("MediaProjection granted.");
+ }
+
+ public void SetSelectedScreen(string displayName)
+ {
+ // Android devices typically have a single display.
+ // Multi-display support can be added in a future iteration.
+ SelectedScreen = DisplayName;
+ }
+}
diff --git a/Desktop.Android/Services/AndroidSessionIndicator.cs b/Desktop.Android/Services/AndroidSessionIndicator.cs
new file mode 100644
index 000000000..956c22918
--- /dev/null
+++ b/Desktop.Android/Services/AndroidSessionIndicator.cs
@@ -0,0 +1,89 @@
+using Android.App;
+using Android.Content;
+using Android.OS;
+using Microsoft.Extensions.Logging;
+using Remotely.Desktop.Shared.Abstractions;
+
+namespace Remotely.Desktop.Android.Services;
+
+///
+/// Displays a persistent foreground notification to inform the device owner
+/// that a Remotely remote control session is active.
+///
+public class AndroidSessionIndicator : ISessionIndicator
+{
+ private const string ChannelId = "remotely_session";
+ private const int NotificationId = 1001;
+
+ private readonly Context _context;
+ private readonly ILogger _logger;
+
+ public AndroidSessionIndicator(
+ Context context,
+ ILogger logger)
+ {
+ _context = context;
+ _logger = logger;
+ }
+
+ public void Show()
+ {
+ try
+ {
+ EnsureNotificationChannel();
+
+ var notificationManager = (NotificationManager?)
+ _context.GetSystemService(Context.NotificationService);
+
+ // Intent to stop the session: opens MainActivity.
+ var intent = new Intent(_context, typeof(MainActivity));
+ intent.SetFlags(ActivityFlags.SingleTop);
+ var pendingIntent = PendingIntent.GetActivity(
+ _context,
+ 0,
+ intent,
+ PendingIntentFlags.Immutable);
+
+ var notification = new Notification.Builder(_context, ChannelId)
+ .SetContentTitle("Remotely – Session Active")
+ .SetContentText("Your screen is being accessed remotely. Tap to view.")
+ .SetSmallIcon(global::Android.Resource.Drawable.IcDialogInfo)
+ .SetContentIntent(pendingIntent)
+ .SetOngoing(true)
+ .SetAutoCancel(false)
+ .Build();
+
+ notificationManager?.Notify(NotificationId, notification);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error while showing session indicator.");
+ }
+ }
+
+ private void EnsureNotificationChannel()
+ {
+ if (Build.VERSION.SdkInt < BuildVersionCodes.O)
+ {
+ return;
+ }
+
+ var notificationManager = (NotificationManager?)
+ _context.GetSystemService(Context.NotificationService);
+
+ if (notificationManager?.GetNotificationChannel(ChannelId) != null)
+ {
+ return;
+ }
+
+ var channel = new NotificationChannel(
+ ChannelId,
+ "Remotely Session",
+ NotificationImportance.Low)
+ {
+ Description = "Shown while a Remotely remote control session is active."
+ };
+
+ notificationManager?.CreateNotificationChannel(channel);
+ }
+}
diff --git a/Desktop.Android/Services/AndroidShutdownService.cs b/Desktop.Android/Services/AndroidShutdownService.cs
new file mode 100644
index 000000000..c101e9f73
--- /dev/null
+++ b/Desktop.Android/Services/AndroidShutdownService.cs
@@ -0,0 +1,57 @@
+using Android.Content;
+using Microsoft.Extensions.Logging;
+using Remotely.Desktop.Shared.Abstractions;
+using Remotely.Desktop.Shared.Services;
+
+namespace Remotely.Desktop.Android.Services;
+
+///
+/// Shuts down the Remotely Android remote control session by disconnecting all
+/// viewers and stopping the foreground service.
+///
+public class AndroidShutdownService : IShutdownService
+{
+ private readonly Context _context;
+ private readonly IDesktopHubConnection _hubConnection;
+ private readonly IAppState _appState;
+ private readonly ILogger _logger;
+
+ public AndroidShutdownService(
+ Context context,
+ IDesktopHubConnection hubConnection,
+ IAppState appState,
+ ILogger logger)
+ {
+ _context = context;
+ _hubConnection = hubConnection;
+ _appState = appState;
+ _logger = logger;
+ }
+
+ public async Task Shutdown()
+ {
+ _logger.LogInformation("Shutting down Remotely Android agent.");
+
+ await TryDisconnectViewers();
+
+ // Stop the foreground service.
+ var serviceIntent = new Intent(_context, typeof(AgentForegroundService));
+ _context.StopService(serviceIntent);
+ }
+
+ private async Task TryDisconnectViewers()
+ {
+ try
+ {
+ if (_hubConnection.IsConnected && _appState.Viewers.Any())
+ {
+ await _hubConnection.DisconnectAllViewers();
+ await _hubConnection.Disconnect();
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error while disconnecting viewers during shutdown.");
+ }
+ }
+}
diff --git a/Desktop.Android/Services/RemotelyAccessibilityService.cs b/Desktop.Android/Services/RemotelyAccessibilityService.cs
new file mode 100644
index 000000000..9c6a97425
--- /dev/null
+++ b/Desktop.Android/Services/RemotelyAccessibilityService.cs
@@ -0,0 +1,199 @@
+using Android.AccessibilityServices;
+using Android.Graphics;
+using Android.OS;
+using Android.Views;
+using Android.Views.Accessibility;
+using Microsoft.Extensions.Logging;
+using AGPath = Android.Graphics.Path;
+
+namespace Remotely.Desktop.Android.Services;
+
+///
+/// Android Accessibility Service that enables gesture and key injection for remote input.
+/// The user must manually enable this service in Android Settings → Accessibility → Remotely.
+///
+public class RemotelyAccessibilityService : AccessibilityService
+{
+ private static RemotelyAccessibilityService? _instance;
+
+ ///
+ /// Gets the current active instance of the accessibility service, or null if not enabled.
+ ///
+ public static RemotelyAccessibilityService? Instance => _instance;
+
+ public int ScreenWidth { get; private set; }
+ public int ScreenHeight { get; private set; }
+
+ public override void OnAccessibilityEvent(AccessibilityEvent? e) { }
+ public override void OnInterrupt() { }
+
+ protected override void OnServiceConnected()
+ {
+ base.OnServiceConnected();
+ _instance = this;
+
+ var displayMetrics = Resources?.DisplayMetrics;
+ if (displayMetrics != null)
+ {
+ ScreenWidth = displayMetrics.WidthPixels;
+ ScreenHeight = displayMetrics.HeightPixels;
+ }
+ }
+
+ public override void OnDestroy()
+ {
+ base.OnDestroy();
+ if (_instance == this)
+ {
+ _instance = null;
+ }
+ }
+
+ ///
+ /// Injects a key event (down or up).
+ /// Note: Android does not support arbitrary key injection without root or a focused
+ /// accessibility node. This method attempts a best-effort injection via the focused
+ /// text field through . For non-text keys, callers should
+ /// use directly for text input.
+ ///
+ public void InjectKeyEvent(KeyEvent keyEvent)
+ {
+ // Best-effort: keep the service alive; actual key injection is performed
+ // through InjectText (ACTION_SET_TEXT) for the focused node.
+ PerformGlobalAction(0);
+ }
+
+ ///
+ /// Injects text into the focused field.
+ ///
+ public void InjectText(string text)
+ {
+ var focusedNode = FindFocus(NodeFocus.Input);
+ if (focusedNode != null)
+ {
+ var args = new Bundle();
+ args.PutCharSequence(
+ AccessibilityNodeInfo.ActionArgumentSetTextCharsequence,
+ new Java.Lang.String(text));
+ focusedNode.PerformAction(
+ global::Android.Views.Accessibility.Action.SetText,
+ args);
+ }
+ }
+
+ ///
+ /// Injects a tap-down gesture at the specified coordinates.
+ ///
+ public void InjectTapDown(float x, float y)
+ {
+ if (!OperatingSystem.IsAndroidVersionAtLeast(26))
+ {
+ return;
+ }
+
+ var path = new AGPath();
+ path.MoveTo(x, y);
+
+ var strokeDescription = new GestureDescription.StrokeDescription(
+ path,
+ startTime: 0,
+ duration: 1,
+ willContinue: true);
+
+ var gestureDescription = new GestureDescription.Builder()
+ .AddStroke(strokeDescription)!
+ .Build()!;
+
+ if (gestureDescription != null)
+ {
+ DispatchGesture(gestureDescription, null, null);
+ }
+ }
+
+ ///
+ /// Injects a tap-up gesture at the specified coordinates.
+ ///
+ public void InjectTapUp(float x, float y)
+ {
+ if (!OperatingSystem.IsAndroidVersionAtLeast(26))
+ {
+ return;
+ }
+
+ var path = new AGPath();
+ path.MoveTo(x, y);
+
+ var strokeDescription = new GestureDescription.StrokeDescription(
+ path,
+ startTime: 0,
+ duration: 50,
+ willContinue: false);
+
+ var gestureDescription = new GestureDescription.Builder()
+ .AddStroke(strokeDescription)!
+ .Build()!;
+
+ if (gestureDescription != null)
+ {
+ DispatchGesture(gestureDescription, null, null);
+ }
+ }
+
+ ///
+ /// Injects a long-press gesture at the specified coordinates.
+ ///
+ public void InjectLongPress(float x, float y)
+ {
+ if (!OperatingSystem.IsAndroidVersionAtLeast(26))
+ {
+ return;
+ }
+
+ var path = new AGPath();
+ path.MoveTo(x, y);
+
+ var strokeDescription = new GestureDescription.StrokeDescription(
+ path,
+ startTime: 0,
+ duration: 600);
+
+ var gestureDescription = new GestureDescription.Builder()
+ .AddStroke(strokeDescription)!
+ .Build()!;
+
+ if (gestureDescription != null)
+ {
+ DispatchGesture(gestureDescription, null, null);
+ }
+ }
+
+ ///
+ /// Injects a swipe gesture from one point to another.
+ ///
+ public void InjectSwipe(float fromX, float fromY, float toX, float toY, long durationMs)
+ {
+ if (!OperatingSystem.IsAndroidVersionAtLeast(26))
+ {
+ return;
+ }
+
+ var path = new AGPath();
+ path.MoveTo(fromX, fromY);
+ path.LineTo(toX, toY);
+
+ var strokeDescription = new GestureDescription.StrokeDescription(
+ path,
+ startTime: 0,
+ duration: Math.Max(1, durationMs));
+
+ var gestureDescription = new GestureDescription.Builder()
+ .AddStroke(strokeDescription)!
+ .Build()!;
+
+ if (gestureDescription != null)
+ {
+ DispatchGesture(gestureDescription, null, null);
+ }
+ }
+}
+
diff --git a/Desktop.Android/Startup/IServiceCollectionExtensions.cs b/Desktop.Android/Startup/IServiceCollectionExtensions.cs
new file mode 100644
index 000000000..63920c05f
--- /dev/null
+++ b/Desktop.Android/Startup/IServiceCollectionExtensions.cs
@@ -0,0 +1,42 @@
+using Android.Content;
+using Microsoft.Extensions.DependencyInjection;
+using Remotely.Desktop.Android.Services;
+using Remotely.Desktop.Shared.Abstractions;
+using Remotely.Desktop.Shared.Startup;
+
+namespace Remotely.Desktop.Android.Startup;
+
+///
+/// Extension methods for registering Android remote control services.
+///
+public static class IServiceCollectionExtensions
+{
+ ///
+ /// Adds all Android-specific and cross-platform remote control services.
+ ///
+ public static void AddRemoteControlAndroid(
+ this IServiceCollection services,
+ Context context)
+ {
+ // Register the Android context as a singleton for services that need it.
+ services.AddSingleton(context);
+
+ // Register cross-platform core services.
+ services.AddRemoteControlXplat();
+
+ // Android-specific implementations of Desktop.Shared abstractions.
+ services.AddSingleton();
+ services.AddTransient(sp => sp.GetRequiredService());
+
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddScoped();
+ services.AddSingleton();
+ services.AddSingleton();
+ }
+}
diff --git a/Desktop.Shared/Properties/AssemblyInfo.cs b/Desktop.Shared/Properties/AssemblyInfo.cs
index ddd26d78c..decf1ec96 100644
--- a/Desktop.Shared/Properties/AssemblyInfo.cs
+++ b/Desktop.Shared/Properties/AssemblyInfo.cs
@@ -1,3 +1,4 @@
using System.Runtime.CompilerServices;
-[assembly: InternalsVisibleTo("Remotely_Desktop")]
\ No newline at end of file
+[assembly: InternalsVisibleTo("Remotely_Desktop")]
+[assembly: InternalsVisibleTo("Remotely_Desktop_Android")]
\ No newline at end of file
diff --git a/Remotely.sln b/Remotely.sln
index 872e15657..b695d14c8 100644
--- a/Remotely.sln
+++ b/Remotely.sln
@@ -62,6 +62,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Desktop.UI", "Desktop.UI\De
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Desktop.Native", "Desktop.Native\Desktop.Native.csproj", "{DB48AE81-BFD4-4005-8693-365B045C8D18}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Desktop.Android", "Desktop.Android\Desktop.Android.csproj", "{9CF22F99-7310-4028-BA95-F3472FD3B156}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Desktop.Core", "Desktop.Core\Desktop.Core.csproj", "{AC05209D-F15B-48EB-B541-97EF76C1A873}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -216,6 +220,30 @@ Global
{DB48AE81-BFD4-4005-8693-365B045C8D18}.Release|x64.Build.0 = Release|Any CPU
{DB48AE81-BFD4-4005-8693-365B045C8D18}.Release|x86.ActiveCfg = Release|Any CPU
{DB48AE81-BFD4-4005-8693-365B045C8D18}.Release|x86.Build.0 = Release|Any CPU
+ {9CF22F99-7310-4028-BA95-F3472FD3B156}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9CF22F99-7310-4028-BA95-F3472FD3B156}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9CF22F99-7310-4028-BA95-F3472FD3B156}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {9CF22F99-7310-4028-BA95-F3472FD3B156}.Debug|x64.Build.0 = Debug|Any CPU
+ {9CF22F99-7310-4028-BA95-F3472FD3B156}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {9CF22F99-7310-4028-BA95-F3472FD3B156}.Debug|x86.Build.0 = Debug|Any CPU
+ {9CF22F99-7310-4028-BA95-F3472FD3B156}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9CF22F99-7310-4028-BA95-F3472FD3B156}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9CF22F99-7310-4028-BA95-F3472FD3B156}.Release|x64.ActiveCfg = Release|Any CPU
+ {9CF22F99-7310-4028-BA95-F3472FD3B156}.Release|x64.Build.0 = Release|Any CPU
+ {9CF22F99-7310-4028-BA95-F3472FD3B156}.Release|x86.ActiveCfg = Release|Any CPU
+ {9CF22F99-7310-4028-BA95-F3472FD3B156}.Release|x86.Build.0 = Release|Any CPU
+ {AC05209D-F15B-48EB-B541-97EF76C1A873}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {AC05209D-F15B-48EB-B541-97EF76C1A873}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {AC05209D-F15B-48EB-B541-97EF76C1A873}.Debug|x64.ActiveCfg = Debug|x64
+ {AC05209D-F15B-48EB-B541-97EF76C1A873}.Debug|x64.Build.0 = Debug|x64
+ {AC05209D-F15B-48EB-B541-97EF76C1A873}.Debug|x86.ActiveCfg = Debug|x86
+ {AC05209D-F15B-48EB-B541-97EF76C1A873}.Debug|x86.Build.0 = Debug|x86
+ {AC05209D-F15B-48EB-B541-97EF76C1A873}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {AC05209D-F15B-48EB-B541-97EF76C1A873}.Release|Any CPU.Build.0 = Release|Any CPU
+ {AC05209D-F15B-48EB-B541-97EF76C1A873}.Release|x64.ActiveCfg = Release|x64
+ {AC05209D-F15B-48EB-B541-97EF76C1A873}.Release|x64.Build.0 = Release|x64
+ {AC05209D-F15B-48EB-B541-97EF76C1A873}.Release|x86.ActiveCfg = Release|x86
+ {AC05209D-F15B-48EB-B541-97EF76C1A873}.Release|x86.Build.0 = Release|x86
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/Shared/Enums/Platform.cs b/Shared/Enums/Platform.cs
index 7543104c9..5d680d4f5 100644
--- a/Shared/Enums/Platform.cs
+++ b/Shared/Enums/Platform.cs
@@ -5,5 +5,6 @@ public enum Platform
Windows,
Linux,
MacOS,
+ Android,
Unknown
}