From dbcbc5084a975793f4a8123472600615bceaa4fc Mon Sep 17 00:00:00 2001
From: AdvDebug <90452585+AdvDebug@users.noreply.github.com>
Date: Sun, 2 Aug 2026 19:10:51 +0300
Subject: [PATCH 1/3] Add Android host support (arm64, GDI, Vulkan)
Brovan now runs on Android as a third host alongside Windows and Linux.
New Brovan android embedding layer. The emulator becomes a NativeAOT shared
library driven by a host app rather than a process with a Main:
- BrovanAndroidApi - exported C ABI (init, surface, start, input injection,
window enumeration, debugger commands).
- AndroidWinManager - IDisplayConnection over ANativeWindow, plus
IGdiRenderSupport.
- AndroidGdiSurface - software rasteriser (lines, rects, ellipses, polygons)
into a per-guest-window backbuffer posted via ANativeWindow_lock/
unlockAndPost, white background.
- AndroidVulkanWsi and a generator branch - VK_KHR_android_surface instead of
Win32/Xcb.
- AndroidInput, AndroidLog, AndroidHost, AndroidGuestWindows, JNI shim, Java
bindings.
- Launcher app: Material 3 library, in-app program import via SAF, settings,
on-screen joystick/D-pad/touchpad controls, opt-in developer console wired to
the debugger.
- build-apk.sh - Unicorn cross-build, linux-bionic-arm64 publish, bundled
OpenSSL, APK assembly.
Changes to shared emulator code:
- Case-insensitive shipped-DLL resolution in GetWindowsLibPath. Import tables
say KERNEL32.dll, System32 ships kernel32.dll; broken on any case-sensitive
host, and it surfaced as a guest loading zero modules.
- GlobalPropertiesToRemove on the generator ProjectReference. Target-shaped
properties leaked into the analyzer, csc silently refused to load it (CS8034
is only a warning), and every source generator emitted nothing.
- GdiPrimitive.Hwnd. The four EnqueueGdi* helpers already had the guest HWND and
dropped it, making per-window compositing impossible.
- Program.SplitCommandLine widened to internal for embedders.
Windows and Linux behaviour is unchanged; the new paths are gated on the RID or
!IsWindows.
---
.github/workflows/build.yml | 66 ++-
.gitignore | 5 +
Brovan.Android/Brovan.Android.csproj | 34 ++
Brovan.Generators/VulkanForwardGenerator.cs | 15 +-
Brovan/Android/AndroidGdiSurface.cs | 309 ++++++++++++++
Brovan/Android/AndroidGuestWindows.cs | 79 ++++
Brovan/Android/AndroidHost.cs | 97 +++++
Brovan/Android/AndroidInput.cs | 138 ++++++
Brovan/Android/AndroidLog.cs | 176 ++++++++
Brovan/Android/AndroidNative.cs | 66 +++
Brovan/Android/AndroidVulkanWsi.cs | 13 +
Brovan/Android/AndroidWinManager.cs | 218 ++++++++++
Brovan/Android/BrovanAndroidApi.cs | 399 ++++++++++++++++++
Brovan/Android/android-link.targets | 25 ++
Brovan/Android/app/brovan/build.gradle | 73 ++++
.../app/brovan/src/main/AndroidManifest.xml | 32 ++
.../src/main/java/dev/brovan/app/Library.java | 227 ++++++++++
.../java/dev/brovan/app/MainActivity.java | 299 +++++++++++++
.../java/dev/brovan/app/PlayerActivity.java | 307 ++++++++++++++
.../src/main/java/dev/brovan/app/Program.java | 33 ++
.../java/dev/brovan/app/ProgramAdapter.java | 69 +++
.../main/java/dev/brovan/app/Settings.java | 64 +++
.../brovan/src/main/res/drawable/ic_about.xml | 6 +
.../brovan/src/main/res/drawable/ic_add.xml | 5 +
.../src/main/res/drawable/ic_library.xml | 6 +
.../brovan/src/main/res/drawable/ic_menu.xml | 6 +
.../brovan/src/main/res/drawable/ic_play.xml | 6 +
.../src/main/res/drawable/ic_settings.xml | 6 +
.../src/main/res/layout/activity_main.xml | 44 ++
.../src/main/res/layout/activity_player.xml | 93 ++++
.../brovan/src/main/res/layout/item_app.xml | 48 +++
.../brovan/src/main/res/layout/nav_header.xml | 26 ++
.../src/main/res/layout/screen_about.xml | 69 +++
.../src/main/res/layout/screen_library.xml | 60 +++
.../src/main/res/layout/screen_settings.xml | 85 ++++
.../app/brovan/src/main/res/menu/nav_menu.xml | 8 +
.../app/brovan/src/main/res/values/colors.xml | 16 +
.../brovan/src/main/res/values/strings.xml | 40 ++
.../app/brovan/src/main/res/values/themes.xml | 33 ++
Brovan/Android/app/build.gradle | 3 +
Brovan/Android/app/gradle.properties | 3 +
Brovan/Android/app/settings.gradle | 18 +
Brovan/Android/build-apk.sh | 156 +++++++
Brovan/Android/build-openssl.sh | 42 ++
.../Android/java/dev/brovan/BrovanNative.java | 229 ++++++++++
.../java/dev/brovan/BrovanSurfaceView.java | 165 ++++++++
.../Android/java/dev/brovan/GuestWindow.java | 44 ++
.../dev/brovan/input/ActionButtonView.java | 88 ++++
.../java/dev/brovan/input/ControlOverlay.java | 165 ++++++++
.../java/dev/brovan/input/JoystickView.java | 132 ++++++
.../java/dev/brovan/input/KeyEmitter.java | 44 ++
.../java/dev/brovan/input/TouchpadView.java | 95 +++++
.../java/dev/brovan/input/VirtualKey.java | 46 ++
Brovan/Android/jni/CMakeLists.txt | 17 +
Brovan/Android/jni/brovan_jni.c | 276 ++++++++++++
Brovan/Brovan.csproj | 12 +-
.../WindowManager/WindowManager.cs | 30 +-
.../Emulation/OS/Windows/WinSyscallsHelper.cs | 4 +
Brovan/GeneralHelper.cs | 35 ++
Brovan/Program.cs | 2 +-
60 files changed, 4894 insertions(+), 13 deletions(-)
create mode 100644 Brovan.Android/Brovan.Android.csproj
create mode 100644 Brovan/Android/AndroidGdiSurface.cs
create mode 100644 Brovan/Android/AndroidGuestWindows.cs
create mode 100644 Brovan/Android/AndroidHost.cs
create mode 100644 Brovan/Android/AndroidInput.cs
create mode 100644 Brovan/Android/AndroidLog.cs
create mode 100644 Brovan/Android/AndroidNative.cs
create mode 100644 Brovan/Android/AndroidVulkanWsi.cs
create mode 100644 Brovan/Android/AndroidWinManager.cs
create mode 100644 Brovan/Android/BrovanAndroidApi.cs
create mode 100644 Brovan/Android/android-link.targets
create mode 100644 Brovan/Android/app/brovan/build.gradle
create mode 100644 Brovan/Android/app/brovan/src/main/AndroidManifest.xml
create mode 100644 Brovan/Android/app/brovan/src/main/java/dev/brovan/app/Library.java
create mode 100644 Brovan/Android/app/brovan/src/main/java/dev/brovan/app/MainActivity.java
create mode 100644 Brovan/Android/app/brovan/src/main/java/dev/brovan/app/PlayerActivity.java
create mode 100644 Brovan/Android/app/brovan/src/main/java/dev/brovan/app/Program.java
create mode 100644 Brovan/Android/app/brovan/src/main/java/dev/brovan/app/ProgramAdapter.java
create mode 100644 Brovan/Android/app/brovan/src/main/java/dev/brovan/app/Settings.java
create mode 100644 Brovan/Android/app/brovan/src/main/res/drawable/ic_about.xml
create mode 100644 Brovan/Android/app/brovan/src/main/res/drawable/ic_add.xml
create mode 100644 Brovan/Android/app/brovan/src/main/res/drawable/ic_library.xml
create mode 100644 Brovan/Android/app/brovan/src/main/res/drawable/ic_menu.xml
create mode 100644 Brovan/Android/app/brovan/src/main/res/drawable/ic_play.xml
create mode 100644 Brovan/Android/app/brovan/src/main/res/drawable/ic_settings.xml
create mode 100644 Brovan/Android/app/brovan/src/main/res/layout/activity_main.xml
create mode 100644 Brovan/Android/app/brovan/src/main/res/layout/activity_player.xml
create mode 100644 Brovan/Android/app/brovan/src/main/res/layout/item_app.xml
create mode 100644 Brovan/Android/app/brovan/src/main/res/layout/nav_header.xml
create mode 100644 Brovan/Android/app/brovan/src/main/res/layout/screen_about.xml
create mode 100644 Brovan/Android/app/brovan/src/main/res/layout/screen_library.xml
create mode 100644 Brovan/Android/app/brovan/src/main/res/layout/screen_settings.xml
create mode 100644 Brovan/Android/app/brovan/src/main/res/menu/nav_menu.xml
create mode 100644 Brovan/Android/app/brovan/src/main/res/values/colors.xml
create mode 100644 Brovan/Android/app/brovan/src/main/res/values/strings.xml
create mode 100644 Brovan/Android/app/brovan/src/main/res/values/themes.xml
create mode 100644 Brovan/Android/app/build.gradle
create mode 100644 Brovan/Android/app/gradle.properties
create mode 100644 Brovan/Android/app/settings.gradle
create mode 100644 Brovan/Android/build-apk.sh
create mode 100644 Brovan/Android/build-openssl.sh
create mode 100644 Brovan/Android/java/dev/brovan/BrovanNative.java
create mode 100644 Brovan/Android/java/dev/brovan/BrovanSurfaceView.java
create mode 100644 Brovan/Android/java/dev/brovan/GuestWindow.java
create mode 100644 Brovan/Android/java/dev/brovan/input/ActionButtonView.java
create mode 100644 Brovan/Android/java/dev/brovan/input/ControlOverlay.java
create mode 100644 Brovan/Android/java/dev/brovan/input/JoystickView.java
create mode 100644 Brovan/Android/java/dev/brovan/input/KeyEmitter.java
create mode 100644 Brovan/Android/java/dev/brovan/input/TouchpadView.java
create mode 100644 Brovan/Android/java/dev/brovan/input/VirtualKey.java
create mode 100644 Brovan/Android/jni/CMakeLists.txt
create mode 100644 Brovan/Android/jni/brovan_jni.c
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 8774c6f..a4ef3c9 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -62,10 +62,70 @@ jobs:
path: artifacts/packages/*
retention-days: 14
+ android:
+ name: Build Android arm64
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ # A .NET 9 SDK is required even though the project targets net8.0: the source generator needs
+ # Roslyn >= 4.10.
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: 9.0.x
+
+ - name: Setup JDK
+ uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: '17'
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v4
+ with:
+ gradle-version: '8.7'
+
+ # The NDK version has to match ndkVersion in Brovan/Android/app/brovan/build.gradle; the runner image
+ # preinstalls a newer one that build-apk.sh would otherwise pick.
+ - name: Install the Android NDK and CMake
+ run: |
+ SDKMANAGER="$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager"
+ yes | "$SDKMANAGER" --licenses > /dev/null 2>&1 || true
+ "$SDKMANAGER" "ndk;26.3.11579264" "cmake;3.22.1" "platforms;android-34" "build-tools;34.0.0"
+ echo "ANDROID_NDK_HOME=$ANDROID_SDK_ROOT/ndk/26.3.11579264" >> "$GITHUB_ENV"
+
+ - name: Cache OpenSSL
+ uses: actions/cache@v4
+ with:
+ path: ~/brovan-toolchain/openssl-3.5.4
+ key: brovan-openssl-3.5.4-android-arm64
+
+ - name: Build OpenSSL for android-arm64
+ run: bash Brovan/Android/build-openssl.sh
+
+ - name: Cache Unicorn
+ uses: actions/cache@v4
+ with:
+ path: Brovan/.cache/unicorn
+ key: brovan-unicorn-android-arm64-${{ hashFiles('Brovan/Brovan.Unicorn.targets') }}
+
+ - name: Build APK
+ run: bash Brovan/Android/build-apk.sh
+
+ - name: Upload CI Artifact
+ uses: actions/upload-artifact@v6
+ with:
+ name: Brovan Android arm64
+ path: artifacts/android/*.apk
+ retention-days: 14
+
release:
name: Publish GitHub Release
if: startsWith(github.ref, 'refs/tags/')
- needs: build
+ needs: [ build, android ]
runs-on: ubuntu-latest
permissions:
contents: write
@@ -80,6 +140,6 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
- gh release create "${{ github.ref_name }}" release-artifacts/**/*
- --title "${{ github.ref_name }}"
+ gh release create "${{ github.ref_name }}" release-artifacts/**/* \
+ --title "${{ github.ref_name }}" \
--generate-notes
diff --git a/.gitignore b/.gitignore
index bac1860..77832ac 100644
--- a/.gitignore
+++ b/.gitignore
@@ -62,3 +62,8 @@ nunit-*.xml
/Brovan.Graphics/brovvulk-icd/generated
/VirtualFS
/Brovan/Properties
+/Brovan/Android/app/.gradle/
+/Brovan/Android/app/**/build/
+/Brovan/Android/app/local.properties
+/Brovan/Android/app/brovan/src/main/jniLibs/
+/*.apk
diff --git a/Brovan.Android/Brovan.Android.csproj b/Brovan.Android/Brovan.Android.csproj
new file mode 100644
index 0000000..47fc7c8
--- /dev/null
+++ b/Brovan.Android/Brovan.Android.csproj
@@ -0,0 +1,34 @@
+
+
+
+
+
+ net8.0
+ false
+ false
+ true
+ $([MSBuild]::NormalizePath($(MSBuildThisFileDirectory), '..', 'Brovan', 'Android', 'build-apk.sh'))
+ $([MSBuild]::NormalizePath($(MSBuildThisFileDirectory), '..', 'artifacts', 'android', 'brovan-arm64-v8a.apk'))
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Brovan.Generators/VulkanForwardGenerator.cs b/Brovan.Generators/VulkanForwardGenerator.cs
index 16c5bac..626852b 100644
--- a/Brovan.Generators/VulkanForwardGenerator.cs
+++ b/Brovan.Generators/VulkanForwardGenerator.cs
@@ -1078,7 +1078,7 @@ private static string EmitHostCase(Model m, Command c, int id)
" uint hasCi = r.ReadU32();\n" +
" System.IntPtr ci = System.IntPtr.Zero;\n" +
" if (hasCi != 0) ci = BrovVulkGenStruct.Rebuild(" + StructId["VkInstanceCreateInfo"] + ", r, st);\n" +
- " if (Brovan.GeneralHelper.IsLinux && ci != System.IntPtr.Zero)\n" +
+ " if ((Brovan.GeneralHelper.IsLinux || Brovan.Android.AndroidHost.IsActive) && ci != System.IntPtr.Zero)\n" +
" {\n" +
" System.IntPtr extPtr = *(System.IntPtr*)(ci + 56);\n" +
" uint extCount = *(uint*)(ci + 48);\n" +
@@ -1100,7 +1100,7 @@ private static string EmitHostCase(Model m, Command c, int id)
" }\n" +
" if (sawWin32)\n" +
" {\n" +
- " System.IntPtr xcbName = System.Runtime.InteropServices.Marshal.StringToHGlobalAnsi(\"VK_KHR_xcb_surface\");\n" +
+ " System.IntPtr xcbName = System.Runtime.InteropServices.Marshal.StringToHGlobalAnsi(Brovan.Android.AndroidHost.IsActive ? \"VK_KHR_android_surface\" : \"VK_KHR_xcb_surface\");\n" +
" System.Runtime.InteropServices.Marshal.WriteIntPtr(newArr, (int)(newCount * 8), xcbName);\n" +
" newCount++;\n" +
" *(uint*)(ci + 48) = newCount;\n" +
@@ -1118,7 +1118,16 @@ private static string EmitHostCase(Model m, Command c, int id)
" System.IntPtr vi = st.Lookup(r.ReadU32(), \"VkInstance\");\n" +
" System.IntPtr surf = System.IntPtr.Zero;\n" +
" int rr;\n" +
- " if (Brovan.GeneralHelper.IsLinux)\n" +
+ " if (Brovan.Android.AndroidHost.IsActive)\n" +
+ " {\n" +
+ " System.IntPtr awin = inst.WinHelper.EnsureHostWindowHandle();\n" +
+ " byte* ci = stackalloc byte[32];\n" +
+ " for (int z = 0; z < 32; z++) ci[z] = 0;\n" +
+ " *(int*)(ci + 0) = " + "1000008000" + ";\n" +
+ " *(void**)(ci + 24) = (void*)awin;\n" +
+ " rr = (int)Brovan.Android.AndroidVulkanWsi.vkCreateAndroidSurfaceKHR(vi, (System.IntPtr)ci, System.IntPtr.Zero, (System.IntPtr)(&surf));\n" +
+ " }\n" +
+ " else if (Brovan.GeneralHelper.IsLinux)\n" +
" {\n" +
" System.IntPtr xdpy; System.IntPtr xwin;\n" +
" inst.WinHelper.EnsureHostXlibSurfaceHandles(out xdpy, out xwin);\n" +
diff --git a/Brovan/Android/AndroidGdiSurface.cs b/Brovan/Android/AndroidGdiSurface.cs
new file mode 100644
index 0000000..c787ddf
--- /dev/null
+++ b/Brovan/Android/AndroidGdiSurface.cs
@@ -0,0 +1,309 @@
+using System;
+using System.Collections.Generic;
+using Brovan.Core.Emulation.OS.SharedHelpers;
+
+namespace Brovan.Android
+{
+ internal sealed class AndroidGdiSurface
+ {
+ private const int DefaultBackground = unchecked((int)0xFFFFFFFF);
+
+ private const int MaximumWindows = 32;
+
+ private sealed class WindowBuffer
+ {
+ public int[] Pixels = Array.Empty();
+ public int Width;
+ public int Height;
+ public bool Dirty;
+ }
+
+ private readonly object _sync = new();
+ private readonly Dictionary _windows = new();
+
+ private ulong _lastDrawn;
+
+ public void Execute(in GdiPrimitive primitive)
+ {
+ lock (_sync)
+ {
+ WindowBuffer target = Resolve(primitive.Hwnd);
+ if (target == null)
+ return;
+
+ Draw(target, primitive);
+ target.Dirty = true;
+ _lastDrawn = primitive.Hwnd;
+ }
+ }
+
+ public void Flush()
+ {
+ lock (_sync)
+ {
+ IntPtr window = AndroidHost.NativeWindow;
+ if (window == IntPtr.Zero)
+ return;
+
+ ulong selected = AndroidGuestWindows.Selected;
+ if (selected == 0)
+ selected = _lastDrawn;
+
+ if (!_windows.TryGetValue(selected, out WindowBuffer target) || !target.Dirty)
+ return;
+
+ Post(window, target);
+ target.Dirty = false;
+ }
+ }
+
+ public void Invalidate()
+ {
+ lock (_sync)
+ {
+ foreach (WindowBuffer buffer in _windows.Values)
+ buffer.Dirty = true;
+ }
+ }
+
+ private WindowBuffer Resolve(ulong hwnd)
+ {
+ if (!_windows.TryGetValue(hwnd, out WindowBuffer buffer))
+ {
+ // A guest that churns windows would otherwise grow this without bound; the emulator only
+ // presents one at a time, so dropping the oldest costs nothing visible.
+ if (_windows.Count >= MaximumWindows)
+ _windows.Clear();
+
+ buffer = new WindowBuffer();
+ _windows[hwnd] = buffer;
+ }
+
+ int width = AndroidHost.Width;
+ int height = AndroidHost.Height;
+ if (width <= 0 || height <= 0)
+ return null;
+
+ if (buffer.Width != width || buffer.Height != height || buffer.Pixels.Length == 0)
+ {
+ buffer.Pixels = new int[width * height];
+ buffer.Width = width;
+ buffer.Height = height;
+ Array.Fill(buffer.Pixels, DefaultBackground);
+ }
+
+ return buffer;
+ }
+
+ private static void Draw(WindowBuffer target, in GdiPrimitive primitive)
+ {
+ int fill = ToPixel(primitive.Brush.ColorRef);
+ int stroke = ToPixel(primitive.Pen.ColorRef);
+ int thickness = Math.Max(1, primitive.Pen.Width);
+
+ switch (primitive.Kind)
+ {
+ case GdiPrimitiveKind.Line:
+ if (primitive.HasPen)
+ DrawLine(target, primitive.X1, primitive.Y1, primitive.X2, primitive.Y2, stroke, thickness);
+ break;
+
+ case GdiPrimitiveKind.FillRect:
+ FillRectangle(target, primitive.X1, primitive.Y1, primitive.X2, primitive.Y2, primitive.HasBrush ? fill : stroke);
+ break;
+
+ case GdiPrimitiveKind.Rectangle:
+ case GdiPrimitiveKind.RoundRect:
+ if (primitive.HasBrush)
+ FillRectangle(target, primitive.X1, primitive.Y1, primitive.X2, primitive.Y2, fill);
+ if (primitive.HasPen)
+ StrokeRectangle(target, primitive.X1, primitive.Y1, primitive.X2, primitive.Y2, stroke, thickness);
+ break;
+
+ case GdiPrimitiveKind.Ellipse:
+ DrawEllipse(target, primitive.X1, primitive.Y1, primitive.X2, primitive.Y2, primitive.HasBrush, fill, primitive.HasPen, stroke);
+ break;
+
+ case GdiPrimitiveKind.Polygon:
+ case GdiPrimitiveKind.Polyline:
+ DrawPolyline(target, primitive.Points, primitive.Kind == GdiPrimitiveKind.Polygon, primitive.HasPen ? stroke : fill, thickness);
+ break;
+ }
+ }
+
+ private static unsafe void Post(IntPtr window, WindowBuffer source)
+ {
+ AndroidNative.NativeWindowSetBuffersGeometry(window, source.Width, source.Height, AndroidNative.WindowFormatRgba8888);
+
+ AndroidNative.NativeWindowBuffer buffer;
+ if (AndroidNative.NativeWindowLock(window, &buffer, IntPtr.Zero) != 0)
+ return;
+
+ try
+ {
+ int rows = Math.Min(buffer.Height, source.Height);
+ int columns = Math.Min(buffer.Width, source.Width);
+ int* destination = (int*)buffer.Bits;
+
+ fixed (int* pixels = source.Pixels)
+ {
+ for (int y = 0; y < rows; y++)
+ {
+ int* sourceRow = pixels + (y * source.Width);
+ int* destinationRow = destination + (y * buffer.Stride);
+ for (int x = 0; x < columns; x++)
+ destinationRow[x] = sourceRow[x];
+ }
+ }
+ }
+ finally
+ {
+ AndroidNative.NativeWindowUnlockAndPost(window);
+ }
+ }
+
+ private static int ToPixel(uint colorRef)
+ {
+ return unchecked((int)(0xFF000000u | (colorRef & 0x00FFFFFFu)));
+ }
+
+ private static void SetPixel(WindowBuffer target, int x, int y, int color)
+ {
+ if ((uint)x >= (uint)target.Width || (uint)y >= (uint)target.Height)
+ return;
+
+ target.Pixels[(y * target.Width) + x] = color;
+ }
+
+ private static void FillRectangle(WindowBuffer target, int left, int top, int right, int bottom, int color)
+ {
+ Normalize(ref left, ref right);
+ Normalize(ref top, ref bottom);
+
+ left = Math.Max(left, 0);
+ top = Math.Max(top, 0);
+ right = Math.Min(right, target.Width);
+ bottom = Math.Min(bottom, target.Height);
+
+ for (int y = top; y < bottom; y++)
+ {
+ int row = y * target.Width;
+ for (int x = left; x < right; x++)
+ target.Pixels[row + x] = color;
+ }
+ }
+
+ private static void StrokeRectangle(WindowBuffer target, int left, int top, int right, int bottom, int color, int thickness)
+ {
+ Normalize(ref left, ref right);
+ Normalize(ref top, ref bottom);
+
+ for (int i = 0; i < thickness; i++)
+ {
+ DrawLine(target, left, top + i, right, top + i, color, 1);
+ DrawLine(target, left, bottom - 1 - i, right, bottom - 1 - i, color, 1);
+ DrawLine(target, left + i, top, left + i, bottom, color, 1);
+ DrawLine(target, right - 1 - i, top, right - 1 - i, bottom, color, 1);
+ }
+ }
+
+ private static void DrawLine(WindowBuffer target, int x0, int y0, int x1, int y1, int color, int thickness)
+ {
+ int dx = Math.Abs(x1 - x0);
+ int dy = -Math.Abs(y1 - y0);
+ int stepX = x0 < x1 ? 1 : -1;
+ int stepY = y0 < y1 ? 1 : -1;
+ int error = dx + dy;
+ int radius = thickness / 2;
+
+ while (true)
+ {
+ if (thickness <= 1)
+ {
+ SetPixel(target, x0, y0, color);
+ }
+ else
+ {
+ for (int oy = -radius; oy <= radius; oy++)
+ for (int ox = -radius; ox <= radius; ox++)
+ SetPixel(target, x0 + ox, y0 + oy, color);
+ }
+
+ if (x0 == x1 && y0 == y1)
+ return;
+
+ int doubled = error * 2;
+ if (doubled >= dy)
+ {
+ if (x0 == x1)
+ return;
+ error += dy;
+ x0 += stepX;
+ }
+
+ if (doubled <= dx)
+ {
+ if (y0 == y1)
+ return;
+ error += dx;
+ y0 += stepY;
+ }
+ }
+ }
+
+ private static void DrawEllipse(WindowBuffer target, int left, int top, int right, int bottom, bool hasBrush, int fill, bool hasPen, int stroke)
+ {
+ Normalize(ref left, ref right);
+ Normalize(ref top, ref bottom);
+
+ int radiusX = (right - left) / 2;
+ int radiusY = (bottom - top) / 2;
+ if (radiusX <= 0 || radiusY <= 0)
+ return;
+
+ int centerX = left + radiusX;
+ int centerY = top + radiusY;
+ long squaredX = (long)radiusX * radiusX;
+ long squaredY = (long)radiusY * radiusY;
+
+ for (int y = -radiusY; y <= radiusY; y++)
+ {
+ long span = squaredX - ((squaredX * y * y) / squaredY);
+ if (span < 0)
+ continue;
+
+ int half = (int)Math.Sqrt(span);
+
+ if (hasBrush)
+ {
+ for (int x = -half; x <= half; x++)
+ SetPixel(target, centerX + x, centerY + y, fill);
+ }
+
+ if (hasPen)
+ {
+ SetPixel(target, centerX - half, centerY + y, stroke);
+ SetPixel(target, centerX + half, centerY + y, stroke);
+ }
+ }
+ }
+
+ private static void DrawPolyline(WindowBuffer target, GdiPoint[] points, bool close, int color, int thickness)
+ {
+ if (points == null || points.Length < 2)
+ return;
+
+ for (int i = 1; i < points.Length; i++)
+ DrawLine(target, points[i - 1].X, points[i - 1].Y, points[i].X, points[i].Y, color, thickness);
+
+ if (close)
+ DrawLine(target, points[^1].X, points[^1].Y, points[0].X, points[0].Y, color, thickness);
+ }
+
+ private static void Normalize(ref int low, ref int high)
+ {
+ if (low > high)
+ (low, high) = (high, low);
+ }
+ }
+}
diff --git a/Brovan/Android/AndroidGuestWindows.cs b/Brovan/Android/AndroidGuestWindows.cs
new file mode 100644
index 0000000..e80d67f
--- /dev/null
+++ b/Brovan/Android/AndroidGuestWindows.cs
@@ -0,0 +1,79 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using Brovan.Core.Emulation.OS.Windows;
+
+namespace Brovan.Android
+{
+ internal readonly struct GuestWindowInfo
+ {
+ public GuestWindowInfo(ulong hwnd, string title, string className, int width, int height, bool visible)
+ {
+ Hwnd = hwnd;
+ Title = title;
+ ClassName = className;
+ Width = width;
+ Height = height;
+ Visible = visible;
+ }
+
+ public ulong Hwnd { get; }
+
+ public string Title { get; }
+
+ public string ClassName { get; }
+
+ public int Width { get; }
+
+ public int Height { get; }
+
+ public bool Visible { get; }
+ }
+
+ internal static class AndroidGuestWindows
+ {
+ private static ulong _selected;
+
+ public static ulong Selected => Volatile.Read(ref _selected);
+
+ public static void Select(ulong hwnd)
+ {
+ Volatile.Write(ref _selected, hwnd);
+ }
+
+ public static List Enumerate()
+ {
+ List windows = new List();
+
+ WinSysHelper helper = Variables.Emulator?.WinHelper;
+ if (helper == null)
+ return windows;
+
+ // The guest owns this list from its own threads; a snapshot can tear while a window is being
+ // created or destroyed, and an inspector must never be the thing that crashes the emulator.
+ try
+ {
+ foreach (ulong hwnd in helper.TopLevelWindows.ToArray())
+ {
+ WinWindow window = helper.GetWindow(hwnd);
+ if (window == null || window.Destroyed)
+ continue;
+
+ windows.Add(new GuestWindowInfo(
+ window.Hwnd,
+ string.IsNullOrEmpty(window.Title) ? window.ClassName ?? string.Empty : window.Title,
+ window.ClassName ?? string.Empty,
+ (int)window.Width,
+ (int)window.Height,
+ window.Visible));
+ }
+ }
+ catch (Exception exception)
+ {
+ AndroidLog.Write(AndroidNative.LogWarn, $"[brovan] Window enumeration failed: {exception.Message}");
+ }
+
+ return windows;
+ }
+ }
+}
diff --git a/Brovan/Android/AndroidHost.cs b/Brovan/Android/AndroidHost.cs
new file mode 100644
index 0000000..6f973cb
--- /dev/null
+++ b/Brovan/Android/AndroidHost.cs
@@ -0,0 +1,97 @@
+using System;
+using System.Threading;
+
+namespace Brovan.Android
+{
+ internal static class AndroidHost
+ {
+ private const int SurfaceWaitMilliseconds = 30000;
+
+ private static readonly object SurfaceSync = new();
+ private static readonly ManualResetEventSlim SurfaceReady = new(false);
+
+ private static IntPtr _nativeWindow;
+ private static int _width;
+ private static int _height;
+ private static int _densityDpi;
+ private static volatile bool _active;
+ private static volatile string _windowTitle = string.Empty;
+
+ public static bool IsActive => _active;
+
+ public static int Width => Volatile.Read(ref _width);
+
+ public static int Height => Volatile.Read(ref _height);
+
+ public static int DensityDpi => Volatile.Read(ref _densityDpi);
+
+ public static string WindowTitle
+ {
+ get => _windowTitle;
+ set => _windowTitle = value ?? string.Empty;
+ }
+
+ public static IntPtr NativeWindow
+ {
+ get
+ {
+ lock (SurfaceSync)
+ return _nativeWindow;
+ }
+ }
+
+ public static void MarkActive()
+ {
+ _active = true;
+ }
+
+ public static void SetSurface(IntPtr window, int width, int height, int densityDpi)
+ {
+ IntPtr previous;
+
+ lock (SurfaceSync)
+ {
+ previous = _nativeWindow;
+ if (previous == window && window != IntPtr.Zero)
+ {
+ StoreMetrics(window, width, height, densityDpi);
+ return;
+ }
+
+ if (window != IntPtr.Zero)
+ AndroidNative.NativeWindowAcquire(window);
+
+ _nativeWindow = window;
+ StoreMetrics(window, width, height, densityDpi);
+ }
+
+ if (previous != IntPtr.Zero)
+ AndroidNative.NativeWindowRelease(previous);
+
+ if (window != IntPtr.Zero)
+ SurfaceReady.Set();
+ else
+ SurfaceReady.Reset();
+ }
+
+ public static bool WaitForSurface()
+ {
+ return SurfaceReady.Wait(SurfaceWaitMilliseconds);
+ }
+
+ private static void StoreMetrics(IntPtr window, int width, int height, int densityDpi)
+ {
+ if (width <= 0 && window != IntPtr.Zero)
+ width = AndroidNative.NativeWindowGetWidth(window);
+
+ if (height <= 0 && window != IntPtr.Zero)
+ height = AndroidNative.NativeWindowGetHeight(window);
+
+ Volatile.Write(ref _width, width > 0 ? width : 0);
+ Volatile.Write(ref _height, height > 0 ? height : 0);
+
+ if (densityDpi > 0)
+ Volatile.Write(ref _densityDpi, densityDpi);
+ }
+ }
+}
diff --git a/Brovan/Android/AndroidInput.cs b/Brovan/Android/AndroidInput.cs
new file mode 100644
index 0000000..40aae82
--- /dev/null
+++ b/Brovan/Android/AndroidInput.cs
@@ -0,0 +1,138 @@
+using Brovan.Core.Emulation.OS.SharedHelpers;
+
+namespace Brovan.Android
+{
+ internal enum PointerAction
+ {
+ Move = 0,
+ Down = 1,
+ Up = 2,
+ }
+
+ internal enum PointerButton
+ {
+ Left = 0,
+ Middle = 1,
+ Right = 2,
+ }
+
+ internal static class AndroidInput
+ {
+ private const uint WM_SIZE = 0x0005;
+ private const uint WM_SETFOCUS = 0x0007;
+ private const uint WM_KILLFOCUS = 0x0008;
+ private const uint WM_KEYDOWN = 0x0100;
+ private const uint WM_KEYUP = 0x0101;
+ private const uint WM_SYSKEYDOWN = 0x0104;
+ private const uint WM_SYSKEYUP = 0x0105;
+ private const uint WM_MOUSEMOVE = 0x0200;
+ private const uint WM_LBUTTONDOWN = 0x0201;
+ private const uint WM_LBUTTONUP = 0x0202;
+ private const uint WM_RBUTTONDOWN = 0x0204;
+ private const uint WM_RBUTTONUP = 0x0205;
+ private const uint WM_MBUTTONDOWN = 0x0207;
+ private const uint WM_MBUTTONUP = 0x0208;
+ private const uint WM_MOUSEWHEEL = 0x020A;
+
+ private const uint VK_MENU = 0x12;
+
+ private static bool _altHeld;
+
+ public static void Pointer(PointerAction action, PointerButton button, int x, int y, uint buttons)
+ {
+ uint message = action switch
+ {
+ PointerAction.Down => button switch
+ {
+ PointerButton.Middle => WM_MBUTTONDOWN,
+ PointerButton.Right => WM_RBUTTONDOWN,
+ _ => WM_LBUTTONDOWN,
+ },
+ PointerAction.Up => button switch
+ {
+ PointerButton.Middle => WM_MBUTTONUP,
+ PointerButton.Right => WM_RBUTTONUP,
+ _ => WM_LBUTTONUP,
+ },
+ _ => WM_MOUSEMOVE,
+ };
+
+ HostEventQueue.Enqueue(message, buttons, MakeLParam(x, y));
+ }
+
+ public static void Scroll(int delta, int x, int y, uint buttons)
+ {
+ HostEventQueue.Enqueue(WM_MOUSEWHEEL, buttons | ((ulong)(ushort)(short)delta << 16), MakeLParam(x, y));
+ }
+
+ public static void Key(bool down, uint virtualKey, uint scanCode)
+ {
+ if (virtualKey == VK_MENU)
+ _altHeld = down;
+
+ uint message = down
+ ? (_altHeld ? WM_SYSKEYDOWN : WM_KEYDOWN)
+ : (_altHeld ? WM_SYSKEYUP : WM_KEYUP);
+
+ HostEventQueue.Enqueue(message, virtualKey, BuildKeyLParam(scanCode, virtualKey, down, _altHeld));
+ }
+
+ public static void Focus(bool focused)
+ {
+ HostEventQueue.Enqueue(focused ? WM_SETFOCUS : WM_KILLFOCUS, 0, 0);
+ }
+
+ public static void Resize(int width, int height)
+ {
+ HostEventQueue.Enqueue(WM_SIZE, 0, MakeLParam(width, height));
+ HostEventQueue.MarkRepaint();
+ }
+
+ private static ulong MakeLParam(int low, int high)
+ {
+ return (ulong)(uint)(((high & 0xFFFF) << 16) | (low & 0xFFFF));
+ }
+
+ private static ulong BuildKeyLParam(uint scanCode, uint virtualKey, bool down, bool altHeld)
+ {
+ ulong lParam = 1;
+ lParam |= (ulong)(scanCode & 0xFF) << 16;
+
+ if (IsExtendedKey(virtualKey))
+ lParam |= 1UL << 24;
+
+ if (altHeld)
+ lParam |= 1UL << 29;
+
+ if (!down)
+ lParam |= (1UL << 30) | (1UL << 31);
+
+ return lParam;
+ }
+
+ private static bool IsExtendedKey(uint virtualKey)
+ {
+ switch (virtualKey)
+ {
+ case 0x21: // VK_PRIOR
+ case 0x22: // VK_NEXT
+ case 0x23: // VK_END
+ case 0x24: // VK_HOME
+ case 0x25: // VK_LEFT
+ case 0x26: // VK_UP
+ case 0x27: // VK_RIGHT
+ case 0x28: // VK_DOWN
+ case 0x2C: // VK_SNAPSHOT
+ case 0x2D: // VK_INSERT
+ case 0x2E: // VK_DELETE
+ case 0x6F: // VK_DIVIDE
+ case 0x90: // VK_NUMLOCK
+ case 0xA3: // VK_RCONTROL
+ case 0xA5: // VK_RMENU
+ return true;
+ default:
+ return false;
+ }
+ }
+ }
+}
diff --git a/Brovan/Android/AndroidLog.cs b/Brovan/Android/AndroidLog.cs
new file mode 100644
index 0000000..eb52b04
--- /dev/null
+++ b/Brovan/Android/AndroidLog.cs
@@ -0,0 +1,176 @@
+using System;
+using System.Buffers;
+using System.IO;
+using System.Text;
+using System.Threading;
+
+namespace Brovan.Android
+{
+ internal static class AndroidLog
+ {
+ private const string Tag = "Brovan";
+
+ private static IntPtr _sink;
+
+ public static void SetSink(IntPtr sink)
+ {
+ Volatile.Write(ref _sink, sink);
+ }
+
+ public static unsafe void Write(int priority, string line)
+ {
+ if (string.IsNullOrEmpty(line))
+ return;
+
+ try
+ {
+ AndroidNative.LogWrite(priority, Tag, line);
+ }
+ catch (DllNotFoundException)
+ {
+ }
+ catch (EntryPointNotFoundException)
+ {
+ }
+
+ IntPtr sink = Volatile.Read(ref _sink);
+ if (sink == IntPtr.Zero)
+ return;
+
+ int capacity = Encoding.UTF8.GetMaxByteCount(line.Length) + 1;
+ byte[] buffer = ArrayPool.Shared.Rent(capacity);
+ try
+ {
+ int written = Encoding.UTF8.GetBytes(line, buffer);
+ buffer[written] = 0;
+
+ fixed (byte* text = buffer)
+ ((delegate* unmanaged)sink)(text);
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
+ }
+
+ public static unsafe void RedirectStandardStreams()
+ {
+ int* descriptors = stackalloc int[2];
+ if (AndroidNative.Pipe(descriptors) != 0)
+ return;
+
+ int readEnd = descriptors[0];
+ if (AndroidNative.Dup2(descriptors[1], 1) < 0 || AndroidNative.Dup2(descriptors[1], 2) < 0)
+ return;
+
+ Thread pump = new Thread(() => Pump(readEnd))
+ {
+ IsBackground = true,
+ Name = "BrovanStdioLog",
+ };
+
+ pump.Start();
+ }
+
+ private static unsafe void Pump(int readEnd)
+ {
+ byte[] buffer = new byte[4096];
+ StringBuilder line = new StringBuilder();
+
+ while (true)
+ {
+ nint read;
+ fixed (byte* target = buffer)
+ read = Core.Emulation.OS.SharedHelpers.Posix.Read(readEnd, target, (nuint)buffer.Length);
+
+ if (read <= 0)
+ return;
+
+ for (int i = 0; i < read; i++)
+ {
+ char value = (char)buffer[i];
+ if (value == '\r')
+ continue;
+
+ if (value != '\n')
+ {
+ line.Append(value);
+ continue;
+ }
+
+ Write(AndroidNative.LogInfo, line.ToString());
+ line.Clear();
+ }
+ }
+ }
+ }
+
+ internal sealed class AndroidLogWriter : TextWriter
+ {
+ private const int MaximumLineLength = 4000;
+
+ private readonly int _priority;
+ private readonly StringBuilder _pending = new();
+
+ public AndroidLogWriter(int priority)
+ {
+ _priority = priority;
+ }
+
+ public override Encoding Encoding => Encoding.UTF8;
+
+ public override void Write(char value)
+ {
+ lock (_pending)
+ AppendLocked(value);
+ }
+
+ public override void Write(string value)
+ {
+ if (string.IsNullOrEmpty(value))
+ return;
+
+ lock (_pending)
+ {
+ for (int i = 0; i < value.Length; i++)
+ AppendLocked(value[i]);
+ }
+ }
+
+ public override void WriteLine(string value)
+ {
+ Write(value);
+ Write('\n');
+ }
+
+ public override void Flush()
+ {
+ lock (_pending)
+ EmitLocked();
+ }
+
+ private void AppendLocked(char value)
+ {
+ if (value == '\r')
+ return;
+
+ if (value != '\n')
+ {
+ _pending.Append(value);
+ if (_pending.Length < MaximumLineLength)
+ return;
+ }
+
+ EmitLocked();
+ }
+
+ private void EmitLocked()
+ {
+ if (_pending.Length == 0)
+ return;
+
+ AndroidLog.Write(_priority, _pending.ToString());
+ _pending.Clear();
+ }
+ }
+}
diff --git a/Brovan/Android/AndroidNative.cs b/Brovan/Android/AndroidNative.cs
new file mode 100644
index 0000000..6091b23
--- /dev/null
+++ b/Brovan/Android/AndroidNative.cs
@@ -0,0 +1,66 @@
+using System;
+using System.Runtime.InteropServices;
+
+namespace Brovan.Android
+{
+ internal static partial class AndroidNative
+ {
+ public const int LogInfo = 4;
+ public const int LogWarn = 5;
+ public const int LogError = 6;
+
+ [LibraryImport("libandroid.so", EntryPoint = "ANativeWindow_acquire")]
+ public static partial void NativeWindowAcquire(IntPtr window);
+
+ [LibraryImport("libandroid.so", EntryPoint = "ANativeWindow_release")]
+ public static partial void NativeWindowRelease(IntPtr window);
+
+ [LibraryImport("libandroid.so", EntryPoint = "ANativeWindow_getWidth")]
+ public static partial int NativeWindowGetWidth(IntPtr window);
+
+ [LibraryImport("libandroid.so", EntryPoint = "ANativeWindow_getHeight")]
+ public static partial int NativeWindowGetHeight(IntPtr window);
+
+ public const int WindowFormatRgba8888 = 1;
+
+ [StructLayout(LayoutKind.Sequential)]
+ public struct NativeWindowBuffer
+ {
+ public int Width;
+ public int Height;
+ public int Stride;
+ public int Format;
+ public IntPtr Bits;
+ public uint Reserved0;
+ public uint Reserved1;
+ public uint Reserved2;
+ public uint Reserved3;
+ public uint Reserved4;
+ public uint Reserved5;
+ }
+
+ [LibraryImport("libandroid.so", EntryPoint = "ANativeWindow_setBuffersGeometry")]
+ public static partial int NativeWindowSetBuffersGeometry(IntPtr window, int width, int height, int format);
+
+ [LibraryImport("libandroid.so", EntryPoint = "ANativeWindow_lock")]
+ public static unsafe partial int NativeWindowLock(IntPtr window, NativeWindowBuffer* buffer, IntPtr dirtyBounds);
+
+ [LibraryImport("libandroid.so", EntryPoint = "ANativeWindow_unlockAndPost")]
+ public static partial int NativeWindowUnlockAndPost(IntPtr window);
+
+ [LibraryImport("liblog.so", EntryPoint = "__android_log_write", StringMarshalling = StringMarshalling.Utf8)]
+ public static partial int LogWrite(int priority, string tag, string text);
+
+ [LibraryImport("libc", EntryPoint = "pipe", SetLastError = true)]
+ public static unsafe partial int Pipe(int* fds);
+
+ [LibraryImport("libc", EntryPoint = "dup2", SetLastError = true)]
+ public static partial int Dup2(int oldFd, int newFd);
+
+ [LibraryImport("libc", EntryPoint = "realpath", SetLastError = true, StringMarshalling = StringMarshalling.Utf8)]
+ public static partial IntPtr RealPath(string path, IntPtr resolved);
+
+ [LibraryImport("libc", EntryPoint = "free")]
+ public static partial void Free(IntPtr pointer);
+ }
+}
diff --git a/Brovan/Android/AndroidVulkanWsi.cs b/Brovan/Android/AndroidVulkanWsi.cs
new file mode 100644
index 0000000..0be2f12
--- /dev/null
+++ b/Brovan/Android/AndroidVulkanWsi.cs
@@ -0,0 +1,13 @@
+using System;
+using System.Runtime.InteropServices;
+
+namespace Brovan.Android
+{
+ internal static class AndroidVulkanWsi
+ {
+ internal const int VkStructureTypeAndroidSurfaceCreateInfoKHR = 1000008000;
+
+ [DllImport("vulkan-1.dll", EntryPoint = "vkCreateAndroidSurfaceKHR", CallingConvention = CallingConvention.Winapi)]
+ internal static extern int vkCreateAndroidSurfaceKHR(IntPtr instance, IntPtr pCreateInfo, IntPtr pAllocator, IntPtr pSurface);
+ }
+}
diff --git a/Brovan/Android/AndroidWinManager.cs b/Brovan/Android/AndroidWinManager.cs
new file mode 100644
index 0000000..63405e2
--- /dev/null
+++ b/Brovan/Android/AndroidWinManager.cs
@@ -0,0 +1,218 @@
+using System;
+using System.Threading;
+using Brovan.Core.Emulation.OS.SharedHelpers;
+
+namespace Brovan.Android
+{
+ internal sealed class AndroidWinManager : IDisplayConnection, IGdiRenderSupport
+ {
+ private const int EFD_CLOEXEC = 0x80000;
+ private const int EFD_NONBLOCK = 0x800;
+
+ private static AndroidWinManager _current;
+
+ private readonly AndroidGdiSurface _gdi = new();
+
+ private int _wakeFd = -1;
+
+ private AndroidWindow _window;
+ private volatile bool _disposed;
+
+ public AndroidWinManager()
+ {
+ if (!AndroidHost.IsActive)
+ throw new PlatformNotSupportedException("The Android window backend requires brovan_init to have run first.");
+
+ _wakeFd = Posix.EventFd(0, EFD_CLOEXEC | EFD_NONBLOCK);
+ _current = this;
+ }
+
+ public static AndroidWinManager Current => _current;
+
+ public bool IsConnected => !_disposed && AndroidHost.NativeWindow != IntPtr.Zero;
+
+ public IntPtr NativeHandle => AndroidHost.NativeWindow;
+
+ public IWindow CreateWindow(WindowOptions options)
+ {
+ if (_disposed)
+ throw new ObjectDisposedException(nameof(AndroidWinManager));
+
+ options ??= new WindowOptions();
+
+ // The guest asks for its window as soon as it starts, but the Surface only exists once the app's
+ // SurfaceView has been laid out. Handing back a window with no ANativeWindow would let the guest
+ // build a Vulkan surface on a null handle, so block until the app attaches one.
+ if (!AndroidHost.WaitForSurface())
+ throw new PlatformNotSupportedException("No Android surface was attached; the host app must call brovan_set_surface before running a guest that draws.");
+
+ AndroidHost.WindowTitle = options.Title ?? string.Empty;
+ _window = new AndroidWindow(options);
+ return _window;
+ }
+
+ public void PumpEvents()
+ {
+ if (!_disposed)
+ _gdi.Flush();
+ }
+
+ public void ExecuteGdiPrimitive(IntPtr windowHandle, GdiPrimitive primitive)
+ {
+ if (!_disposed)
+ _gdi.Execute(primitive);
+ }
+
+ public void InvalidateSurface()
+ {
+ if (!_disposed)
+ _gdi.Invalidate();
+ }
+
+ public unsafe void WaitForEvents(int timeoutMilliseconds)
+ {
+ int wakeFd = Volatile.Read(ref _wakeFd);
+ if (wakeFd < 0)
+ return;
+
+ Posix.PollFd descriptor;
+ descriptor.Fd = wakeFd;
+ descriptor.Events = Posix.POLLIN;
+ descriptor.RevEvents = 0;
+
+ if (Posix.Poll(&descriptor, 1, timeoutMilliseconds) <= 0)
+ return;
+
+ if ((descriptor.RevEvents & Posix.POLLIN) != 0)
+ {
+ ulong drained;
+ Posix.Read(wakeFd, &drained, sizeof(ulong));
+ }
+ }
+
+ public unsafe void Wake()
+ {
+ int wakeFd = Volatile.Read(ref _wakeFd);
+ if (wakeFd < 0)
+ return;
+
+ ulong token = 1;
+ Posix.Write(wakeFd, &token, sizeof(ulong));
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+
+ _disposed = true;
+ _window?.Dispose();
+ _window = null;
+
+ if (ReferenceEquals(_current, this))
+ _current = null;
+ }
+
+ private sealed class AndroidWindow : IWindow
+ {
+ private readonly bool _resizable;
+
+ private bool _disposed;
+ private string _title;
+ private bool _visible;
+ private bool _decorated;
+ private WindowState _state;
+
+ internal AndroidWindow(WindowOptions options)
+ {
+ _title = options.Title ?? string.Empty;
+ _visible = options.Visible;
+ _decorated = options.Decorated;
+ _resizable = options.Resizable;
+ _state = options.State;
+ }
+
+ public string Title
+ {
+ get => _title;
+ set
+ {
+ EnsureAlive();
+ _title = value ?? string.Empty;
+ AndroidHost.WindowTitle = _title;
+ }
+ }
+
+ // The Surface is sized by the app and the compositor, so the guest can read the real dimensions
+ // but cannot drive them; a resize request is accepted and ignored rather than failed, because
+ // guests routinely size their window and carry on regardless of the result.
+ public int Width
+ {
+ get => AndroidHost.Width;
+ set { }
+ }
+
+ public int Height
+ {
+ get => AndroidHost.Height;
+ set { }
+ }
+
+ public bool Visible
+ {
+ get => _visible;
+ set
+ {
+ EnsureAlive();
+ _visible = value;
+ }
+ }
+
+ public WindowState State
+ {
+ get => _state;
+ set
+ {
+ EnsureAlive();
+ _state = value;
+ }
+ }
+
+ public bool Resizable => _resizable;
+
+ public bool Decorated
+ {
+ get => _decorated;
+ set
+ {
+ EnsureAlive();
+ _decorated = value;
+ }
+ }
+
+ public IntPtr NativeHandle => AndroidHost.NativeWindow;
+
+ // Nothing to apply: the host window is the app's Surface, which the app owns.
+ public void Present()
+ {
+ }
+
+ public void Show() => Visible = true;
+
+ public void Hide() => Visible = false;
+
+ public void Close() => Dispose();
+
+ public void Dispose()
+ {
+ _disposed = true;
+ }
+
+ private void EnsureAlive()
+ {
+ if (_disposed)
+ throw new ObjectDisposedException(nameof(AndroidWindow));
+ }
+ }
+ }
+}
diff --git a/Brovan/Android/BrovanAndroidApi.cs b/Brovan/Android/BrovanAndroidApi.cs
new file mode 100644
index 0000000..7654e29
--- /dev/null
+++ b/Brovan/Android/BrovanAndroidApi.cs
@@ -0,0 +1,399 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Threading;
+using Brovan.Core.Emulation;
+using Brovan.Core.Emulation.OS.SharedHelpers;
+using Brovan.Core.Helpers;
+
+namespace Brovan.Android
+{
+ internal static unsafe class BrovanAndroidApi
+ {
+ public const int StatusOk = 0;
+ public const int StatusNotInitialized = -1;
+ public const int StatusAlreadyRunning = -2;
+ public const int StatusInvalidArgument = -3;
+ public const int StatusMissingWindowsLibs = -4;
+ public const int StatusMissingRegistry = -5;
+ public const int StatusApiSetMapFailed = -6;
+ public const int StatusBinaryNotFound = -7;
+ public const int StatusFailed = -8;
+
+ private static int _initialized;
+ private static int _running;
+ private static bool _verbose;
+ private static IntPtr _exitSink;
+
+ [UnmanagedCallersOnly(EntryPoint = "brovan_init")]
+ public static int Init(byte* baseDirectory)
+ {
+ if (Interlocked.Exchange(ref _initialized, 1) != 0)
+ return StatusOk;
+
+ try
+ {
+ string directory = Marshal.PtrToStringUTF8((IntPtr)baseDirectory);
+ if (string.IsNullOrWhiteSpace(directory))
+ return StatusInvalidArgument;
+
+ Directory.CreateDirectory(directory);
+
+ // getFilesDir() returns /data/user/0/, but /data/user/0 symlinks to /data/data.
+ // The IO sandbox resolves the symlink, causing paths under WindowsLibs and VirtualFS
+ // to fall outside the allowed root, so the guest fails to load DLLs.
+ directory = Canonicalize(directory);
+
+ if (!directory.EndsWith(Path.DirectorySeparatorChar))
+ directory += Path.DirectorySeparatorChar;
+
+ // Every path in the emulator is derived from AppContext.BaseDirectory, and several of those
+ // are static field initializers, so this has to land before anything else is touched.
+ AppContext.SetData("APP_CONTEXT_BASE_DIRECTORY", directory);
+
+ AndroidHost.MarkActive();
+
+ AndroidLog.RedirectStandardStreams();
+ Console.SetOut(new AndroidLogWriter(AndroidNative.LogInfo));
+ Console.SetError(new AndroidLogWriter(AndroidNative.LogError));
+
+ AppDomain.CurrentDomain.UnhandledException += (sender, e) =>
+ {
+ Exception exception = (Exception)e.ExceptionObject;
+ Utils.LogError($"[Global Unhandled Exception]: {exception.Message}\nStack Trace:\n\n{exception.StackTrace}");
+ AndroidLog.Write(AndroidNative.LogError, $"[Global Unhandled Exception]: {exception.Message}");
+ };
+
+ NativeLibraryResolver.Register();
+ return StatusOk;
+ }
+ catch (Exception exception)
+ {
+ AndroidLog.Write(AndroidNative.LogError, $"[brovan_init] {exception}");
+ Volatile.Write(ref _initialized, 0);
+ return StatusFailed;
+ }
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "brovan_set_log_sink")]
+ public static void SetLogSink(IntPtr sink) => AndroidLog.SetSink(sink);
+
+ [UnmanagedCallersOnly(EntryPoint = "brovan_set_exit_sink")]
+ public static void SetExitSink(IntPtr sink) => Volatile.Write(ref _exitSink, sink);
+
+ [UnmanagedCallersOnly(EntryPoint = "brovan_set_verbose")]
+ public static void SetVerbose(int enabled) => _verbose = enabled != 0;
+
+ [UnmanagedCallersOnly(EntryPoint = "brovan_set_surface")]
+ public static void SetSurface(IntPtr nativeWindow, int width, int height, int densityDpi)
+ {
+ Guard(() =>
+ {
+ AndroidHost.SetSurface(nativeWindow, width, height, densityDpi);
+ HostDisplayMetrics.Invalidate();
+
+ if (nativeWindow != IntPtr.Zero)
+ AndroidInput.Resize(AndroidHost.Width, AndroidHost.Height);
+ }, nameof(SetSurface));
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "brovan_clear_surface")]
+ public static void ClearSurface()
+ {
+ Guard(() => AndroidHost.SetSurface(IntPtr.Zero, 0, 0, 0), nameof(ClearSurface));
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "brovan_start")]
+ public static int Start(byte* binaryPath, byte* guestCommandLine, byte* workingDirectory, byte* commands, int backend, int networkMode)
+ {
+ if (Volatile.Read(ref _initialized) == 0)
+ return StatusNotInitialized;
+
+ if (Interlocked.Exchange(ref _running, 1) != 0)
+ return StatusAlreadyRunning;
+
+ try
+ {
+ string path = Marshal.PtrToStringUTF8((IntPtr)binaryPath);
+ int validation = ValidateEnvironment(path);
+ if (validation != StatusOk)
+ {
+ Volatile.Write(ref _running, 0);
+ return validation;
+ }
+
+ string rawArguments = Marshal.PtrToStringUTF8((IntPtr)guestCommandLine);
+ string directory = Marshal.PtrToStringUTF8((IntPtr)workingDirectory);
+ string command = Marshal.PtrToStringUTF8((IntPtr)commands);
+ string[] arguments = string.IsNullOrEmpty(rawArguments)
+ ? Array.Empty()
+ : Program.SplitCommandLine(rawArguments);
+
+ EmulationBackendKind backendKind = backend switch
+ {
+ 1 => EmulationBackendKind.Kvm,
+ 2 => EmulationBackendKind.Whp,
+ _ => EmulationBackendKind.Unicorn,
+ };
+
+ NetworkAccessMode mode = networkMode switch
+ {
+ 0 => NetworkAccessMode.None,
+ 2 => NetworkAccessMode.Full,
+ _ => NetworkAccessMode.Loopback,
+ };
+
+ Thread guestThread = new Thread(() =>
+ RunGuest(path, rawArguments, arguments, directory, command, backendKind, new NetworkAccessPolicy(mode)))
+ {
+ IsBackground = false,
+ Name = "BrovanGuestMain",
+ };
+
+ guestThread.Start();
+ return StatusOk;
+ }
+ catch (Exception exception)
+ {
+ AndroidLog.Write(AndroidNative.LogError, $"[brovan_start] {exception}");
+ Volatile.Write(ref _running, 0);
+ return StatusFailed;
+ }
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "brovan_is_running")]
+ public static int IsRunning() => Volatile.Read(ref _running);
+
+ [UnmanagedCallersOnly(EntryPoint = "brovan_send_command")]
+ public static void SendCommand(byte* command)
+ {
+ Guard(() =>
+ {
+ // LogError buffers and only flushes every N writes; the CLI gets away with it because the
+ // process exits, but an app process keeps the buffer alive forever and error_log.log stays
+ // empty exactly when something has gone wrong.
+ Utils.FlushLog();
+ CommandReader.Instance.Post(Marshal.PtrToStringUTF8((IntPtr)command));
+ }, nameof(SendCommand));
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "brovan_request_close")]
+ public static void RequestClose() => Guard(HostEventQueue.RequestClose, nameof(RequestClose));
+
+ [UnmanagedCallersOnly(EntryPoint = "brovan_request_repaint")]
+ public static void RequestRepaint() => Guard(HostEventQueue.MarkRepaint, nameof(RequestRepaint));
+
+ [UnmanagedCallersOnly(EntryPoint = "brovan_inject_pointer")]
+ public static void InjectPointer(int action, int button, int x, int y, int buttons)
+ {
+ Guard(() => AndroidInput.Pointer((PointerAction)action, (PointerButton)button, x, y, (uint)buttons), nameof(InjectPointer));
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "brovan_inject_scroll")]
+ public static void InjectScroll(int delta, int x, int y, int buttons)
+ {
+ Guard(() => AndroidInput.Scroll(delta, x, y, (uint)buttons), nameof(InjectScroll));
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "brovan_inject_key")]
+ public static void InjectKey(int down, int virtualKey, int scanCode)
+ {
+ Guard(() => AndroidInput.Key(down != 0, (uint)virtualKey, (uint)scanCode), nameof(InjectKey));
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "brovan_inject_focus")]
+ public static void InjectFocus(int focused)
+ {
+ Guard(() => AndroidInput.Focus(focused != 0), nameof(InjectFocus));
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "brovan_list_windows")]
+ public static int ListWindows(byte* buffer, int capacity)
+ {
+ if (buffer == null || capacity <= 0)
+ return StatusInvalidArgument;
+
+ try
+ {
+ List windows = AndroidGuestWindows.Enumerate();
+ StringBuilder text = new StringBuilder();
+
+ foreach (GuestWindowInfo window in windows)
+ {
+ text.Append(window.Hwnd).Append('|')
+ .Append(window.Width).Append('|')
+ .Append(window.Height).Append('|')
+ .Append(window.Visible ? 1 : 0).Append('|')
+ .Append(window.Title.Replace('|', ' ').Replace('\n', ' '))
+ .Append('\n');
+ }
+
+ return WriteUtf8(text.ToString(), buffer, capacity);
+ }
+ catch (Exception exception)
+ {
+ AndroidLog.Write(AndroidNative.LogError, $"[brovan_list_windows] {exception}");
+ return StatusFailed;
+ }
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "brovan_select_window")]
+ public static void SelectWindow(ulong hwnd)
+ {
+ Guard(() =>
+ {
+ AndroidGuestWindows.Select(hwnd);
+ AndroidWinManager.Current?.InvalidateSurface();
+ HostEventQueue.MarkRepaint();
+ }, nameof(SelectWindow));
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "brovan_get_window_title")]
+ public static int GetWindowTitle(byte* buffer, int capacity)
+ {
+ if (buffer == null || capacity <= 0)
+ return StatusInvalidArgument;
+
+ return WriteUtf8(AndroidHost.WindowTitle, buffer, capacity);
+ }
+
+ private static int ValidateEnvironment(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ return StatusInvalidArgument;
+
+ if (!File.Exists(path))
+ return StatusBinaryNotFound;
+
+ if (!Directory.Exists(GeneralHelper.WindowsLibsPath))
+ return StatusMissingWindowsLibs;
+
+ if (!Directory.Exists(Path.Combine(AppContext.BaseDirectory, "WinReg")))
+ return StatusMissingRegistry;
+
+ if (!File.Exists(BinaryEmulator.ApiSetMapPath) && !TryGenerateApiSetMap())
+ return StatusApiSetMapFailed;
+
+ return StatusOk;
+ }
+
+ private static void RunGuest(string path, string rawArguments, string[] arguments, string workingDirectory, string command, EmulationBackendKind backend, NetworkAccessPolicy policy)
+ {
+ int reason = 0;
+ try
+ {
+ if (_verbose)
+ {
+ // The verbose path runs the guest only if it is handed a command, then falls into a
+ // Console.ReadLine loop. An app process has no stdin, so an unparked reader would spin
+ // returning null forever.
+ Console.SetIn(CommandReader.Instance);
+ EmulationMenu.EmulationMenu.RunEmulator(path, true, false,
+ string.IsNullOrEmpty(command) ? "start" : command,
+ rawArguments, arguments, policy, false, backend, workingDirectory);
+ }
+ else
+ {
+ EmulationMenu.EmulationMenu.RunEmulator(path, true, true, command,
+ rawArguments, arguments, policy, false, backend, workingDirectory);
+ }
+ }
+ catch (Exception exception)
+ {
+ reason = 1;
+ AndroidLog.Write(AndroidNative.LogError, $"[brovan] Guest terminated with an exception: {exception}");
+ }
+ finally
+ {
+ Utils.FlushLog();
+ Volatile.Write(ref _running, 0);
+ NotifyExit(reason);
+ }
+ }
+
+ private static void NotifyExit(int reason)
+ {
+ IntPtr sink = Volatile.Read(ref _exitSink);
+ if (sink == IntPtr.Zero)
+ return;
+
+ ((delegate* unmanaged)sink)(reason);
+ }
+
+ private static bool TryGenerateApiSetMap()
+ {
+ try
+ {
+ File.WriteAllBytes(BinaryEmulator.ApiSetMapPath, CrossGenerator.GenerateMap());
+ return true;
+ }
+ catch (Exception exception)
+ {
+ AndroidLog.Write(AndroidNative.LogError, $"[brovan_start] ApiSetMap generation failed: {exception.Message}");
+ return false;
+ }
+ }
+
+ private static string Canonicalize(string directory)
+ {
+ IntPtr resolved = AndroidNative.RealPath(directory, IntPtr.Zero);
+ if (resolved == IntPtr.Zero)
+ return directory;
+
+ try
+ {
+ return Marshal.PtrToStringUTF8(resolved) ?? directory;
+ }
+ finally
+ {
+ AndroidNative.Free(resolved);
+ }
+ }
+
+ private static int WriteUtf8(string value, byte* buffer, int capacity)
+ {
+ Span destination = new Span(buffer, capacity);
+ destination[0] = 0;
+
+ value ??= string.Empty;
+ if (Encoding.UTF8.GetByteCount(value) + 1 > capacity)
+ return StatusInvalidArgument;
+
+ int written = Encoding.UTF8.GetBytes(value.AsSpan(), destination);
+ destination[written] = 0;
+ return written;
+ }
+
+ private static void Guard(Action action, string name)
+ {
+ try
+ {
+ action();
+ }
+ catch (Exception exception)
+ {
+ AndroidLog.Write(AndroidNative.LogError, $"[brovan_{name}] {exception}");
+ }
+ }
+
+ private sealed class CommandReader : TextReader
+ {
+ public static readonly CommandReader Instance = new CommandReader();
+
+ private readonly BlockingCollection _pending = new BlockingCollection();
+
+ public void Post(string command)
+ {
+ if (!string.IsNullOrEmpty(command))
+ _pending.Add(command);
+ }
+
+ public override string ReadLine() => _pending.Take();
+
+ public override int Read() => -1;
+ }
+ }
+}
diff --git a/Brovan/Android/android-link.targets b/Brovan/Android/android-link.targets
new file mode 100644
index 0000000..63b201f
--- /dev/null
+++ b/Brovan/Android/android-link.targets
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Brovan/Android/app/brovan/build.gradle b/Brovan/Android/app/brovan/build.gradle
new file mode 100644
index 0000000..9dff27e
--- /dev/null
+++ b/Brovan/Android/app/brovan/build.gradle
@@ -0,0 +1,73 @@
+plugins {
+ id 'com.android.application'
+}
+
+android {
+ namespace 'dev.brovan.app'
+ compileSdk 34
+ ndkVersion '26.3.11579264'
+
+ defaultConfig {
+ applicationId 'dev.brovan.app'
+ minSdk 26
+ targetSdk 34
+ versionCode 1
+ versionName '1.0'
+
+ ndk {
+ abiFilters 'arm64-v8a'
+ }
+
+ externalNativeBuild {
+ cmake {
+ arguments "-DBROVAN_LIB_DIR=${projectDir}/src/main/jniLibs/arm64-v8a"
+ }
+ }
+ }
+
+ externalNativeBuild {
+ cmake {
+ path '../../jni/CMakeLists.txt'
+ }
+ }
+
+ sourceSets {
+ main {
+ java.srcDirs += ['../../java']
+ jniLibs.srcDirs = ['src/main/jniLibs']
+ }
+ }
+
+ packaging {
+ jniLibs {
+ // libBrovan.so and libunicorn.so are opened by name at runtime, so keep them on disk.
+ useLegacyPackaging true
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
+ }
+
+ buildFeatures {
+ viewBinding true
+ }
+
+ buildTypes {
+ debug {
+ debuggable true
+ jniDebuggable true
+ }
+ release {
+ minifyEnabled false
+ }
+ }
+}
+
+dependencies {
+ implementation 'com.google.android.material:material:1.12.0'
+ implementation 'androidx.recyclerview:recyclerview:1.3.2'
+ implementation 'androidx.documentfile:documentfile:1.0.1'
+ implementation 'androidx.preference:preference:1.2.1'
+}
diff --git a/Brovan/Android/app/brovan/src/main/AndroidManifest.xml b/Brovan/Android/app/brovan/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..35c8bd1
--- /dev/null
+++ b/Brovan/Android/app/brovan/src/main/AndroidManifest.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Brovan/Android/app/brovan/src/main/java/dev/brovan/app/Library.java b/Brovan/Android/app/brovan/src/main/java/dev/brovan/app/Library.java
new file mode 100644
index 0000000..ae50793
--- /dev/null
+++ b/Brovan/Android/app/brovan/src/main/java/dev/brovan/app/Library.java
@@ -0,0 +1,227 @@
+package dev.brovan.app;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.documentfile.provider.DocumentFile;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Properties;
+
+/**
+ * Imported programs, each stored in its own folder under files/programs so a program keeps the assets it
+ * shipped with and can open them by relative path.
+ */
+final class Library {
+
+ private static final String ROOT = "programs";
+ private static final String MANIFEST = "program.properties";
+ private static final String KEY_NAME = "name";
+ private static final String KEY_EXECUTABLE = "executable";
+
+ private final File root;
+
+ Library(Context context) {
+ root = new File(context.getFilesDir(), ROOT);
+ }
+
+ List list() {
+ List programs = new ArrayList<>();
+ File[] directories = root.listFiles(File::isDirectory);
+ if (directories == null) {
+ return programs;
+ }
+
+ for (File directory : directories) {
+ Program program = read(directory);
+ if (program != null) {
+ programs.add(program);
+ }
+ }
+
+ programs.sort(Comparator.comparing(program -> program.name().toLowerCase(Locale.ROOT)));
+ return programs;
+ }
+
+ /** Copies a whole folder, then reports the executables it contains so the caller can pick the entry. */
+ ImportResult importFolder(Context context, Uri treeUri) throws IOException {
+ DocumentFile source = DocumentFile.fromTreeUri(context, treeUri);
+ if (source == null || !source.isDirectory()) {
+ throw new IOException("That is not a folder.");
+ }
+
+ File destination = allocate(source.getName());
+ copyTree(context, source, destination);
+ return new ImportResult(destination, findExecutables(destination));
+ }
+
+ ImportResult importExecutable(Context context, Uri fileUri) throws IOException {
+ DocumentFile source = DocumentFile.fromSingleUri(context, fileUri);
+ if (source == null || !source.isFile()) {
+ throw new IOException("That is not a file.");
+ }
+
+ String fileName = source.getName();
+ if (fileName == null) {
+ throw new IOException("That file has no name.");
+ }
+
+ File destination = allocate(stripExtension(fileName));
+ if (!destination.mkdirs() && !destination.isDirectory()) {
+ throw new IOException("Could not create " + destination);
+ }
+
+ copyFile(context, source.getUri(), new File(destination, fileName));
+ return new ImportResult(destination, findExecutables(destination));
+ }
+
+ void commit(File directory, String executableRelativePath) throws IOException {
+ Properties manifest = new Properties();
+ manifest.setProperty(KEY_NAME, directory.getName());
+ manifest.setProperty(KEY_EXECUTABLE, executableRelativePath);
+
+ try (OutputStream stream = new FileOutputStream(new File(directory, MANIFEST))) {
+ manifest.store(stream, null);
+ }
+ }
+
+ void remove(Program program) {
+ delete(program.directory());
+ }
+
+ void discard(File directory) {
+ delete(directory);
+ }
+
+ private Program read(File directory) {
+ File manifest = new File(directory, MANIFEST);
+ if (!manifest.isFile()) {
+ return null;
+ }
+
+ Properties properties = new Properties();
+ try (InputStream stream = new java.io.FileInputStream(manifest)) {
+ properties.load(stream);
+ } catch (IOException failure) {
+ return null;
+ }
+
+ String executable = properties.getProperty(KEY_EXECUTABLE);
+ if (executable == null || !new File(directory, executable).isFile()) {
+ return null;
+ }
+
+ return new Program(directory, properties.getProperty(KEY_NAME, directory.getName()), executable);
+ }
+
+ private File allocate(String preferredName) {
+ String base = sanitize(preferredName);
+ File candidate = new File(root, base);
+
+ for (int suffix = 2; candidate.exists(); suffix++) {
+ candidate = new File(root, base + " (" + suffix + ")");
+ }
+
+ return candidate;
+ }
+
+ private static void copyTree(Context context, DocumentFile source, File destination) throws IOException {
+ if (!destination.mkdirs() && !destination.isDirectory()) {
+ throw new IOException("Could not create " + destination);
+ }
+
+ for (DocumentFile child : source.listFiles()) {
+ String name = child.getName();
+ if (name == null) {
+ continue;
+ }
+
+ File target = new File(destination, name);
+ if (child.isDirectory()) {
+ copyTree(context, child, target);
+ } else {
+ copyFile(context, child.getUri(), target);
+ }
+ }
+ }
+
+ private static void copyFile(Context context, Uri source, File destination) throws IOException {
+ try (InputStream input = context.getContentResolver().openInputStream(source);
+ OutputStream output = new FileOutputStream(destination)) {
+ if (input == null) {
+ throw new IOException("Could not read " + source);
+ }
+
+ byte[] buffer = new byte[64 * 1024];
+ int read;
+ while ((read = input.read(buffer)) > 0) {
+ output.write(buffer, 0, read);
+ }
+ }
+ }
+
+ private static List findExecutables(File directory) {
+ List executables = new ArrayList<>();
+ collectExecutables(directory, "", executables);
+ executables.sort(Comparator.naturalOrder());
+ return executables;
+ }
+
+ private static void collectExecutables(File directory, String prefix, List into) {
+ File[] entries = directory.listFiles();
+ if (entries == null) {
+ return;
+ }
+
+ for (File entry : entries) {
+ String relative = prefix.isEmpty() ? entry.getName() : prefix + "/" + entry.getName();
+ if (entry.isDirectory()) {
+ collectExecutables(entry, relative, into);
+ } else if (entry.getName().toLowerCase(Locale.ROOT).endsWith(".exe")) {
+ into.add(relative);
+ }
+ }
+ }
+
+ private static String sanitize(String name) {
+ if (name == null || name.trim().isEmpty()) {
+ return "Program";
+ }
+
+ return name.replaceAll("[^A-Za-z0-9 ._-]", "_").trim();
+ }
+
+ private static String stripExtension(String name) {
+ int dot = name.lastIndexOf('.');
+ return dot > 0 ? name.substring(0, dot) : name;
+ }
+
+ private static void delete(File file) {
+ File[] children = file.listFiles();
+ if (children != null) {
+ for (File child : children) {
+ delete(child);
+ }
+ }
+
+ file.delete();
+ }
+
+ static final class ImportResult {
+ final File directory;
+ final List executables;
+
+ ImportResult(File directory, List executables) {
+ this.directory = directory;
+ this.executables = executables;
+ }
+ }
+}
diff --git a/Brovan/Android/app/brovan/src/main/java/dev/brovan/app/MainActivity.java b/Brovan/Android/app/brovan/src/main/java/dev/brovan/app/MainActivity.java
new file mode 100644
index 0000000..db9dd03
--- /dev/null
+++ b/Brovan/Android/app/brovan/src/main/java/dev/brovan/app/MainActivity.java
@@ -0,0 +1,299 @@
+package dev.brovan.app;
+
+import android.content.ActivityNotFoundException;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.ArrayAdapter;
+import android.widget.FrameLayout;
+import android.widget.LinearLayout;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AlertDialog;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.drawerlayout.widget.DrawerLayout;
+import androidx.recyclerview.widget.GridLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.android.material.appbar.MaterialToolbar;
+import com.google.android.material.button.MaterialButton;
+import com.google.android.material.materialswitch.MaterialSwitch;
+import com.google.android.material.navigation.NavigationView;
+import com.google.android.material.progressindicator.CircularProgressIndicator;
+import com.google.android.material.snackbar.Snackbar;
+import com.google.android.material.textfield.MaterialAutoCompleteTextView;
+
+import java.io.File;
+
+import dev.brovan.input.ControlOverlay;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+public class MainActivity extends AppCompatActivity {
+
+ private static final int REQUEST_FOLDER = 1;
+ private static final int REQUEST_FILE = 2;
+
+ private final ExecutorService worker = Executors.newSingleThreadExecutor();
+
+ private Library library;
+ private Settings settings;
+ private DrawerLayout drawer;
+ private FrameLayout content;
+ private MaterialToolbar toolbar;
+ private ProgramAdapter adapter;
+ private View emptyState;
+ private CircularProgressIndicator progress;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_main);
+
+ library = new Library(this);
+ settings = new Settings(this);
+
+ drawer = findViewById(R.id.drawer);
+ content = findViewById(R.id.content);
+ toolbar = findViewById(R.id.toolbar);
+ toolbar.setNavigationOnClickListener(view -> drawer.open());
+
+ NavigationView navigation = findViewById(R.id.navigation);
+ navigation.setNavigationItemSelectedListener(item -> {
+ show(item.getItemId());
+ drawer.close();
+ return true;
+ });
+ navigation.setCheckedItem(R.id.nav_library);
+
+ show(R.id.nav_library);
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ worker.shutdownNow();
+ }
+
+ private void show(int itemId) {
+ content.removeAllViews();
+
+ if (itemId == R.id.nav_settings) {
+ toolbar.setTitle(R.string.nav_settings);
+ content.addView(createSettings());
+ } else if (itemId == R.id.nav_about) {
+ toolbar.setTitle(R.string.nav_about);
+ content.addView(createAbout());
+ } else {
+ toolbar.setTitle(R.string.nav_library);
+ content.addView(createLibrary());
+ }
+ }
+
+ private View createLibrary() {
+ View view = LayoutInflater.from(this).inflate(R.layout.screen_library, content, false);
+
+ emptyState = view.findViewById(R.id.empty);
+ progress = view.findViewById(R.id.progress);
+
+ RecyclerView list = view.findViewById(R.id.apps);
+ list.setLayoutManager(new GridLayoutManager(this, columnCount()));
+ adapter = new ProgramAdapter(new ProgramAdapter.Listener() {
+ @Override
+ public void onLaunch(Program program) {
+ launch(program);
+ }
+
+ @Override
+ public void onLongPress(Program program) {
+ confirmRemoval(program);
+ }
+ });
+ list.setAdapter(adapter);
+
+ view.findViewById(R.id.add).setOnClickListener(this::showAddOptions);
+
+ refresh();
+ return view;
+ }
+
+ private int columnCount() {
+ int widthDp = (int) (getResources().getDisplayMetrics().widthPixels
+ / getResources().getDisplayMetrics().density);
+ return Math.max(2, widthDp / 190);
+ }
+
+ private void refresh() {
+ List programs = library.list();
+ adapter.submit(programs);
+ emptyState.setVisibility(programs.isEmpty() ? View.VISIBLE : View.GONE);
+ }
+
+ private void showAddOptions(View anchor) {
+ new AlertDialog.Builder(this)
+ .setTitle(R.string.library_add_title)
+ .setItems(new CharSequence[]{
+ getString(R.string.library_add_folder),
+ getString(R.string.library_add_file)}, (dialog, index) -> {
+ if (index == 0) {
+ pick(new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE), REQUEST_FOLDER);
+ } else {
+ Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT)
+ .addCategory(Intent.CATEGORY_OPENABLE)
+ .setType("*/*");
+ pick(intent, REQUEST_FILE);
+ }
+ })
+ .show();
+ }
+
+ private void pick(Intent intent, int requestCode) {
+ try {
+ startActivityForResult(intent, requestCode);
+ } catch (ActivityNotFoundException missing) {
+ snack("No file picker is available on this device.");
+ }
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+
+ if (resultCode != RESULT_OK || data == null || data.getData() == null) {
+ return;
+ }
+
+ Uri uri = data.getData();
+ boolean folder = requestCode == REQUEST_FOLDER;
+ if (!folder && requestCode != REQUEST_FILE) {
+ return;
+ }
+
+ progress.setVisibility(View.VISIBLE);
+ worker.execute(() -> {
+ try {
+ Library.ImportResult result = folder
+ ? library.importFolder(this, uri)
+ : library.importExecutable(this, uri);
+
+ runOnUiThread(() -> {
+ progress.setVisibility(View.GONE);
+ finishImport(result);
+ });
+ } catch (Exception failure) {
+ runOnUiThread(() -> {
+ progress.setVisibility(View.GONE);
+ snack("Import failed: " + failure.getMessage());
+ });
+ }
+ });
+ }
+
+ private void finishImport(Library.ImportResult result) {
+ if (result.executables.isEmpty()) {
+ library.discard(result.directory);
+ snack("No .exe found in that folder.");
+ return;
+ }
+
+ if (result.executables.size() == 1) {
+ commit(result.directory, result.executables.get(0));
+ return;
+ }
+
+ CharSequence[] options = result.executables.toArray(new CharSequence[0]);
+ new AlertDialog.Builder(this)
+ .setTitle(R.string.library_pick_executable)
+ .setItems(options, (dialog, index) -> commit(result.directory, result.executables.get(index)))
+ .setOnCancelListener(dialog -> library.discard(result.directory))
+ .show();
+ }
+
+ private void commit(File directory, String executable) {
+ try {
+ library.commit(directory, executable);
+ refresh();
+ } catch (Exception failure) {
+ snack("Could not save: " + failure.getMessage());
+ }
+ }
+
+ private void confirmRemoval(Program program) {
+ new AlertDialog.Builder(this)
+ .setTitle(program.name())
+ .setItems(new CharSequence[]{getString(R.string.library_remove)}, (dialog, index) -> {
+ library.remove(program);
+ refresh();
+ })
+ .show();
+ }
+
+ private void launch(Program program) {
+ startActivity(PlayerActivity.intentFor(this, program, settings));
+ }
+
+ private View createSettings() {
+ View view = LayoutInflater.from(this).inflate(R.layout.screen_settings, content, false);
+
+ MaterialAutoCompleteTextView backend = view.findViewById(R.id.backend);
+ backend.setAdapter(new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, Settings.BACKENDS));
+ backend.setText(Settings.BACKENDS[settings.backend()], false);
+ backend.setOnItemClickListener((parent, item, position, id) -> settings.setBackend(position));
+
+ MaterialAutoCompleteTextView network = view.findViewById(R.id.network);
+ network.setAdapter(new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, Settings.NETWORK_MODES));
+ network.setText(Settings.NETWORK_MODES[settings.network()], false);
+ network.setOnItemClickListener((parent, item, position, id) -> settings.setNetwork(position));
+
+ ControlOverlay.Scheme[] schemes = ControlOverlay.Scheme.values();
+ String[] schemeLabels = new String[schemes.length];
+ for (int i = 0; i < schemes.length; i++) {
+ schemeLabels[i] = schemes[i].label();
+ }
+
+ MaterialAutoCompleteTextView controls = view.findViewById(R.id.controls);
+ controls.setAdapter(new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, schemeLabels));
+ controls.setText(schemeLabels[settings.controlScheme()], false);
+ controls.setOnItemClickListener((parent, item, position, id) -> settings.setControlScheme(position));
+
+ MaterialSwitch developer = view.findViewById(R.id.developer);
+ developer.setChecked(settings.developerMode());
+ developer.setOnCheckedChangeListener((button, checked) -> settings.setDeveloperMode(checked));
+
+ MaterialSwitch fit = view.findViewById(R.id.keep_aspect);
+ fit.setChecked(settings.fitWindow());
+ fit.setOnCheckedChangeListener((button, checked) -> settings.setFitWindow(checked));
+
+ return view;
+ }
+
+ private View createAbout() {
+ View view = LayoutInflater.from(this).inflate(R.layout.screen_about, content, false);
+ MaterialButton open = view.findViewById(R.id.open_github);
+ open.setOnClickListener(button -> {
+ try {
+ startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.about_github))));
+ } catch (ActivityNotFoundException missing) {
+ snack(getString(R.string.about_github));
+ }
+ });
+ return view;
+ }
+
+ private void snack(String message) {
+ Snackbar.make(content, message, Snackbar.LENGTH_LONG).show();
+ }
+
+ @Override
+ public void onBackPressed() {
+ if (drawer.isOpen()) {
+ drawer.close();
+ return;
+ }
+
+ super.onBackPressed();
+ }
+}
diff --git a/Brovan/Android/app/brovan/src/main/java/dev/brovan/app/PlayerActivity.java b/Brovan/Android/app/brovan/src/main/java/dev/brovan/app/PlayerActivity.java
new file mode 100644
index 0000000..301a4b6
--- /dev/null
+++ b/Brovan/Android/app/brovan/src/main/java/dev/brovan/app/PlayerActivity.java
@@ -0,0 +1,307 @@
+package dev.brovan.app;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.SpannableStringBuilder;
+import android.text.style.ForegroundColorSpan;
+import android.view.View;
+import android.view.WindowManager;
+import android.widget.EditText;
+import android.widget.ScrollView;
+import android.widget.TextView;
+
+import androidx.appcompat.app.AlertDialog;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.core.content.ContextCompat;
+
+import com.google.android.material.floatingactionbutton.FloatingActionButton;
+
+import java.io.File;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.List;
+
+import dev.brovan.BrovanNative;
+import dev.brovan.BrovanSurfaceView;
+import dev.brovan.GuestWindow;
+import dev.brovan.input.ControlOverlay;
+
+/**
+ * Runs one program full screen. It lives in its own process, so quitting reclaims everything the emulator
+ * allocated and the next launch starts from a clean state.
+ */
+public class PlayerActivity extends AppCompatActivity implements BrovanNative.Listener {
+
+ private static final String EXTRA_DIRECTORY = "directory";
+ private static final String EXTRA_EXECUTABLE = "executable";
+ private static final String EXTRA_NAME = "name";
+ private static final String EXTRA_BACKEND = "backend";
+ private static final String EXTRA_NETWORK = "network";
+ private static final String EXTRA_DEVELOPER = "developer";
+ private static final String EXTRA_CONTROLS = "controls";
+
+ private static final int MAX_LINES = 1200;
+ private static final int TRIM_CHUNK = 200;
+
+ private final ArrayDeque lines = new ArrayDeque<>();
+
+ private BrovanSurfaceView surface;
+ private ControlOverlay controls;
+ private Settings settings;
+ private View console;
+ private TextView status;
+ private TextView log;
+ private ScrollView logScroll;
+ private boolean developerMode;
+
+ static Intent intentFor(Context context, Program program, Settings settings) {
+ return new Intent(context, PlayerActivity.class)
+ .putExtra(EXTRA_DIRECTORY, program.directory().getAbsolutePath())
+ .putExtra(EXTRA_EXECUTABLE, program.executable().getAbsolutePath())
+ .putExtra(EXTRA_NAME, program.name())
+ .putExtra(EXTRA_BACKEND, settings.backend())
+ .putExtra(EXTRA_NETWORK, settings.network())
+ .putExtra(EXTRA_DEVELOPER, settings.developerMode())
+ .putExtra(EXTRA_CONTROLS, settings.controlScheme());
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+ setContentView(R.layout.activity_player);
+
+ settings = new Settings(this);
+ surface = findViewById(R.id.surface);
+ controls = findViewById(R.id.controls);
+ console = findViewById(R.id.console);
+
+ int scheme = getIntent().getIntExtra(EXTRA_CONTROLS, 0);
+ controls.apply(ControlOverlay.Scheme.values()[scheme]);
+ status = findViewById(R.id.status);
+ log = findViewById(R.id.log);
+ logScroll = findViewById(R.id.log_scroll);
+
+ developerMode = getIntent().getBooleanExtra(EXTRA_DEVELOPER, false);
+
+ FloatingActionButton menu = findViewById(R.id.menu);
+ menu.setOnClickListener(view -> showMenu());
+
+ EditText command = findViewById(R.id.command);
+ findViewById(R.id.send).setOnClickListener(view -> {
+ String text = command.getText().toString().trim();
+ if (!text.isEmpty()) {
+ append("[/] > " + text);
+ command.setText("");
+ BrovanNative.sendCommand(text);
+ }
+ });
+
+ BrovanNative.setListener(this);
+ start();
+ }
+
+ private void start() {
+ int status = BrovanNative.init(getFilesDir().getAbsolutePath());
+ if (status != BrovanNative.STATUS_OK) {
+ fail("Could not start the emulator (" + status + ").");
+ return;
+ }
+
+ BrovanNative.setVerbose(developerMode);
+ setStatus(getIntent().getStringExtra(EXTRA_NAME));
+
+ int result = BrovanNative.start(
+ getIntent().getStringExtra(EXTRA_EXECUTABLE),
+ null,
+ getIntent().getStringExtra(EXTRA_DIRECTORY),
+ null,
+ getIntent().getIntExtra(EXTRA_BACKEND, 0),
+ getIntent().getIntExtra(EXTRA_NETWORK, 1));
+
+ if (result != BrovanNative.STATUS_OK) {
+ fail(describe(result));
+ }
+ }
+
+ private void showMenu() {
+ List labels = new ArrayList<>();
+ List actions = new ArrayList<>();
+
+ labels.add(getString(R.string.player_controls));
+ actions.add(this::showControlSchemes);
+
+ labels.add(getString(R.string.player_windows));
+ actions.add(this::showWindows);
+
+ labels.add(getString(R.string.player_redraw));
+ actions.add(BrovanNative::requestRepaint);
+
+ if (developerMode) {
+ labels.add(getString(R.string.player_console));
+ actions.add(this::toggleConsole);
+ }
+
+ labels.add(getString(R.string.player_quit));
+ actions.add(this::quit);
+
+ new AlertDialog.Builder(this)
+ .setItems(labels.toArray(new CharSequence[0]),
+ (dialog, index) -> actions.get(index).run())
+ .show();
+ }
+
+ private void showControlSchemes() {
+ ControlOverlay.Scheme[] schemes = ControlOverlay.Scheme.values();
+ CharSequence[] labels = new CharSequence[schemes.length];
+ for (int i = 0; i < schemes.length; i++) {
+ labels[i] = schemes[i].label();
+ }
+
+ new AlertDialog.Builder(this)
+ .setTitle(R.string.player_controls)
+ .setSingleChoiceItems(labels, controls.scheme().ordinal(), (dialog, index) -> {
+ controls.apply(schemes[index]);
+ settings.setControlScheme(index);
+ dialog.dismiss();
+ })
+ .show();
+ }
+
+ private void showWindows() {
+ List windows = BrovanNative.listWindows();
+ if (windows.isEmpty()) {
+ append("[*] The program has not created a window yet.");
+ return;
+ }
+
+ CharSequence[] labels = new CharSequence[windows.size()];
+ for (int i = 0; i < windows.size(); i++) {
+ labels[i] = windows.get(i).toString();
+ }
+
+ new AlertDialog.Builder(this)
+ .setTitle(R.string.player_windows)
+ .setItems(labels, (dialog, index) -> {
+ BrovanNative.selectWindow(windows.get(index).hwnd());
+ console.setVisibility(View.GONE);
+ surface.requestFocus();
+ })
+ .show();
+ }
+
+ private void toggleConsole() {
+ boolean visible = console.getVisibility() == View.VISIBLE;
+ console.setVisibility(visible ? View.GONE : View.VISIBLE);
+ if (visible) {
+ surface.requestFocus();
+ BrovanNative.requestRepaint();
+ }
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ controls.releaseAll();
+ }
+
+ private void quit() {
+ BrovanNative.requestClose();
+ finish();
+ }
+
+ private void fail(String message) {
+ append("[-] " + message);
+ console.setVisibility(View.VISIBLE);
+ setStatus(message);
+ }
+
+ private void setStatus(String value) {
+ runOnUiThread(() -> status.setText(value));
+ }
+
+ private void append(String line) {
+ SpannableString styled = new SpannableString(line);
+ styled.setSpan(new ForegroundColorSpan(colorFor(line)), 0, line.length(),
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+
+ runOnUiThread(() -> {
+ lines.addLast(styled);
+ if (lines.size() > MAX_LINES) {
+ for (int i = 0; i < TRIM_CHUNK && !lines.isEmpty(); i++) {
+ lines.removeFirst();
+ }
+
+ SpannableStringBuilder builder = new SpannableStringBuilder();
+ for (CharSequence entry : lines) {
+ builder.append(entry).append("\n");
+ }
+ log.setText(builder);
+ } else {
+ log.append(styled);
+ log.append("\n");
+ }
+
+ logScroll.post(() -> logScroll.fullScroll(View.FOCUS_DOWN));
+ });
+ }
+
+ private int colorFor(String line) {
+ if (line.startsWith("[-]") || line.startsWith("[!!]")) return ContextCompat.getColor(this, R.color.log_error);
+ if (line.startsWith("[+]")) return ContextCompat.getColor(this, R.color.log_ok);
+ if (line.startsWith("[!]")) return ContextCompat.getColor(this, R.color.log_warn);
+ if (line.startsWith("[#]")) return ContextCompat.getColor(this, R.color.log_info);
+ return ContextCompat.getColor(this, R.color.text_primary);
+ }
+
+ @Override
+ public void onLog(String line) {
+ if (developerMode) {
+ append(line);
+ }
+ }
+
+ @Override
+ public void onExit(int reason) {
+ append(reason == 0 ? "[*] The program closed." : "[-] The program stopped unexpectedly.");
+ setStatus(reason == 0 ? "Finished" : "Stopped");
+ runOnUiThread(() -> {
+ if (!developerMode) {
+ finish();
+ }
+ });
+ }
+
+ @Override
+ public void onBackPressed() {
+ if (console.getVisibility() == View.VISIBLE) {
+ toggleConsole();
+ return;
+ }
+
+ quit();
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+
+ // The emulator refuses a second guest in the same process, so the process goes with the activity.
+ if (isFinishing()) {
+ android.os.Process.killProcess(android.os.Process.myPid());
+ }
+ }
+
+ private static String describe(int status) {
+ switch (status) {
+ case BrovanNative.STATUS_MISSING_WINDOWS_LIBS: return "Windows system files are missing.";
+ case BrovanNative.STATUS_MISSING_REGISTRY: return "The registry files are missing.";
+ case BrovanNative.STATUS_BINARY_NOT_FOUND: return "The program file is gone.";
+ case BrovanNative.STATUS_ALREADY_RUNNING: return "Another program is already running.";
+ default: return "The program could not be started (" + status + ").";
+ }
+ }
+}
diff --git a/Brovan/Android/app/brovan/src/main/java/dev/brovan/app/Program.java b/Brovan/Android/app/brovan/src/main/java/dev/brovan/app/Program.java
new file mode 100644
index 0000000..4fbd57b
--- /dev/null
+++ b/Brovan/Android/app/brovan/src/main/java/dev/brovan/app/Program.java
@@ -0,0 +1,33 @@
+package dev.brovan.app;
+
+import java.io.File;
+
+/** A program the user imported, together with the folder holding the files it needs. */
+public final class Program {
+
+ private final File directory;
+ private final String name;
+ private final String executableName;
+
+ Program(File directory, String name, String executableName) {
+ this.directory = directory;
+ this.name = name;
+ this.executableName = executableName;
+ }
+
+ public File directory() {
+ return directory;
+ }
+
+ public File executable() {
+ return new File(directory, executableName);
+ }
+
+ public String name() {
+ return name;
+ }
+
+ public String executableName() {
+ return executableName;
+ }
+}
diff --git a/Brovan/Android/app/brovan/src/main/java/dev/brovan/app/ProgramAdapter.java b/Brovan/Android/app/brovan/src/main/java/dev/brovan/app/ProgramAdapter.java
new file mode 100644
index 0000000..40c8f25
--- /dev/null
+++ b/Brovan/Android/app/brovan/src/main/java/dev/brovan/app/ProgramAdapter.java
@@ -0,0 +1,69 @@
+package dev.brovan.app;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import java.util.ArrayList;
+import java.util.List;
+
+final class ProgramAdapter extends RecyclerView.Adapter {
+
+ interface Listener {
+ void onLaunch(Program program);
+
+ void onLongPress(Program program);
+ }
+
+ private final List programs = new ArrayList<>();
+ private final Listener listener;
+
+ ProgramAdapter(Listener listener) {
+ this.listener = listener;
+ }
+
+ void submit(List updated) {
+ programs.clear();
+ programs.addAll(updated);
+ notifyDataSetChanged();
+ }
+
+ @NonNull
+ @Override
+ public Holder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_app, parent, false);
+ return new Holder(view);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull Holder holder, int position) {
+ Program program = programs.get(position);
+ holder.name.setText(program.name());
+ holder.detail.setText(program.executableName());
+ holder.itemView.setOnClickListener(view -> listener.onLaunch(program));
+ holder.itemView.setOnLongClickListener(view -> {
+ listener.onLongPress(program);
+ return true;
+ });
+ }
+
+ @Override
+ public int getItemCount() {
+ return programs.size();
+ }
+
+ static final class Holder extends RecyclerView.ViewHolder {
+ final TextView name;
+ final TextView detail;
+
+ Holder(View view) {
+ super(view);
+ name = view.findViewById(R.id.name);
+ detail = view.findViewById(R.id.detail);
+ }
+ }
+}
diff --git a/Brovan/Android/app/brovan/src/main/java/dev/brovan/app/Settings.java b/Brovan/Android/app/brovan/src/main/java/dev/brovan/app/Settings.java
new file mode 100644
index 0000000..71dec99
--- /dev/null
+++ b/Brovan/Android/app/brovan/src/main/java/dev/brovan/app/Settings.java
@@ -0,0 +1,64 @@
+package dev.brovan.app;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+
+/** User-visible run options. */
+final class Settings {
+
+ static final String[] BACKENDS = {"Unicorn", "KVM", "WHP"};
+ static final String[] NETWORK_MODES = {"None", "Loopback", "Full"};
+
+ private static final String FILE = "brovan";
+ private static final String KEY_BACKEND = "backend";
+ private static final String KEY_NETWORK = "network";
+ private static final String KEY_DEVELOPER = "developer";
+ private static final String KEY_FIT_WINDOW = "fit_window";
+ private static final String KEY_CONTROLS = "controls";
+
+ private final SharedPreferences preferences;
+
+ Settings(Context context) {
+ preferences = context.getSharedPreferences(FILE, Context.MODE_PRIVATE);
+ }
+
+ int backend() {
+ return preferences.getInt(KEY_BACKEND, 0);
+ }
+
+ void setBackend(int value) {
+ preferences.edit().putInt(KEY_BACKEND, value).apply();
+ }
+
+ int network() {
+ return preferences.getInt(KEY_NETWORK, 1);
+ }
+
+ void setNetwork(int value) {
+ preferences.edit().putInt(KEY_NETWORK, value).apply();
+ }
+
+ boolean developerMode() {
+ return preferences.getBoolean(KEY_DEVELOPER, false);
+ }
+
+ void setDeveloperMode(boolean value) {
+ preferences.edit().putBoolean(KEY_DEVELOPER, value).apply();
+ }
+
+ int controlScheme() {
+ return preferences.getInt(KEY_CONTROLS, 0);
+ }
+
+ void setControlScheme(int value) {
+ preferences.edit().putInt(KEY_CONTROLS, value).apply();
+ }
+
+ boolean fitWindow() {
+ return preferences.getBoolean(KEY_FIT_WINDOW, true);
+ }
+
+ void setFitWindow(boolean value) {
+ preferences.edit().putBoolean(KEY_FIT_WINDOW, value).apply();
+ }
+}
diff --git a/Brovan/Android/app/brovan/src/main/res/drawable/ic_about.xml b/Brovan/Android/app/brovan/src/main/res/drawable/ic_about.xml
new file mode 100644
index 0000000..3aaefa2
--- /dev/null
+++ b/Brovan/Android/app/brovan/src/main/res/drawable/ic_about.xml
@@ -0,0 +1,6 @@
+
+
+
diff --git a/Brovan/Android/app/brovan/src/main/res/drawable/ic_add.xml b/Brovan/Android/app/brovan/src/main/res/drawable/ic_add.xml
new file mode 100644
index 0000000..5de447e
--- /dev/null
+++ b/Brovan/Android/app/brovan/src/main/res/drawable/ic_add.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/Brovan/Android/app/brovan/src/main/res/drawable/ic_library.xml b/Brovan/Android/app/brovan/src/main/res/drawable/ic_library.xml
new file mode 100644
index 0000000..01b17d9
--- /dev/null
+++ b/Brovan/Android/app/brovan/src/main/res/drawable/ic_library.xml
@@ -0,0 +1,6 @@
+
+
+
diff --git a/Brovan/Android/app/brovan/src/main/res/drawable/ic_menu.xml b/Brovan/Android/app/brovan/src/main/res/drawable/ic_menu.xml
new file mode 100644
index 0000000..0ea7d7f
--- /dev/null
+++ b/Brovan/Android/app/brovan/src/main/res/drawable/ic_menu.xml
@@ -0,0 +1,6 @@
+
+
+
diff --git a/Brovan/Android/app/brovan/src/main/res/drawable/ic_play.xml b/Brovan/Android/app/brovan/src/main/res/drawable/ic_play.xml
new file mode 100644
index 0000000..9276798
--- /dev/null
+++ b/Brovan/Android/app/brovan/src/main/res/drawable/ic_play.xml
@@ -0,0 +1,6 @@
+
+
+
diff --git a/Brovan/Android/app/brovan/src/main/res/drawable/ic_settings.xml b/Brovan/Android/app/brovan/src/main/res/drawable/ic_settings.xml
new file mode 100644
index 0000000..2c7ff8f
--- /dev/null
+++ b/Brovan/Android/app/brovan/src/main/res/drawable/ic_settings.xml
@@ -0,0 +1,6 @@
+
+
+
diff --git a/Brovan/Android/app/brovan/src/main/res/layout/activity_main.xml b/Brovan/Android/app/brovan/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..2c67495
--- /dev/null
+++ b/Brovan/Android/app/brovan/src/main/res/layout/activity_main.xml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Brovan/Android/app/brovan/src/main/res/layout/activity_player.xml b/Brovan/Android/app/brovan/src/main/res/layout/activity_player.xml
new file mode 100644
index 0000000..35c78e3
--- /dev/null
+++ b/Brovan/Android/app/brovan/src/main/res/layout/activity_player.xml
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Brovan/Android/app/brovan/src/main/res/layout/item_app.xml b/Brovan/Android/app/brovan/src/main/res/layout/item_app.xml
new file mode 100644
index 0000000..466585c
--- /dev/null
+++ b/Brovan/Android/app/brovan/src/main/res/layout/item_app.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Brovan/Android/app/brovan/src/main/res/layout/nav_header.xml b/Brovan/Android/app/brovan/src/main/res/layout/nav_header.xml
new file mode 100644
index 0000000..0a9f1ed
--- /dev/null
+++ b/Brovan/Android/app/brovan/src/main/res/layout/nav_header.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
diff --git a/Brovan/Android/app/brovan/src/main/res/layout/screen_about.xml b/Brovan/Android/app/brovan/src/main/res/layout/screen_about.xml
new file mode 100644
index 0000000..a18fec3
--- /dev/null
+++ b/Brovan/Android/app/brovan/src/main/res/layout/screen_about.xml
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Brovan/Android/app/brovan/src/main/res/layout/screen_library.xml b/Brovan/Android/app/brovan/src/main/res/layout/screen_library.xml
new file mode 100644
index 0000000..894a076
--- /dev/null
+++ b/Brovan/Android/app/brovan/src/main/res/layout/screen_library.xml
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Brovan/Android/app/brovan/src/main/res/layout/screen_settings.xml b/Brovan/Android/app/brovan/src/main/res/layout/screen_settings.xml
new file mode 100644
index 0000000..69e584c
--- /dev/null
+++ b/Brovan/Android/app/brovan/src/main/res/layout/screen_settings.xml
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Brovan/Android/app/brovan/src/main/res/menu/nav_menu.xml b/Brovan/Android/app/brovan/src/main/res/menu/nav_menu.xml
new file mode 100644
index 0000000..b74b189
--- /dev/null
+++ b/Brovan/Android/app/brovan/src/main/res/menu/nav_menu.xml
@@ -0,0 +1,8 @@
+
+
diff --git a/Brovan/Android/app/brovan/src/main/res/values/colors.xml b/Brovan/Android/app/brovan/src/main/res/values/colors.xml
new file mode 100644
index 0000000..b653242
--- /dev/null
+++ b/Brovan/Android/app/brovan/src/main/res/values/colors.xml
@@ -0,0 +1,16 @@
+
+
+ #FF0D1117
+ #FF161C24
+ #FF1E2733
+ #FF2A3542
+ #FF7C9CF5
+ #FF0B1020
+ #FFE6EDF3
+ #FF95A1B2
+
+ #FFF2707A
+ #FFE8C36B
+ #FF5BD68A
+ #FF6FB6F1
+
diff --git a/Brovan/Android/app/brovan/src/main/res/values/strings.xml b/Brovan/Android/app/brovan/src/main/res/values/strings.xml
new file mode 100644
index 0000000..d6517e9
--- /dev/null
+++ b/Brovan/Android/app/brovan/src/main/res/values/strings.xml
@@ -0,0 +1,40 @@
+
+
+ Brovan
+
+ Library
+ Settings
+ About
+ Open navigation
+ Close navigation
+
+ No programs yet
+ Add a folder containing a Windows program and everything it needs. Brovan copies it into its own storage so the program can find its files.
+ Add a program
+ Add folder
+ Add single .exe
+ Importing…
+ Remove
+ Choose the program to launch
+
+ Graphics and input
+ Advanced
+ On-screen controls
+ CPU backend
+ Network access
+ Developer mode
+ Show the console and emulator trace while a program runs
+
+ AdvDebug
+ https://github.com/AdvDebug
+ Open GitHub
+ Run Windows programs on Android
+
+ Controls
+ Windows
+ Console
+ Redraw
+ Quit
+ debugger command
+ Send
+
diff --git a/Brovan/Android/app/brovan/src/main/res/values/themes.xml b/Brovan/Android/app/brovan/src/main/res/values/themes.xml
new file mode 100644
index 0000000..a73643d
--- /dev/null
+++ b/Brovan/Android/app/brovan/src/main/res/values/themes.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/Brovan/Android/app/build.gradle b/Brovan/Android/app/build.gradle
new file mode 100644
index 0000000..4897847
--- /dev/null
+++ b/Brovan/Android/app/build.gradle
@@ -0,0 +1,3 @@
+plugins {
+ id 'com.android.application' version '8.5.2' apply false
+}
diff --git a/Brovan/Android/app/gradle.properties b/Brovan/Android/app/gradle.properties
new file mode 100644
index 0000000..6df3ac8
--- /dev/null
+++ b/Brovan/Android/app/gradle.properties
@@ -0,0 +1,3 @@
+org.gradle.jvmargs=-Xmx3g
+android.useAndroidX=true
+android.nonTransitiveRClass=true
diff --git a/Brovan/Android/app/settings.gradle b/Brovan/Android/app/settings.gradle
new file mode 100644
index 0000000..3201c15
--- /dev/null
+++ b/Brovan/Android/app/settings.gradle
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "Brovan"
+include ":brovan"
diff --git a/Brovan/Android/build-apk.sh b/Brovan/Android/build-apk.sh
new file mode 100644
index 0000000..8ddc26f
--- /dev/null
+++ b/Brovan/Android/build-apk.sh
@@ -0,0 +1,156 @@
+#!/usr/bin/env bash
+# Builds the Brovan APK. Must run on a Linux host (WSL is fine): NativeAOT does not cross-compile from
+# Windows to linux-bionic.
+#
+# Expects a .NET 9 SDK (the source generator needs Roslyn >= 4.10), a JDK 17, Gradle 8.7+, and an Android
+# SDK with NDK 26. Point the variables below at them if they are not already on PATH.
+set -euo pipefail
+
+ANDROID_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "$ANDROID_DIR/../.." && pwd)"
+PROJECT="$REPO_ROOT/Brovan/Brovan.csproj"
+GRADLE_PROJECT="$ANDROID_DIR/app"
+JNI_LIBS="$GRADLE_PROJECT/brovan/src/main/jniLibs/arm64-v8a"
+
+TOOLS="${BROVAN_TOOLCHAIN:-$HOME/brovan-toolchain}"
+
+DOTNET="${DOTNET:-}"
+if [ -z "$DOTNET" ]; then
+ if [ -x "$HOME/.dotnet9/dotnet" ]; then
+ DOTNET="$HOME/.dotnet9/dotnet"
+ else
+ DOTNET="$(command -v dotnet || true)"
+ fi
+fi
+
+if [ -z "${JAVA_HOME:-}" ] || [ ! -d "${JAVA_HOME:-}" ]; then
+ if [ -d "$TOOLS/jdk17" ]; then
+ export JAVA_HOME="$TOOLS/jdk17"
+ else
+ unset JAVA_HOME
+ fi
+fi
+
+export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-$TOOLS/android-sdk}"
+export ANDROID_HOME="$ANDROID_SDK_ROOT"
+
+GRADLE="${GRADLE:-}"
+if [ -z "$GRADLE" ]; then
+ if [ -x "$TOOLS/gradle-8.7/bin/gradle" ]; then
+ GRADLE="$TOOLS/gradle-8.7/bin/gradle"
+ else
+ GRADLE="$(command -v gradle || true)"
+ fi
+fi
+
+# The trailing `|| true` keeps pipefail from ending the script here when the SDK is not installed at all:
+# these have to report "no toolchain" through missing() below, not abort.
+NDK="${ANDROID_NDK_HOME:-}"
+[ -n "$NDK" ] || NDK="$(ls -d "$ANDROID_SDK_ROOT"/ndk/* 2>/dev/null | sort -V | tail -1 || true)"
+CMAKE="${CMAKE:-}"
+[ -n "$CMAKE" ] || CMAKE="$(ls -d "$ANDROID_SDK_ROOT"/cmake/*/bin/cmake 2>/dev/null | sort -V | tail -1 || true)"
+[ -x "${CMAKE:-}" ] || CMAKE="$(command -v cmake || true)"
+
+CONFIG="${CONFIG:-Release}"
+API_LEVEL="${API_LEVEL:-26}"
+PUBLISH_DIR="${BROVAN_PUBLISH_DIR:-/tmp/brovan-android-publish}"
+APK_OUTPUT="${BROVAN_APK_OUTPUT:-$REPO_ROOT/artifacts/android/brovan-arm64-v8a.apk}"
+UNICORN_SRC="$REPO_ROOT/Brovan/.cache/unicorn/unicorn-2.1.4"
+UNICORN_BUILD="$REPO_ROOT/Brovan/.cache/unicorn/build-android-arm64"
+UNICORN_ARTIFACT="$UNICORN_BUILD/libunicorn.so"
+
+# Exit code 3 means "this host has no Android toolchain", which Brovan.Android/Brovan.Android.csproj treats
+# as a skip rather than a build failure. Anything else is a real failure.
+missing() { echo "$1" >&2; exit 3; }
+
+[ -n "$DOTNET" ] && [ -x "$DOTNET" ] || missing "dotnet SDK not found; set DOTNET or install one at $HOME/.dotnet9"
+DOTNET_MAJOR="$("$DOTNET" --version 2>/dev/null | cut -d. -f1)"
+case "${DOTNET_MAJOR:-}" in
+ ''|*[!0-9]*) missing "could not read the SDK version of $DOTNET" ;;
+esac
+[ "$DOTNET_MAJOR" -ge 9 ] || missing "the source generator needs Roslyn >= 4.10, so a .NET 9 SDK is required; $DOTNET is $DOTNET_MAJOR.x"
+[ -n "$GRADLE" ] && [ -x "$GRADLE" ] || missing "gradle not found; set GRADLE (8.7 or newer, required by AGP 8.5)"
+[ -n "$NDK" ] && [ -d "$NDK" ] || missing "Android NDK not found under $ANDROID_SDK_ROOT/ndk"
+[ -n "${CMAKE:-}" ] && [ -x "$CMAKE" ] || missing "cmake not found"
+
+mkdir -p "$JNI_LIBS"
+
+if [ ! -f "$UNICORN_SRC/CMakeLists.txt" ]; then
+ echo "==> Fetching the Unicorn source through Brovan.Unicorn.targets"
+ "$DOTNET" msbuild "$PROJECT" -t:ExtractUnicornSource -nologo -v:minimal || true
+ [ -f "$UNICORN_SRC/CMakeLists.txt" ] || { echo "Unicorn source missing at $UNICORN_SRC" >&2; exit 1; }
+fi
+
+# Unicorn has to be cross-built before the publish: Brovan.Unicorn.targets copies whatever sits at
+# UnicornArtifact into the publish output, and if that path is empty it configures a host-arch build there
+# instead, poisoning the CMake cache for the arm64 configure that follows.
+echo "==> [1/4] Cross-building Unicorn for arm64-v8a"
+if [ ! -f "$UNICORN_ARTIFACT" ]; then
+ "$CMAKE" -S "$UNICORN_SRC" -B "$UNICORN_BUILD" \
+ -DCMAKE_TOOLCHAIN_FILE="$NDK/build/cmake/android.toolchain.cmake" \
+ -DANDROID_ABI=arm64-v8a \
+ -DANDROID_PLATFORM="android-$API_LEVEL" \
+ -DCMAKE_BUILD_TYPE=Release \
+ -DCMAKE_SHARED_LINKER_FLAGS="-Wl,-z,max-page-size=16384" \
+ -DUNICORN_ARCH="x86;aarch64" \
+ -DBUILD_SHARED_LIBS=ON \
+ -DUNICORN_LEGACY_STATIC_ARCHIVE=OFF \
+ -DUNICORN_FUZZ=OFF \
+ -DUNICORN_LOGGING=OFF \
+ -DUNICORN_BUILD_TESTS=OFF
+ "$CMAKE" --build "$UNICORN_BUILD" --parallel
+fi
+cp "$UNICORN_ARTIFACT" "$JNI_LIBS/libunicorn.so"
+
+# .NET's crypto shim aborts the process the moment any OpenSSL-backed primitive is touched
+# ("No usable version of libssl was found"), and Android exposes no libssl to apps. Its probe list includes
+# the unversioned names, which is what OpenSSL's android targets emit and what Android will package.
+OPENSSL_BUILD="${OPENSSL_BUILD:-$TOOLS/openssl-3.5.4}"
+if [ -f "$OPENSSL_BUILD/libssl.so" ] && [ -f "$OPENSSL_BUILD/libcrypto.so" ]; then
+ cp "$OPENSSL_BUILD/libssl.so" "$OPENSSL_BUILD/libcrypto.so" "$JNI_LIBS/"
+else
+ echo "warning: no OpenSSL build at $OPENSSL_BUILD; the guest will abort on first crypto use" >&2
+fi
+
+echo "==> [2/4] Publishing Brovan as a NativeAOT shared library (linux-bionic-arm64)"
+NDK_BIN="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin"
+CLANG="$NDK_BIN/aarch64-linux-android$API_LEVEL-clang"
+OBJCOPY="$NDK_BIN/llvm-objcopy"
+[ -x "$CLANG" ] || missing "NDK clang not found at $CLANG"
+[ -x "$OBJCOPY" ] || missing "NDK llvm-objcopy not found at $OBJCOPY"
+
+rm -rf "$PUBLISH_DIR"
+# PublishAot is set inside Brovan.csproj on purpose: passing it on the command line leaks it into the
+# netstandard2.0 generator project, which fails with NETSDK1207.
+"$DOTNET" publish "$PROJECT" \
+ -c "$CONFIG" \
+ -r linux-bionic-arm64 \
+ --self-contained true \
+ -p:NativeLib=Shared \
+ -p:OutputType=Library \
+ -p:PublishAotUsingRuntimePack=true \
+ -p:PlatformTarget=arm64 \
+ -p:CppCompilerAndLinker="$CLANG" \
+ -p:ObjCopyName="$OBJCOPY" \
+ -p:LinkerFlavor=lld \
+ -p:CustomAfterMicrosoftCommonTargets="$ANDROID_DIR/android-link.targets" \
+ -p:UnicornBuildDir="$UNICORN_BUILD" \
+ -p:UnicornArtifact="$UNICORN_ARTIFACT" \
+ -o "$PUBLISH_DIR"
+
+# NativeAOT names the shared library after the assembly and drops the lib prefix on some SDKs.
+PRODUCED="$(find "$PUBLISH_DIR" -maxdepth 1 -name 'Brovan.so' -o -maxdepth 1 -name 'libBrovan.so' | head -1)"
+[ -n "$PRODUCED" ] || { echo "no shared library produced in $PUBLISH_DIR" >&2; ls -la "$PUBLISH_DIR" >&2; exit 1; }
+cp "$PRODUCED" "$JNI_LIBS/libBrovan.so"
+file "$JNI_LIBS/libBrovan.so"
+
+echo "==> [3/4] Assembling the APK"
+printf 'sdk.dir=%s\n' "$ANDROID_SDK_ROOT" > "$GRADLE_PROJECT/local.properties"
+"$GRADLE" -p "$GRADLE_PROJECT" assembleDebug --no-daemon
+
+echo "==> [4/4] Collecting the APK"
+BUILT_APK="$(find "$GRADLE_PROJECT" -path '*/outputs/apk/debug/*.apk' -print | head -1)"
+[ -n "$BUILT_APK" ] || { echo "gradle produced no APK under $GRADLE_PROJECT" >&2; exit 1; }
+mkdir -p "$(dirname "$APK_OUTPUT")"
+cp "$BUILT_APK" "$APK_OUTPUT"
+echo "$APK_OUTPUT"
diff --git a/Brovan/Android/build-openssl.sh b/Brovan/Android/build-openssl.sh
new file mode 100644
index 0000000..a766258
--- /dev/null
+++ b/Brovan/Android/build-openssl.sh
@@ -0,0 +1,42 @@
+#!/usr/bin/env bash
+# Builds libssl/libcrypto for android-arm64 into the directory build-apk.sh picks them up from.
+#
+# .NET aborts the process on the first OpenSSL-backed primitive ("No usable version of libssl was found") and
+# Android exposes no libssl to apps. OpenSSL's android targets emit unversioned sonames, which is both what
+# Android packaging allows and what .NET's probe list accepts.
+set -euo pipefail
+
+TOOLS="${BROVAN_TOOLCHAIN:-$HOME/brovan-toolchain}"
+VERSION="${OPENSSL_VERSION:-3.5.4}"
+OUT="${OPENSSL_BUILD:-$TOOLS/openssl-$VERSION}"
+API_LEVEL="${API_LEVEL:-26}"
+SRC="$TOOLS/src/openssl-$VERSION"
+
+export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-$TOOLS/android-sdk}"
+NDK="${ANDROID_NDK_HOME:-$(ls -d "$ANDROID_SDK_ROOT"/ndk/* 2>/dev/null | sort -V | tail -1)}"
+[ -n "$NDK" ] && [ -d "$NDK" ] || { echo "Android NDK not found under $ANDROID_SDK_ROOT/ndk" >&2; exit 1; }
+
+if [ -f "$OUT/libssl.so" ] && [ -f "$OUT/libcrypto.so" ]; then
+ echo "OpenSSL $VERSION already built at $OUT"
+ exit 0
+fi
+
+mkdir -p "$TOOLS/src"
+if [ ! -f "$SRC/Configure" ]; then
+ curl -fsSL "https://github.com/openssl/openssl/releases/download/openssl-$VERSION/openssl-$VERSION.tar.gz" \
+ -o "$TOOLS/src/openssl-$VERSION.tar.gz"
+ tar -xzf "$TOOLS/src/openssl-$VERSION.tar.gz" -C "$TOOLS/src"
+fi
+
+export ANDROID_NDK_ROOT="$NDK"
+export PATH="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin:$PATH"
+
+cd "$SRC"
+# Android 16 shows PageSizeMismatchDialog for 4K-aligned libraries and 16K-page devices refuse them.
+./Configure android-arm64 -D__ANDROID_API__="$API_LEVEL" shared no-tests no-docs \
+ -Wl,-z,max-page-size=16384
+make -j"$(nproc)" build_libs
+
+mkdir -p "$OUT"
+cp "$SRC/libssl.so" "$SRC/libcrypto.so" "$OUT/"
+echo "OpenSSL $VERSION -> $OUT"
diff --git a/Brovan/Android/java/dev/brovan/BrovanNative.java b/Brovan/Android/java/dev/brovan/BrovanNative.java
new file mode 100644
index 0000000..0bdb2aa
--- /dev/null
+++ b/Brovan/Android/java/dev/brovan/BrovanNative.java
@@ -0,0 +1,229 @@
+package dev.brovan;
+
+import android.view.Surface;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Thin binding over the emulator's exported C ABI. Every method is safe to call from any thread except
+ * where noted; the emulator runs the guest on its own threads and never borrows the caller's.
+ */
+public final class BrovanNative {
+
+ public static final int STATUS_OK = 0;
+ public static final int STATUS_NOT_INITIALIZED = -1;
+ public static final int STATUS_ALREADY_RUNNING = -2;
+ public static final int STATUS_INVALID_ARGUMENT = -3;
+ public static final int STATUS_MISSING_WINDOWS_LIBS = -4;
+ public static final int STATUS_MISSING_REGISTRY = -5;
+ public static final int STATUS_APISETMAP_FAILED = -6;
+ public static final int STATUS_BINARY_NOT_FOUND = -7;
+ public static final int STATUS_FAILED = -8;
+
+ public static final int BACKEND_UNICORN = 0;
+ public static final int BACKEND_KVM = 1;
+ public static final int BACKEND_WHP = 2;
+
+ public static final int NETWORK_NONE = 0;
+ public static final int NETWORK_LOOPBACK = 1;
+ public static final int NETWORK_FULL = 2;
+
+ public static final int POINTER_MOVE = 0;
+ public static final int POINTER_DOWN = 1;
+ public static final int POINTER_UP = 2;
+
+ public static final int BUTTON_LEFT = 0;
+ public static final int BUTTON_MIDDLE = 1;
+ public static final int BUTTON_RIGHT = 2;
+
+ public static final int MK_LBUTTON = 0x0001;
+ public static final int MK_RBUTTON = 0x0002;
+ public static final int MK_SHIFT = 0x0004;
+ public static final int MK_CONTROL = 0x0008;
+ public static final int MK_MBUTTON = 0x0010;
+
+ public interface Listener {
+ void onLog(String line);
+
+ void onExit(int reason);
+ }
+
+ private static volatile Listener listener;
+
+ static {
+ // .NET's crypto shim dlopens libssl.so, and Android provides none. Loading our bundled pair up front
+ // registers them under their sonames so the runtime's own dlopen resolves to these.
+ try {
+ System.loadLibrary("crypto");
+ System.loadLibrary("ssl");
+ } catch (UnsatisfiedLinkError ignored) {
+ // Left to the runtime to report if it actually needs them.
+ }
+
+ System.loadLibrary("Brovan");
+ System.loadLibrary("brovan_jni");
+ }
+
+ private BrovanNative() {
+ }
+
+ public static void setListener(Listener value) {
+ listener = value;
+ }
+
+ /**
+ * Must be the first call into the emulator. baseDirectory becomes the root the emulator resolves
+ * WindowsLibs, WinReg, apisetmap.bin, VirtualFS, sessions and logs against, so it has to be a writable
+ * app-private directory (getFilesDir()).
+ */
+ public static int init(String baseDirectory) {
+ return nativeInit(baseDirectory);
+ }
+
+ /** Call from surfaceCreated / surfaceChanged. */
+ public static void setSurface(Surface surface, int densityDpi) {
+ nativeSetSurface(surface, densityDpi);
+ }
+
+ /** Call from surfaceDestroyed. */
+ public static void clearSurface() {
+ nativeClearSurface();
+ }
+
+ public static int start(String binaryPath, String guestCommandLine, String workingDirectory,
+ String debuggerCommands, int backend, int networkMode) {
+ return nativeStart(binaryPath, guestCommandLine, workingDirectory, debuggerCommands, backend, networkMode);
+ }
+
+ /** Enables the emulator's own trace into logcat. Must be called before {@link #start}. */
+ public static void setVerbose(boolean enabled) {
+ nativeSetVerbose(enabled ? 1 : 0);
+ }
+
+ /** Feeds one line to the emulator's debugger prompt. Verbose mode only. */
+ public static void sendCommand(String command) {
+ nativeSendCommand(command);
+ }
+
+ public static boolean isRunning() {
+ return nativeIsRunning() != 0;
+ }
+
+ /** Posts WM_CLOSE to the guest. Calling it a second time terminates the process. */
+ public static void requestClose() {
+ nativeRequestClose();
+ }
+
+ public static void injectPointer(int action, int button, int x, int y, int buttons) {
+ nativeInjectPointer(action, button, x, y, buttons);
+ }
+
+ public static void injectScroll(int delta, int x, int y, int buttons) {
+ nativeInjectScroll(delta, x, y, buttons);
+ }
+
+ public static void injectKey(boolean down, int virtualKey, int scanCode) {
+ nativeInjectKey(down ? 1 : 0, virtualKey, scanCode);
+ }
+
+ public static void injectFocus(boolean focused) {
+ nativeInjectFocus(focused ? 1 : 0);
+ }
+
+ /** Marks the guest window dirty so it repaints. */
+ public static void requestRepaint() {
+ nativeRequestRepaint();
+ }
+
+ /** One top-level guest window per element. */
+ public static List listWindows() {
+ List windows = new ArrayList<>();
+ String raw = nativeListWindows();
+ if (raw == null || raw.isEmpty()) {
+ return windows;
+ }
+
+ for (String line : raw.split("\n")) {
+ if (line.isEmpty()) {
+ continue;
+ }
+
+ String[] parts = line.split("\\|", 5);
+ if (parts.length < 5) {
+ continue;
+ }
+
+ try {
+ windows.add(new GuestWindow(
+ Long.parseUnsignedLong(parts[0]),
+ Integer.parseInt(parts[1]),
+ Integer.parseInt(parts[2]),
+ "1".equals(parts[3]),
+ parts[4]));
+ } catch (NumberFormatException ignored) {
+ // A torn snapshot is not worth failing the whole list for.
+ }
+ }
+
+ return windows;
+ }
+
+ /** Chooses which guest window is presented on the Surface. */
+ public static void selectWindow(long hwnd) {
+ nativeSelectWindow(hwnd);
+ }
+
+ public static String getWindowTitle() {
+ return nativeGetWindowTitle();
+ }
+
+ @SuppressWarnings("unused")
+ private static void onNativeLog(String line) {
+ Listener current = listener;
+ if (current != null) {
+ current.onLog(line);
+ }
+ }
+
+ @SuppressWarnings("unused")
+ private static void onNativeExit(int reason) {
+ Listener current = listener;
+ if (current != null) {
+ current.onExit(reason);
+ }
+ }
+
+ private static native int nativeInit(String baseDirectory);
+
+ private static native void nativeSetSurface(Surface surface, int densityDpi);
+
+ private static native void nativeClearSurface();
+
+ private static native int nativeStart(String binaryPath, String guestCommandLine, String workingDirectory,
+ String debuggerCommands, int backend, int networkMode);
+
+ private static native void nativeSetVerbose(int enabled);
+
+ private static native void nativeSendCommand(String command);
+
+ private static native int nativeIsRunning();
+
+ private static native void nativeRequestClose();
+
+ private static native void nativeInjectPointer(int action, int button, int x, int y, int buttons);
+
+ private static native void nativeInjectScroll(int delta, int x, int y, int buttons);
+
+ private static native void nativeInjectKey(int down, int virtualKey, int scanCode);
+
+ private static native void nativeInjectFocus(int focused);
+
+ private static native void nativeRequestRepaint();
+
+ private static native String nativeListWindows();
+
+ private static native void nativeSelectWindow(long hwnd);
+
+ private static native String nativeGetWindowTitle();
+}
diff --git a/Brovan/Android/java/dev/brovan/BrovanSurfaceView.java b/Brovan/Android/java/dev/brovan/BrovanSurfaceView.java
new file mode 100644
index 0000000..fbf3917
--- /dev/null
+++ b/Brovan/Android/java/dev/brovan/BrovanSurfaceView.java
@@ -0,0 +1,165 @@
+package dev.brovan;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.SurfaceHolder;
+import android.view.SurfaceView;
+
+/**
+ * Feeds the emulator its Surface and turns touch and key events into the Win32 messages the guest expects.
+ *
+ * The Activity hosting this view must declare
+ * android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|density"
+ * so it is not recreated: a Surface swap invalidates the VkSurfaceKHR the guest already holds, and the
+ * emulator cannot rebuild a swapchain behind a running guest.
+ */
+public class BrovanSurfaceView extends SurfaceView implements SurfaceHolder.Callback {
+
+ private int buttons;
+
+ public BrovanSurfaceView(Context context) {
+ super(context);
+ initialize();
+ }
+
+ public BrovanSurfaceView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ initialize();
+ }
+
+ private void initialize() {
+ getHolder().addCallback(this);
+ setFocusable(true);
+ setFocusableInTouchMode(true);
+ }
+
+ @Override
+ public void surfaceCreated(SurfaceHolder holder) {
+ BrovanNative.setSurface(holder.getSurface(), getResources().getDisplayMetrics().densityDpi);
+ BrovanNative.injectFocus(true);
+ }
+
+ @Override
+ public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
+ BrovanNative.setSurface(holder.getSurface(), getResources().getDisplayMetrics().densityDpi);
+ }
+
+ @Override
+ public void surfaceDestroyed(SurfaceHolder holder) {
+ BrovanNative.injectFocus(false);
+ BrovanNative.clearSurface();
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ int x = (int) event.getX();
+ int y = (int) event.getY();
+
+ switch (event.getActionMasked()) {
+ case MotionEvent.ACTION_DOWN:
+ buttons |= BrovanNative.MK_LBUTTON;
+ BrovanNative.injectPointer(BrovanNative.POINTER_MOVE, BrovanNative.BUTTON_LEFT, x, y, buttons);
+ BrovanNative.injectPointer(BrovanNative.POINTER_DOWN, BrovanNative.BUTTON_LEFT, x, y, buttons);
+ return true;
+ case MotionEvent.ACTION_MOVE:
+ BrovanNative.injectPointer(BrovanNative.POINTER_MOVE, BrovanNative.BUTTON_LEFT, x, y, buttons);
+ return true;
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_CANCEL:
+ buttons &= ~BrovanNative.MK_LBUTTON;
+ BrovanNative.injectPointer(BrovanNative.POINTER_UP, BrovanNative.BUTTON_LEFT, x, y, buttons);
+ return true;
+ default:
+ return super.onTouchEvent(event);
+ }
+ }
+
+ @Override
+ public boolean onKeyDown(int keyCode, KeyEvent event) {
+ int virtualKey = toVirtualKey(keyCode);
+ if (virtualKey == 0) {
+ return super.onKeyDown(keyCode, event);
+ }
+
+ BrovanNative.injectKey(true, virtualKey, event.getScanCode());
+ return true;
+ }
+
+ @Override
+ public boolean onKeyUp(int keyCode, KeyEvent event) {
+ int virtualKey = toVirtualKey(keyCode);
+ if (virtualKey == 0) {
+ return super.onKeyUp(keyCode, event);
+ }
+
+ BrovanNative.injectKey(false, virtualKey, event.getScanCode());
+ return true;
+ }
+
+ /** Android keycodes are positional; the guest speaks Win32 virtual-key codes. */
+ public static int toVirtualKey(int keyCode) {
+ if (keyCode >= KeyEvent.KEYCODE_A && keyCode <= KeyEvent.KEYCODE_Z) {
+ return 0x41 + (keyCode - KeyEvent.KEYCODE_A);
+ }
+
+ if (keyCode >= KeyEvent.KEYCODE_0 && keyCode <= KeyEvent.KEYCODE_9) {
+ return 0x30 + (keyCode - KeyEvent.KEYCODE_0);
+ }
+
+ if (keyCode >= KeyEvent.KEYCODE_F1 && keyCode <= KeyEvent.KEYCODE_F12) {
+ return 0x70 + (keyCode - KeyEvent.KEYCODE_F1);
+ }
+
+ if (keyCode >= KeyEvent.KEYCODE_NUMPAD_0 && keyCode <= KeyEvent.KEYCODE_NUMPAD_9) {
+ return 0x60 + (keyCode - KeyEvent.KEYCODE_NUMPAD_0);
+ }
+
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_DEL: return 0x08;
+ case KeyEvent.KEYCODE_TAB: return 0x09;
+ case KeyEvent.KEYCODE_ENTER:
+ case KeyEvent.KEYCODE_NUMPAD_ENTER: return 0x0D;
+ case KeyEvent.KEYCODE_SHIFT_LEFT: return 0xA0;
+ case KeyEvent.KEYCODE_SHIFT_RIGHT: return 0xA1;
+ case KeyEvent.KEYCODE_CTRL_LEFT: return 0xA2;
+ case KeyEvent.KEYCODE_CTRL_RIGHT: return 0xA3;
+ case KeyEvent.KEYCODE_ALT_LEFT: return 0x12;
+ case KeyEvent.KEYCODE_ALT_RIGHT: return 0xA5;
+ case KeyEvent.KEYCODE_CAPS_LOCK: return 0x14;
+ case KeyEvent.KEYCODE_ESCAPE:
+ case KeyEvent.KEYCODE_BACK: return 0x1B;
+ case KeyEvent.KEYCODE_SPACE: return 0x20;
+ case KeyEvent.KEYCODE_PAGE_UP: return 0x21;
+ case KeyEvent.KEYCODE_PAGE_DOWN: return 0x22;
+ case KeyEvent.KEYCODE_MOVE_END: return 0x23;
+ case KeyEvent.KEYCODE_MOVE_HOME: return 0x24;
+ case KeyEvent.KEYCODE_DPAD_LEFT: return 0x25;
+ case KeyEvent.KEYCODE_DPAD_UP: return 0x26;
+ case KeyEvent.KEYCODE_DPAD_RIGHT: return 0x27;
+ case KeyEvent.KEYCODE_DPAD_DOWN: return 0x28;
+ case KeyEvent.KEYCODE_INSERT: return 0x2D;
+ case KeyEvent.KEYCODE_FORWARD_DEL: return 0x2E;
+ case KeyEvent.KEYCODE_NUMPAD_MULTIPLY: return 0x6A;
+ case KeyEvent.KEYCODE_NUMPAD_ADD: return 0x6B;
+ case KeyEvent.KEYCODE_NUMPAD_SUBTRACT: return 0x6D;
+ case KeyEvent.KEYCODE_NUMPAD_DOT: return 0x6E;
+ case KeyEvent.KEYCODE_NUMPAD_DIVIDE: return 0x6F;
+ case KeyEvent.KEYCODE_NUM_LOCK: return 0x90;
+ case KeyEvent.KEYCODE_SCROLL_LOCK: return 0x91;
+ case KeyEvent.KEYCODE_SEMICOLON: return 0xBA;
+ case KeyEvent.KEYCODE_EQUALS: return 0xBB;
+ case KeyEvent.KEYCODE_COMMA: return 0xBC;
+ case KeyEvent.KEYCODE_MINUS: return 0xBD;
+ case KeyEvent.KEYCODE_PERIOD: return 0xBE;
+ case KeyEvent.KEYCODE_SLASH: return 0xBF;
+ case KeyEvent.KEYCODE_GRAVE: return 0xC0;
+ case KeyEvent.KEYCODE_LEFT_BRACKET: return 0xDB;
+ case KeyEvent.KEYCODE_BACKSLASH: return 0xDC;
+ case KeyEvent.KEYCODE_RIGHT_BRACKET: return 0xDD;
+ case KeyEvent.KEYCODE_APOSTROPHE: return 0xDE;
+ default: return 0;
+ }
+ }
+}
diff --git a/Brovan/Android/java/dev/brovan/GuestWindow.java b/Brovan/Android/java/dev/brovan/GuestWindow.java
new file mode 100644
index 0000000..b54cc36
--- /dev/null
+++ b/Brovan/Android/java/dev/brovan/GuestWindow.java
@@ -0,0 +1,44 @@
+package dev.brovan;
+
+/** A top-level window owned by the emulated process. */
+public final class GuestWindow {
+
+ private final long hwnd;
+ private final int width;
+ private final int height;
+ private final boolean visible;
+ private final String title;
+
+ GuestWindow(long hwnd, int width, int height, boolean visible, String title) {
+ this.hwnd = hwnd;
+ this.width = width;
+ this.height = height;
+ this.visible = visible;
+ this.title = title;
+ }
+
+ public long hwnd() {
+ return hwnd;
+ }
+
+ public int width() {
+ return width;
+ }
+
+ public int height() {
+ return height;
+ }
+
+ public boolean visible() {
+ return visible;
+ }
+
+ public String title() {
+ return title.isEmpty() ? "(untitled)" : title;
+ }
+
+ @Override
+ public String toString() {
+ return title() + " " + width + "x" + height + (visible ? "" : " (hidden)");
+ }
+}
diff --git a/Brovan/Android/java/dev/brovan/input/ActionButtonView.java b/Brovan/Android/java/dev/brovan/input/ActionButtonView.java
new file mode 100644
index 0000000..ee123ac
--- /dev/null
+++ b/Brovan/Android/java/dev/brovan/input/ActionButtonView.java
@@ -0,0 +1,88 @@
+package dev.brovan.input;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.view.MotionEvent;
+import android.view.View;
+
+/** Round hold-to-press button bound to one key. */
+public class ActionButtonView extends View {
+
+ public interface Listener {
+ void onPressed(VirtualKey key, boolean down);
+ }
+
+ private final Paint fillPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint ringPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint textPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+
+ private final VirtualKey key;
+ private final String label;
+
+ private Listener listener;
+ private boolean pressed;
+
+ public ActionButtonView(Context context, VirtualKey key, String label) {
+ super(context);
+ this.key = key;
+ this.label = label;
+
+ fillPaint.setColor(Color.argb(70, 255, 255, 255));
+ ringPaint.setColor(Color.argb(130, 255, 255, 255));
+ ringPaint.setStyle(Paint.Style.STROKE);
+ ringPaint.setStrokeWidth(3f);
+ textPaint.setColor(Color.argb(220, 255, 255, 255));
+ textPaint.setTextAlign(Paint.Align.CENTER);
+ }
+
+ public void setListener(Listener listener) {
+ this.listener = listener;
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ float centreX = getWidth() / 2f;
+ float centreY = getHeight() / 2f;
+ float radius = Math.min(centreX, centreY) - 4f;
+
+ fillPaint.setAlpha(pressed ? 140 : 70);
+ canvas.drawCircle(centreX, centreY, radius, fillPaint);
+ canvas.drawCircle(centreX, centreY, radius, ringPaint);
+
+ textPaint.setTextSize(radius * 0.7f);
+ float baseline = centreY - (textPaint.descent() + textPaint.ascent()) / 2f;
+ canvas.drawText(label, centreX, baseline, textPaint);
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ switch (event.getActionMasked()) {
+ case MotionEvent.ACTION_DOWN:
+ updatePressed(true);
+ return true;
+
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_CANCEL:
+ updatePressed(false);
+ return true;
+
+ default:
+ return super.onTouchEvent(event);
+ }
+ }
+
+ private void updatePressed(boolean value) {
+ if (pressed == value) {
+ return;
+ }
+
+ pressed = value;
+ invalidate();
+
+ if (listener != null) {
+ listener.onPressed(key, value);
+ }
+ }
+}
diff --git a/Brovan/Android/java/dev/brovan/input/ControlOverlay.java b/Brovan/Android/java/dev/brovan/input/ControlOverlay.java
new file mode 100644
index 0000000..a3df321
--- /dev/null
+++ b/Brovan/Android/java/dev/brovan/input/ControlOverlay.java
@@ -0,0 +1,165 @@
+package dev.brovan.input;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.Gravity;
+import android.view.View;
+import android.widget.FrameLayout;
+
+import java.util.Set;
+
+/**
+ * Heads-up touch controls drawn over the guest. Only the controls themselves consume touches, so
+ * anywhere else on screen still reaches the guest as an ordinary mouse event.
+ */
+public class ControlOverlay extends FrameLayout {
+
+ public enum Scheme {
+ NONE("Touch only"),
+ WASD("Joystick (WASD)"),
+ ARROWS("Joystick (arrows)"),
+ DPAD("D-pad (arrows)"),
+ TOUCHPAD("Mouse touchpad");
+
+ private final String label;
+
+ Scheme(String label) {
+ this.label = label;
+ }
+
+ public String label() {
+ return label;
+ }
+ }
+
+ private final KeyEmitter keys = new KeyEmitter();
+
+ private Scheme scheme = Scheme.NONE;
+
+ public ControlOverlay(Context context) {
+ super(context);
+ setClipChildren(false);
+ }
+
+ public ControlOverlay(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ setClipChildren(false);
+ }
+
+ public Scheme scheme() {
+ return scheme;
+ }
+
+ public void apply(Scheme value) {
+ scheme = value;
+ keys.releaseAll();
+ removeAllViews();
+
+ switch (value) {
+ case WASD:
+ addJoystick(VirtualKey.W, VirtualKey.S, VirtualKey.A, VirtualKey.D);
+ addActionButtons();
+ break;
+
+ case ARROWS:
+ addJoystick(VirtualKey.UP, VirtualKey.DOWN, VirtualKey.LEFT, VirtualKey.RIGHT);
+ addActionButtons();
+ break;
+
+ case DPAD:
+ addDpad();
+ addActionButtons();
+ break;
+
+ case TOUCHPAD:
+ addTouchpad();
+ break;
+
+ case NONE:
+ default:
+ break;
+ }
+ }
+
+ public void releaseAll() {
+ keys.releaseAll();
+ }
+
+ private void addJoystick(VirtualKey up, VirtualKey down, VirtualKey left, VirtualKey right) {
+ JoystickView joystick = new JoystickView(getContext());
+ joystick.setKeys(up, down, left, right);
+ joystick.setListener(new JoystickView.Listener() {
+ @Override
+ public void onDirections(Set directions) {
+ keys.apply(directions);
+ }
+ });
+
+ int size = dp(180);
+ LayoutParams params = new LayoutParams(size, size, Gravity.BOTTOM | Gravity.START);
+ params.leftMargin = dp(24);
+ params.bottomMargin = dp(24);
+ addView(joystick, params);
+ }
+
+ private void addDpad() {
+ int button = dp(62);
+ int gap = dp(4);
+ int originX = dp(28);
+ int originY = dp(28);
+
+ addDpadButton(VirtualKey.UP, "▲", originX + button + gap, originY + (button + gap) * 2, button);
+ addDpadButton(VirtualKey.DOWN, "▼", originX + button + gap, originY, button);
+ addDpadButton(VirtualKey.LEFT, "◀", originX, originY + button + gap, button);
+ addDpadButton(VirtualKey.RIGHT, "▶", originX + (button + gap) * 2, originY + button + gap, button);
+ }
+
+ private void addDpadButton(VirtualKey key, String label, int leftMargin, int bottomMargin, int size) {
+ LayoutParams params = new LayoutParams(size, size, Gravity.BOTTOM | Gravity.START);
+ params.leftMargin = leftMargin;
+ params.bottomMargin = bottomMargin;
+ addView(button(key, label), params);
+ }
+
+ private void addActionButtons() {
+ int size = dp(70);
+ int gap = dp(10);
+ int originX = dp(28);
+ int originY = dp(28);
+
+ addActionButton(VirtualKey.SPACE, "A", originX + size + gap, originY, size);
+ addActionButton(VirtualKey.ENTER, "B", originX, originY + size + gap, size);
+ addActionButton(VirtualKey.SHIFT, "X", originX + (size + gap) * 2, originY + size + gap, size);
+ addActionButton(VirtualKey.ESCAPE, "Esc", originX + size + gap, originY + (size + gap) * 2, size);
+ }
+
+ private void addActionButton(VirtualKey key, String label, int rightMargin, int bottomMargin, int size) {
+ LayoutParams params = new LayoutParams(size, size, Gravity.BOTTOM | Gravity.END);
+ params.rightMargin = rightMargin;
+ params.bottomMargin = bottomMargin;
+ addView(button(key, label), params);
+ }
+
+ private View button(VirtualKey key, String label) {
+ ActionButtonView view = new ActionButtonView(getContext(), key, label);
+ view.setListener((pressedKey, down) -> {
+ if (down) {
+ keys.press(pressedKey);
+ } else {
+ keys.release(pressedKey);
+ }
+ });
+ return view;
+ }
+
+ private void addTouchpad() {
+ LayoutParams params = new LayoutParams(dp(260), dp(170), Gravity.BOTTOM | Gravity.END);
+ params.rightMargin = dp(24);
+ params.bottomMargin = dp(24);
+ addView(new TouchpadView(getContext()), params);
+ }
+
+ private int dp(int value) {
+ return Math.round(value * getResources().getDisplayMetrics().density);
+ }
+}
diff --git a/Brovan/Android/java/dev/brovan/input/JoystickView.java b/Brovan/Android/java/dev/brovan/input/JoystickView.java
new file mode 100644
index 0000000..63f943d
--- /dev/null
+++ b/Brovan/Android/java/dev/brovan/input/JoystickView.java
@@ -0,0 +1,132 @@
+package dev.brovan.input;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.view.MotionEvent;
+import android.view.View;
+
+import java.util.EnumSet;
+import java.util.Set;
+
+/**
+ * Analog-looking stick that resolves to the eight compass directions, because the guest only has keys to
+ * press. The dead zone stops a resting thumb from walking the character.
+ */
+public class JoystickView extends View {
+
+ public interface Listener {
+ void onDirections(Set directions);
+ }
+
+ private static final float DEAD_ZONE = 0.28f;
+
+ private final Paint basePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint ringPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint knobPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+
+ private VirtualKey up = VirtualKey.W;
+ private VirtualKey down = VirtualKey.S;
+ private VirtualKey left = VirtualKey.A;
+ private VirtualKey right = VirtualKey.D;
+
+ private Listener listener;
+ private float knobX;
+ private float knobY;
+ private boolean active;
+
+ public JoystickView(Context context) {
+ super(context);
+
+ basePaint.setColor(Color.argb(70, 255, 255, 255));
+ ringPaint.setColor(Color.argb(120, 255, 255, 255));
+ ringPaint.setStyle(Paint.Style.STROKE);
+ ringPaint.setStrokeWidth(3f);
+ knobPaint.setColor(Color.argb(170, 255, 255, 255));
+ }
+
+ public void setListener(Listener listener) {
+ this.listener = listener;
+ }
+
+ public void setKeys(VirtualKey up, VirtualKey down, VirtualKey left, VirtualKey right) {
+ this.up = up;
+ this.down = down;
+ this.left = left;
+ this.right = right;
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ float centreX = getWidth() / 2f;
+ float centreY = getHeight() / 2f;
+ float radius = Math.min(centreX, centreY) - 6f;
+
+ canvas.drawCircle(centreX, centreY, radius, basePaint);
+ canvas.drawCircle(centreX, centreY, radius, ringPaint);
+
+ float x = active ? centreX + knobX * radius : centreX;
+ float y = active ? centreY + knobY * radius : centreY;
+ canvas.drawCircle(x, y, radius * 0.38f, knobPaint);
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ switch (event.getActionMasked()) {
+ case MotionEvent.ACTION_DOWN:
+ case MotionEvent.ACTION_MOVE:
+ active = true;
+ track(event.getX(), event.getY());
+ return true;
+
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_CANCEL:
+ active = false;
+ knobX = 0f;
+ knobY = 0f;
+ emit(EnumSet.noneOf(VirtualKey.class));
+ invalidate();
+ return true;
+
+ default:
+ return super.onTouchEvent(event);
+ }
+ }
+
+ private void track(float touchX, float touchY) {
+ float centreX = getWidth() / 2f;
+ float centreY = getHeight() / 2f;
+ float radius = Math.min(centreX, centreY);
+
+ float dx = (touchX - centreX) / radius;
+ float dy = (touchY - centreY) / radius;
+
+ float length = (float) Math.hypot(dx, dy);
+ if (length > 1f) {
+ dx /= length;
+ dy /= length;
+ length = 1f;
+ }
+
+ knobX = dx;
+ knobY = dy;
+
+ Set directions = EnumSet.noneOf(VirtualKey.class);
+ if (length >= DEAD_ZONE) {
+ if (dy <= -DEAD_ZONE) directions.add(up);
+ if (dy >= DEAD_ZONE) directions.add(down);
+ if (dx <= -DEAD_ZONE) directions.add(left);
+ if (dx >= DEAD_ZONE) directions.add(right);
+ }
+
+ emit(directions);
+ invalidate();
+ }
+
+ private void emit(Set directions) {
+ if (listener != null) {
+ listener.onDirections(directions);
+ }
+ }
+}
diff --git a/Brovan/Android/java/dev/brovan/input/KeyEmitter.java b/Brovan/Android/java/dev/brovan/input/KeyEmitter.java
new file mode 100644
index 0000000..d67b499
--- /dev/null
+++ b/Brovan/Android/java/dev/brovan/input/KeyEmitter.java
@@ -0,0 +1,44 @@
+package dev.brovan.input;
+
+import java.util.EnumSet;
+import java.util.Set;
+
+import dev.brovan.BrovanNative;
+
+/**
+ * Keeps track of which keys the touch controls are currently holding, so a control that changes direction
+ * releases what it was holding instead of leaving the guest with a key stuck down.
+ */
+public final class KeyEmitter {
+
+ private final Set held = EnumSet.noneOf(VirtualKey.class);
+
+ public void press(VirtualKey key) {
+ if (held.add(key)) {
+ BrovanNative.injectKey(true, key.code(), key.scanCode());
+ }
+ }
+
+ public void release(VirtualKey key) {
+ if (held.remove(key)) {
+ BrovanNative.injectKey(false, key.code(), key.scanCode());
+ }
+ }
+
+ /** Presses everything in {@code wanted} and releases anything held that is no longer wanted. */
+ public void apply(Set wanted) {
+ for (VirtualKey key : EnumSet.copyOf(held)) {
+ if (!wanted.contains(key)) {
+ release(key);
+ }
+ }
+
+ for (VirtualKey key : wanted) {
+ press(key);
+ }
+ }
+
+ public void releaseAll() {
+ apply(EnumSet.noneOf(VirtualKey.class));
+ }
+}
diff --git a/Brovan/Android/java/dev/brovan/input/TouchpadView.java b/Brovan/Android/java/dev/brovan/input/TouchpadView.java
new file mode 100644
index 0000000..fa6a925
--- /dev/null
+++ b/Brovan/Android/java/dev/brovan/input/TouchpadView.java
@@ -0,0 +1,95 @@
+package dev.brovan.input;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.view.MotionEvent;
+import android.view.View;
+
+import dev.brovan.BrovanNative;
+
+/**
+ * Relative pointer control. Games that hide the cursor and read movement deltas cannot use absolute
+ * touch positions, so this accumulates a virtual cursor and reports it as ordinary mouse motion.
+ */
+public class TouchpadView extends View {
+
+ private static final float SPEED = 1.6f;
+ private static final int TAP_SLOP = 12;
+ private static final int TAP_TIMEOUT_MS = 220;
+
+ private final Paint hintPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+
+ private float cursorX;
+ private float cursorY;
+ private float lastX;
+ private float lastY;
+ private float travelled;
+ private long downAt;
+
+ public TouchpadView(Context context) {
+ super(context);
+ hintPaint.setColor(Color.argb(40, 255, 255, 255));
+ hintPaint.setStyle(Paint.Style.STROKE);
+ hintPaint.setStrokeWidth(2f);
+ }
+
+ @Override
+ protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) {
+ super.onSizeChanged(width, height, oldWidth, oldHeight);
+ cursorX = width / 2f;
+ cursorY = height / 2f;
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ canvas.drawRoundRect(4f, 4f, getWidth() - 4f, getHeight() - 4f, 18f, 18f, hintPaint);
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ switch (event.getActionMasked()) {
+ case MotionEvent.ACTION_DOWN:
+ lastX = event.getX();
+ lastY = event.getY();
+ travelled = 0f;
+ downAt = event.getEventTime();
+ return true;
+
+ case MotionEvent.ACTION_MOVE: {
+ float dx = (event.getX() - lastX) * SPEED;
+ float dy = (event.getY() - lastY) * SPEED;
+ lastX = event.getX();
+ lastY = event.getY();
+ travelled += Math.abs(dx) + Math.abs(dy);
+
+ cursorX = clamp(cursorX + dx, getWidth());
+ cursorY = clamp(cursorY + dy, getHeight());
+ BrovanNative.injectPointer(BrovanNative.POINTER_MOVE, BrovanNative.BUTTON_LEFT,
+ (int) cursorX, (int) cursorY, 0);
+ return true;
+ }
+
+ case MotionEvent.ACTION_UP:
+ if (travelled < TAP_SLOP && event.getEventTime() - downAt < TAP_TIMEOUT_MS) {
+ click();
+ }
+ return true;
+
+ default:
+ return super.onTouchEvent(event);
+ }
+ }
+
+ private void click() {
+ int x = (int) cursorX;
+ int y = (int) cursorY;
+ BrovanNative.injectPointer(BrovanNative.POINTER_DOWN, BrovanNative.BUTTON_LEFT, x, y, BrovanNative.MK_LBUTTON);
+ BrovanNative.injectPointer(BrovanNative.POINTER_UP, BrovanNative.BUTTON_LEFT, x, y, 0);
+ }
+
+ private static float clamp(float value, int limit) {
+ return Math.max(0f, Math.min(value, limit));
+ }
+}
diff --git a/Brovan/Android/java/dev/brovan/input/VirtualKey.java b/Brovan/Android/java/dev/brovan/input/VirtualKey.java
new file mode 100644
index 0000000..6b971db
--- /dev/null
+++ b/Brovan/Android/java/dev/brovan/input/VirtualKey.java
@@ -0,0 +1,46 @@
+package dev.brovan.input;
+
+/**
+ * Win32 virtual-key codes paired with their set-1 scan codes. Games read either one, so both have to be
+ * right when the key comes from a touch control rather than a real keyboard.
+ */
+public enum VirtualKey {
+
+ UP(0x26, 0x48),
+ DOWN(0x28, 0x50),
+ LEFT(0x25, 0x4B),
+ RIGHT(0x27, 0x4D),
+
+ W(0x57, 0x11),
+ A(0x41, 0x1E),
+ S(0x53, 0x1F),
+ D(0x44, 0x20),
+ Q(0x51, 0x10),
+ E(0x45, 0x12),
+ F(0x46, 0x21),
+ R(0x52, 0x13),
+
+ SPACE(0x20, 0x39),
+ ENTER(0x0D, 0x1C),
+ ESCAPE(0x1B, 0x01),
+ SHIFT(0xA0, 0x2A),
+ CONTROL(0xA2, 0x1D),
+ ALT(0x12, 0x38),
+ TAB(0x09, 0x0F);
+
+ private final int code;
+ private final int scanCode;
+
+ VirtualKey(int code, int scanCode) {
+ this.code = code;
+ this.scanCode = scanCode;
+ }
+
+ public int code() {
+ return code;
+ }
+
+ public int scanCode() {
+ return scanCode;
+ }
+}
diff --git a/Brovan/Android/jni/CMakeLists.txt b/Brovan/Android/jni/CMakeLists.txt
new file mode 100644
index 0000000..ad14310
--- /dev/null
+++ b/Brovan/Android/jni/CMakeLists.txt
@@ -0,0 +1,17 @@
+cmake_minimum_required(VERSION 3.22)
+project(brovan_jni C)
+
+add_library(brovan_jni SHARED brovan_jni.c)
+
+# libBrovan.so is the NativeAOT shared library published from Brovan.csproj; BROVAN_LIB_DIR must point at
+# the jniLibs directory it was copied into for the current ABI. find_library is deliberately not used: the
+# NDK toolchain sets CMAKE_FIND_ROOT_PATH_MODE_LIBRARY to ONLY, so it searches the sysroot and ignores PATHS.
+set(BROVAN_LIB "${BROVAN_LIB_DIR}/libBrovan.so")
+if(NOT EXISTS "${BROVAN_LIB}")
+ message(FATAL_ERROR "libBrovan.so not found at ${BROVAN_LIB}; publish Brovan for linux-bionic-arm64 first")
+endif()
+
+target_link_libraries(brovan_jni "${BROVAN_LIB}" android log)
+
+# Android 16 warns about 4K-aligned libraries and 16K-page devices reject them outright.
+target_link_options(brovan_jni PRIVATE "-Wl,-z,max-page-size=16384")
diff --git a/Brovan/Android/jni/brovan_jni.c b/Brovan/Android/jni/brovan_jni.c
new file mode 100644
index 0000000..4ab6e10
--- /dev/null
+++ b/Brovan/Android/jni/brovan_jni.c
@@ -0,0 +1,276 @@
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define TAG "BrovanJni"
+#define METHOD(name) Java_dev_brovan_BrovanNative_native##name
+
+extern int brovan_init(const char *baseDirectory);
+extern void brovan_set_log_sink(void *sink);
+extern void brovan_set_exit_sink(void *sink);
+extern void brovan_set_verbose(int enabled);
+extern void brovan_set_surface(void *nativeWindow, int width, int height, int densityDpi);
+extern void brovan_clear_surface(void);
+extern int brovan_start(const char *binaryPath, const char *guestCommandLine, const char *workingDirectory,
+ const char *commands, int backend, int networkMode);
+extern int brovan_is_running(void);
+extern void brovan_send_command(const char *command);
+extern void brovan_request_close(void);
+extern void brovan_request_repaint(void);
+extern void brovan_inject_pointer(int action, int button, int x, int y, int buttons);
+extern void brovan_inject_scroll(int delta, int x, int y, int buttons);
+extern void brovan_inject_key(int down, int virtualKey, int scanCode);
+extern void brovan_inject_focus(int focused);
+extern int brovan_get_window_title(char *buffer, int capacity);
+extern int brovan_list_windows(char *buffer, int capacity);
+extern void brovan_select_window(unsigned long long hwnd);
+
+static JavaVM *g_vm;
+static jclass g_callbacks;
+static jmethodID g_onLog;
+static jmethodID g_onExit;
+
+typedef struct {
+ JNIEnv *env;
+ int attached;
+} Attachment;
+
+static Attachment attach(void) {
+ Attachment attachment = {NULL, 0};
+
+ if (g_vm == NULL) {
+ return attachment;
+ }
+
+ if ((*g_vm)->GetEnv(g_vm, (void **)&attachment.env, JNI_VERSION_1_6) == JNI_OK) {
+ return attachment;
+ }
+
+ /* Guest and emulator threads are created by the .NET runtime and are unknown to the JVM until
+ attached; any JNI call from them without this crashes the process. */
+ if ((*g_vm)->AttachCurrentThread(g_vm, &attachment.env, NULL) == JNI_OK) {
+ attachment.attached = 1;
+ }
+
+ return attachment;
+}
+
+static void detach(Attachment attachment) {
+ if (attachment.attached) {
+ (*g_vm)->DetachCurrentThread(g_vm);
+ }
+}
+
+static void on_log(const char *text) {
+ if (text == NULL || g_callbacks == NULL || g_onLog == NULL) {
+ return;
+ }
+
+ Attachment attachment = attach();
+ if (attachment.env == NULL) {
+ return;
+ }
+
+ jstring line = (*attachment.env)->NewStringUTF(attachment.env, text);
+ if (line != NULL) {
+ (*attachment.env)->CallStaticVoidMethod(attachment.env, g_callbacks, g_onLog, line);
+ (*attachment.env)->DeleteLocalRef(attachment.env, line);
+ }
+
+ detach(attachment);
+}
+
+static void on_exit_guest(int reason) {
+ if (g_callbacks == NULL || g_onExit == NULL) {
+ return;
+ }
+
+ Attachment attachment = attach();
+ if (attachment.env == NULL) {
+ return;
+ }
+
+ (*attachment.env)->CallStaticVoidMethod(attachment.env, g_callbacks, g_onExit, (jint)reason);
+ detach(attachment);
+}
+
+/* Borrowed UTF-8 view of a jstring; release() must be called with the same jstring. */
+static const char *borrow(JNIEnv *env, jstring value) {
+ return value == NULL ? NULL : (*env)->GetStringUTFChars(env, value, NULL);
+}
+
+static void release(JNIEnv *env, jstring value, const char *borrowed) {
+ if (value != NULL && borrowed != NULL) {
+ (*env)->ReleaseStringUTFChars(env, value, borrowed);
+ }
+}
+
+static jstring read_into_string(JNIEnv *env, int (*reader)(char *, int), int capacity) {
+ char *buffer = malloc((size_t)capacity);
+ if (buffer == NULL) {
+ return (*env)->NewStringUTF(env, "");
+ }
+
+ buffer[0] = '\0';
+ reader(buffer, capacity);
+
+ jstring result = (*env)->NewStringUTF(env, buffer);
+ free(buffer);
+ return result;
+}
+
+JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) {
+ (void)reserved;
+ g_vm = vm;
+ return JNI_VERSION_1_6;
+}
+
+JNIEXPORT jint JNICALL METHOD(Init)(JNIEnv *env, jclass clazz, jstring baseDirectory) {
+ if (g_callbacks == NULL) {
+ g_callbacks = (jclass)(*env)->NewGlobalRef(env, clazz);
+ g_onLog = (*env)->GetStaticMethodID(env, clazz, "onNativeLog", "(Ljava/lang/String;)V");
+ g_onExit = (*env)->GetStaticMethodID(env, clazz, "onNativeExit", "(I)V");
+ (*env)->ExceptionClear(env);
+ }
+
+ const char *path = borrow(env, baseDirectory);
+ if (path == NULL) {
+ return -3;
+ }
+
+ int status = brovan_init(path);
+ release(env, baseDirectory, path);
+
+ if (status == 0) {
+ brovan_set_log_sink((void *)&on_log);
+ brovan_set_exit_sink((void *)&on_exit_guest);
+ } else {
+ __android_log_print(ANDROID_LOG_ERROR, TAG, "brovan_init failed: %d", status);
+ }
+
+ return status;
+}
+
+JNIEXPORT void JNICALL METHOD(SetSurface)(JNIEnv *env, jclass clazz, jobject surface, jint densityDpi) {
+ (void)clazz;
+
+ if (surface == NULL) {
+ brovan_clear_surface();
+ return;
+ }
+
+ ANativeWindow *window = ANativeWindow_fromSurface(env, surface);
+ if (window == NULL) {
+ __android_log_print(ANDROID_LOG_ERROR, TAG, "ANativeWindow_fromSurface returned NULL");
+ return;
+ }
+
+ /* brovan_set_surface takes its own reference, so the one fromSurface returned is ours to drop. */
+ brovan_set_surface(window, ANativeWindow_getWidth(window), ANativeWindow_getHeight(window), densityDpi);
+ ANativeWindow_release(window);
+}
+
+JNIEXPORT void JNICALL METHOD(ClearSurface)(JNIEnv *env, jclass clazz) {
+ (void)env;
+ (void)clazz;
+ brovan_clear_surface();
+}
+
+JNIEXPORT jint JNICALL METHOD(Start)(JNIEnv *env, jclass clazz, jstring binaryPath, jstring guestCommandLine,
+ jstring workingDirectory, jstring commands, jint backend, jint networkMode) {
+ (void)clazz;
+
+ const char *path = borrow(env, binaryPath);
+ const char *cmdline = borrow(env, guestCommandLine);
+ const char *cwd = borrow(env, workingDirectory);
+ const char *debuggerCommands = borrow(env, commands);
+
+ int status = path != NULL ? brovan_start(path, cmdline, cwd, debuggerCommands, backend, networkMode) : -3;
+
+ release(env, binaryPath, path);
+ release(env, guestCommandLine, cmdline);
+ release(env, workingDirectory, cwd);
+ release(env, commands, debuggerCommands);
+
+ return status;
+}
+
+JNIEXPORT void JNICALL METHOD(SetVerbose)(JNIEnv *env, jclass clazz, jint enabled) {
+ (void)env;
+ (void)clazz;
+ brovan_set_verbose(enabled);
+}
+
+JNIEXPORT void JNICALL METHOD(SendCommand)(JNIEnv *env, jclass clazz, jstring command) {
+ (void)clazz;
+
+ const char *text = borrow(env, command);
+ if (text == NULL) {
+ return;
+ }
+
+ brovan_send_command(text);
+ release(env, command, text);
+}
+
+JNIEXPORT jint JNICALL METHOD(IsRunning)(JNIEnv *env, jclass clazz) {
+ (void)env;
+ (void)clazz;
+ return brovan_is_running();
+}
+
+JNIEXPORT void JNICALL METHOD(RequestClose)(JNIEnv *env, jclass clazz) {
+ (void)env;
+ (void)clazz;
+ brovan_request_close();
+}
+
+JNIEXPORT void JNICALL METHOD(RequestRepaint)(JNIEnv *env, jclass clazz) {
+ (void)env;
+ (void)clazz;
+ brovan_request_repaint();
+}
+
+JNIEXPORT void JNICALL METHOD(InjectPointer)(JNIEnv *env, jclass clazz, jint action, jint button,
+ jint x, jint y, jint buttons) {
+ (void)env;
+ (void)clazz;
+ brovan_inject_pointer(action, button, x, y, buttons);
+}
+
+JNIEXPORT void JNICALL METHOD(InjectScroll)(JNIEnv *env, jclass clazz, jint delta, jint x, jint y, jint buttons) {
+ (void)env;
+ (void)clazz;
+ brovan_inject_scroll(delta, x, y, buttons);
+}
+
+JNIEXPORT void JNICALL METHOD(InjectKey)(JNIEnv *env, jclass clazz, jint down, jint virtualKey, jint scanCode) {
+ (void)env;
+ (void)clazz;
+ brovan_inject_key(down, virtualKey, scanCode);
+}
+
+JNIEXPORT void JNICALL METHOD(InjectFocus)(JNIEnv *env, jclass clazz, jint focused) {
+ (void)env;
+ (void)clazz;
+ brovan_inject_focus(focused);
+}
+
+JNIEXPORT void JNICALL METHOD(SelectWindow)(JNIEnv *env, jclass clazz, jlong hwnd) {
+ (void)env;
+ (void)clazz;
+ brovan_select_window((unsigned long long)hwnd);
+}
+
+JNIEXPORT jstring JNICALL METHOD(ListWindows)(JNIEnv *env, jclass clazz) {
+ (void)clazz;
+ return read_into_string(env, brovan_list_windows, 16384);
+}
+
+JNIEXPORT jstring JNICALL METHOD(GetWindowTitle)(JNIEnv *env, jclass clazz) {
+ (void)clazz;
+ return read_into_string(env, brovan_get_window_title, 512);
+}
diff --git a/Brovan/Brovan.csproj b/Brovan/Brovan.csproj
index 988188c..3e2f385 100644
--- a/Brovan/Brovan.csproj
+++ b/Brovan/Brovan.csproj
@@ -20,20 +20,20 @@
False
-
+
-
+ Image.Length ||
Image[PeHeaderOffset] != (byte)'P' || Image[PeHeaderOffset + 1] != (byte)'E')
@@ -49,7 +49,8 @@
Image[DllCharacteristicsOffset + 1] = (byte)(DllCharacteristics >> 8);
File.WriteAllBytes(FilePath, Image);
Log.LogMessage(MessageImportance.High, "Cleared the GuardCF flag in " + Path.GetFileName(FilePath) + " so Unicorn can run without a CFG-disabling restart.");
- ]]>
+ ]]>
+
@@ -66,7 +67,8 @@
-
+
diff --git a/Brovan/Core/Emulation/OS/SharedHelpers/WindowManager/WindowManager.cs b/Brovan/Core/Emulation/OS/SharedHelpers/WindowManager/WindowManager.cs
index 758778f..9e358f0 100644
--- a/Brovan/Core/Emulation/OS/SharedHelpers/WindowManager/WindowManager.cs
+++ b/Brovan/Core/Emulation/OS/SharedHelpers/WindowManager/WindowManager.cs
@@ -228,6 +228,12 @@ private static void Ensure()
_screenWidth = FallbackScreenWidth;
_screenHeight = FallbackScreenHeight;
+ if (Brovan.Android.AndroidHost.IsActive)
+ {
+ EnsureFromAndroidSurface();
+ return;
+ }
+
if (OperatingSystem.IsLinux())
{
EnsureFromX11();
@@ -281,6 +287,24 @@ private static void Ensure()
}
}
+ private static void EnsureFromAndroidSurface()
+ {
+ int width = Brovan.Android.AndroidHost.Width;
+ int height = Brovan.Android.AndroidHost.Height;
+ if (width > 0 && height > 0)
+ {
+ _screenWidth = width;
+ _screenHeight = height;
+ }
+
+ uint density = (uint)Brovan.Android.AndroidHost.DensityDpi;
+ if (density >= MinimumDpi && density <= MaximumDpi)
+ {
+ _systemDpi = density;
+ _rawDpi = density;
+ }
+ }
+
private static void EnsureFromX11()
{
IntPtr display = IntPtr.Zero;
@@ -432,6 +456,8 @@ public struct GdiPoint
public struct GdiPrimitive
{
+ public ulong Hwnd;
+
public GdiPrimitiveKind Kind;
public int X1;
public int Y1;
@@ -541,7 +567,9 @@ public static IDisplayConnection Create()
{
Func factory;
- if (OperatingSystem.IsWindows())
+ if (Brovan.Android.AndroidHost.IsActive)
+ factory = () => new Brovan.Android.AndroidWinManager();
+ else if (OperatingSystem.IsWindows())
factory = () => new WindowsWinManager();
else if (OperatingSystem.IsLinux())
factory = () => new LinuxWinManager();
diff --git a/Brovan/Core/Emulation/OS/Windows/WinSyscallsHelper.cs b/Brovan/Core/Emulation/OS/Windows/WinSyscallsHelper.cs
index e2d48c6..e760a03 100644
--- a/Brovan/Core/Emulation/OS/Windows/WinSyscallsHelper.cs
+++ b/Brovan/Core/Emulation/OS/Windows/WinSyscallsHelper.cs
@@ -3364,6 +3364,7 @@ public void EnqueueGdiLine(ulong Hwnd, int X1, int Y1, int X2, int Y2, uint PenC
if (DesktopDisplay is GuiThreadManager guiManager)
guiManager.EnqueueGdiPrimitive(new GdiPrimitive
{
+ Hwnd = Hwnd,
Kind = GdiPrimitiveKind.Line,
X1 = X1,
Y1 = Y1,
@@ -3379,6 +3380,7 @@ public void EnqueueGdiFillRect(ulong Hwnd, int Left, int Top, int Right, int Bot
if (DesktopDisplay is GuiThreadManager guiManager)
guiManager.EnqueueGdiPrimitive(new GdiPrimitive
{
+ Hwnd = Hwnd,
Kind = GdiPrimitiveKind.FillRect,
X1 = Left,
Y1 = Top,
@@ -3395,6 +3397,7 @@ public void EnqueueGdiShape(ulong Hwnd, GdiPrimitiveKind Kind, int Left, int Top
if (DesktopDisplay is GuiThreadManager guiManager)
guiManager.EnqueueGdiPrimitive(new GdiPrimitive
{
+ Hwnd = Hwnd,
Kind = Kind,
X1 = Left,
Y1 = Top,
@@ -3414,6 +3417,7 @@ public void EnqueueGdiPoly(ulong Hwnd, GdiPrimitiveKind Kind, GdiPoint[] Points,
if (DesktopDisplay is GuiThreadManager guiManager)
guiManager.EnqueueGdiPrimitive(new GdiPrimitive
{
+ Hwnd = Hwnd,
Kind = Kind,
Points = Points,
Pen = new GdiPenDescriptor { ColorRef = PenColor, Width = PenWidth },
diff --git a/Brovan/GeneralHelper.cs b/Brovan/GeneralHelper.cs
index f32f4d7..2c7dce2 100644
--- a/Brovan/GeneralHelper.cs
+++ b/Brovan/GeneralHelper.cs
@@ -244,12 +244,47 @@ public static string GetWindowsLibPath(string Library, bool IsWow64 = false, Bin
string Result = Path.Combine(BasePath, Library);
+ if (!IsWindows && !File.Exists(Result))
+ {
+ string Resolved = ResolveShippedLibraryCase(BasePath, Library);
+ if (Resolved != null)
+ return Resolved;
+ }
+
if (!File.Exists(Result))
PrintHighlight($"[-] Windows library not found: {Result}", true);
return Result;
}
+ private static readonly Dictionary> ShippedLibraryIndex = new(StringComparer.Ordinal);
+
+ private static string ResolveShippedLibraryCase(string Directory, string FileName)
+ {
+ Dictionary Index;
+
+ lock (ShippedLibraryIndex)
+ {
+ if (!ShippedLibraryIndex.TryGetValue(Directory, out Index))
+ {
+ Index = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ try
+ {
+ foreach (string Entry in System.IO.Directory.EnumerateFiles(Directory))
+ Index[Path.GetFileName(Entry)] = Entry;
+ }
+ catch (DirectoryNotFoundException)
+ {
+ }
+
+ ShippedLibraryIndex[Directory] = Index;
+ }
+ }
+
+ return Index.TryGetValue(FileName, out string Actual) ? Actual : null;
+ }
+
public static bool DumpApiSetMap()
{
try
diff --git a/Brovan/Program.cs b/Brovan/Program.cs
index 5e6244b..67ed73c 100644
--- a/Brovan/Program.cs
+++ b/Brovan/Program.cs
@@ -208,7 +208,7 @@ private static string DecodeArgumentValue(string Value)
}
}
- private static string[] SplitCommandLine(string CommandLine)
+ internal static string[] SplitCommandLine(string CommandLine)
{
List Arguments = new List();
if (string.IsNullOrWhiteSpace(CommandLine))
From 951c9e21eedc6c393a399a13dcff8ec5bad610c7 Mon Sep 17 00:00:00 2001
From: AdvDebug <90452585+AdvDebug@users.noreply.github.com>
Date: Sun, 2 Aug 2026 19:20:13 +0300
Subject: [PATCH 2/3] Fix Vulkan library resolution on Android host
---
Brovan/Core/Emulation/UnicornBinding/Native.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Brovan/Core/Emulation/UnicornBinding/Native.cs b/Brovan/Core/Emulation/UnicornBinding/Native.cs
index d0bd326..358d522 100644
--- a/Brovan/Core/Emulation/UnicornBinding/Native.cs
+++ b/Brovan/Core/Emulation/UnicornBinding/Native.cs
@@ -166,7 +166,7 @@ private static IntPtr Resolve(string LibName, Assembly Asm, DllImportSearchPath?
throw new PlatformNotSupportedException("Brovan currently supports resolving unicorn for Windows and Linux only.");
}
- if (string.Equals(LibName, "vulkan-1.dll", StringComparison.OrdinalIgnoreCase) && GeneralHelper.IsLinux)
+ if (string.Equals(LibName, "vulkan-1.dll", StringComparison.OrdinalIgnoreCase) && (GeneralHelper.IsLinux || Android.AndroidHost.IsActive))
{
if (NativeLibrary.TryLoad("libvulkan.so.1", out IntPtr handle))
return handle;
From b5addf49dbfbd300edbb17439db0893afb0de9af Mon Sep 17 00:00:00 2001
From: AdvDebug <90452585+AdvDebug@users.noreply.github.com>
Date: Sun, 2 Aug 2026 19:56:08 +0300
Subject: [PATCH 3/3] Revert back Android check and clarify IsLinux
---
Brovan/Core/Emulation/UnicornBinding/Native.cs | 2 +-
Brovan/GeneralHelper.cs | 3 +++
2 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/Brovan/Core/Emulation/UnicornBinding/Native.cs b/Brovan/Core/Emulation/UnicornBinding/Native.cs
index 358d522..d0bd326 100644
--- a/Brovan/Core/Emulation/UnicornBinding/Native.cs
+++ b/Brovan/Core/Emulation/UnicornBinding/Native.cs
@@ -166,7 +166,7 @@ private static IntPtr Resolve(string LibName, Assembly Asm, DllImportSearchPath?
throw new PlatformNotSupportedException("Brovan currently supports resolving unicorn for Windows and Linux only.");
}
- if (string.Equals(LibName, "vulkan-1.dll", StringComparison.OrdinalIgnoreCase) && (GeneralHelper.IsLinux || Android.AndroidHost.IsActive))
+ if (string.Equals(LibName, "vulkan-1.dll", StringComparison.OrdinalIgnoreCase) && GeneralHelper.IsLinux)
{
if (NativeLibrary.TryLoad("libvulkan.so.1", out IntPtr handle))
return handle;
diff --git a/Brovan/GeneralHelper.cs b/Brovan/GeneralHelper.cs
index 2c7dce2..18a2d62 100644
--- a/Brovan/GeneralHelper.cs
+++ b/Brovan/GeneralHelper.cs
@@ -166,6 +166,9 @@ internal class NativeUnixImports
internal class GeneralHelper
{
public static bool IsWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
+ ///
+ /// returns true for android too.
+ ///
public static bool IsLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
public static string WindowsLibsPath = Path.Combine(AppContext.BaseDirectory, "WindowsLibs");