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");