From 935f3114fc695b62c2a5159b69d82b45ff7c627f Mon Sep 17 00:00:00 2001 From: Tomas Date: Wed, 30 Sep 2026 14:47:08 +0300 Subject: [PATCH 01/10] Add the on-device screen streaming server A small Java server, built with Gradle into a dex jar and pushed to the device, that mirrors a display into an ImageReader, encodes each frame as JPEG and writes it to an abstract unix socket. It also reads control messages back up the same socket and injects touch, scroll, key and text events. The jar is a build output and is not committed. Cherry picked from the livestream branch, at its latest state: the null virtualDisplay fix and the protocol version reset are folded in. Co-Authored-By: Claude Opus 5 --- External/UnityLogcatServer/.gitignore | 3 + External/UnityLogcatServer/README.md | 309 +++++++++++++ External/UnityLogcatServer/build.gradle | 283 ++++++++++++ External/UnityLogcatServer/gradle.properties | 22 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 58694 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + External/UnityLogcatServer/gradlew | 183 ++++++++ External/UnityLogcatServer/gradlew.bat | 103 +++++ External/UnityLogcatServer/settings.gradle | 1 + .../android/logcat/server/ControlReader.java | 198 +++++++++ .../android/logcat/server/DisplayInfo.java | 50 +++ .../android/logcat/server/KeyInjector.java | 95 ++++ .../unity/android/logcat/server/Logger.java | 94 ++++ .../unity/android/logcat/server/Options.java | 135 ++++++ .../unity/android/logcat/server/Protocol.java | 78 ++++ .../android/logcat/server/ScreenStreamer.java | 420 ++++++++++++++++++ .../android/logcat/server/ScrollInjector.java | 81 ++++ .../unity/android/logcat/server/Server.java | 229 ++++++++++ .../com/unity/android/logcat/server/Size.java | 98 ++++ .../android/logcat/server/TouchInjector.java | 115 +++++ .../wrappers/DisplayManagerWrapper.java | 81 ++++ .../server/wrappers/InputManagerWrapper.java | 149 +++++++ .../wrappers/SurfaceControlWrapper.java | 64 +++ 23 files changed, 2798 insertions(+) create mode 100644 External/UnityLogcatServer/.gitignore create mode 100644 External/UnityLogcatServer/README.md create mode 100644 External/UnityLogcatServer/build.gradle create mode 100644 External/UnityLogcatServer/gradle.properties create mode 100644 External/UnityLogcatServer/gradle/wrapper/gradle-wrapper.jar create mode 100644 External/UnityLogcatServer/gradle/wrapper/gradle-wrapper.properties create mode 100755 External/UnityLogcatServer/gradlew create mode 100644 External/UnityLogcatServer/gradlew.bat create mode 100644 External/UnityLogcatServer/settings.gradle create mode 100644 External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/ControlReader.java create mode 100644 External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/DisplayInfo.java create mode 100644 External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/KeyInjector.java create mode 100644 External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/Logger.java create mode 100644 External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/Options.java create mode 100644 External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/Protocol.java create mode 100644 External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/ScreenStreamer.java create mode 100644 External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/ScrollInjector.java create mode 100644 External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/Server.java create mode 100644 External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/Size.java create mode 100644 External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/TouchInjector.java create mode 100644 External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/wrappers/DisplayManagerWrapper.java create mode 100644 External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/wrappers/InputManagerWrapper.java create mode 100644 External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/wrappers/SurfaceControlWrapper.java diff --git a/External/UnityLogcatServer/.gitignore b/External/UnityLogcatServer/.gitignore new file mode 100644 index 00000000..56e87749 --- /dev/null +++ b/External/UnityLogcatServer/.gitignore @@ -0,0 +1,3 @@ +build/ +.gradle/ +local.properties diff --git a/External/UnityLogcatServer/README.md b/External/UnityLogcatServer/README.md new file mode 100644 index 00000000..d09afa61 --- /dev/null +++ b/External/UnityLogcatServer/README.md @@ -0,0 +1,309 @@ +# UnityLogcatServer + +On-device server for the Android Logcat package's live screen streaming. It +mirrors a device display, encodes each frame as JPEG and writes the frames to a +socket that the Unity Editor reads. The same socket carries touch and key events back +the other way, so the live view is interactive. + +This is not an Android application. It has no manifest, no resources and no +activity - it is a dexed jar started by `app_process`, running as the `shell` +user, which is what lets it call the hidden display-mirroring APIs, and hold the +`INJECT_EVENTS` permission, that a normal app cannot. + +## Building + +Requires a JDK 17+ and an Android SDK with `platforms/android-36` and +`build-tools/36.0.0`. A Unity installation with Android support ships both. + +Point the build at the SDK with any one of: + +* `local.properties` in this directory, containing `sdk.dir=` (gitignored) +* the `ANDROID_HOME` environment variable +* the `ANDROID_SDK_ROOT` environment variable + +Then: + +```sh +./gradlew dexJar +``` + +which produces `build/outputs/unity-logcat-server.jar` and then copies it to +`com.unity.mobile.android-logcat/External~/unity-logcat-server.jar`, which is the +copy the package ships and the Editor pushes to the device. + +That copy step (`copyJarToPackage`) hangs off `dexJar`, so every route that +produces the jar - `dexJar`, `assemble`, `pushJar`, `runJar` - refreshes it, and +the two cannot silently drift apart. Deleting the copy is enough to make the next +build put it back, even when nothing else needs rebuilding. + +It copies the file itself instead of using a `Copy` task, because Gradle creates a +`Copy` task's destination directory before any task action runs - so a guard +against writing to the wrong place could never see it missing. The step instead +checks for the package's `package.json`, which is both a stronger check (it +confirms the destination really is this package) and something Gradle cannot +create on our behalf. If the project is ever moved relative to the package the +build fails naming the expected layout, though Gradle will already have created an +empty `External~` on its way to that failure. + +`External~` is named with a trailing `~` so that Unity ships the folder in the +package but does not import its contents as assets: the jar needs no `.meta` file +and never enters the AssetDatabase. The Editor reads it straight off disk. + +**The copy is a build output and is not committed.** The root `.gitignore` excludes +`com.unity.mobile.android-logcat/External~/unity-logcat-server.jar`; CI builds it +on demand before the package is published. The `External~` folder therefore does +not exist in a fresh checkout - the copy step creates it. Only the jar itself is +ignored, not the folder, so anything else put there later stays visible to git. + +The build uses the plain `java-library` plugin plus an explicit `d8` step rather +than the Android Gradle Plugin. AGP would produce an APK that then has to be +renamed, needs network access to the `google()` repository and pulls in a large +dependency tree - none of which this project has any use for. + +Sources are compiled with `android.jar` replacing the JDK bootclasspath, so +reaching for a desktop-only API is a compile error rather than a crash on the +device. + +## Running it by hand + +`./gradlew runJar` pushes the jar and runs it in the foreground. Or, spelled out: + +```sh +adb push build/outputs/unity-logcat-server.jar /data/local/tmp/ +adb shell CLASSPATH=/data/local/tmp/unity-logcat-server.jar \ + app_process / com.unity.android.logcat.server.Server log_level=debug + +# from another shell +adb forward tcp:27183 localabstract:unity_logcat_server +``` + +and then read frames from `127.0.0.1:27183`. + +Options are `key=value` pairs; `Server.USAGE` lists them: + +| Option | Default | Meaning | +| --- | --- | --- | +| `socket_name` | `unity_logcat_server` | abstract unix socket to listen on | +| `display_id` | `0` | display to capture | +| `max_size` | `1024` | longest side of the stream in pixels, 0 for native | +| `quality` | `70` | JPEG quality, 1..100 | +| `max_fps` | `30` | frame rate cap | +| `connect_timeout_ms` | `10000` | how long to wait for the Editor, 0 waits forever | +| `log_level` | `info` | `verbose`, `debug`, `info`, `warn`, `error` | + +Log output goes to both logcat (tag `UnityLogcatServer`) and stderr, so the +Editor can surface a startup failure from the `adb shell` process it spawned. + +## Lifecycle + +The server handles exactly one client and then exits. There is no daemon, nothing +is left listening between sessions, and a second live-stream session is a second +process. + +### Startup + +1. The Editor pushes the jar to `/data/local/tmp/` and spawns + `adb shell CLASSPATH= app_process / com.unity.android.logcat.server.Server `. + The process runs as the `shell` user, which is what makes the hidden + display-mirroring APIs callable. +2. `Server.main` parses the options and sets the log level. Bad options exit + immediately with the usage text. +3. `DisplayManagerGlobal` is resolved by reflection. If that class is missing the + server fails here, before it has claimed anything. +4. A `LocalServerSocket` is opened on `socket_name` and `Listening on + localabstract:` is logged. Nothing is captured yet - the display is only + mirrored once a client is actually there. +5. The Editor runs `adb forward tcp: localabstract:` and connects. + `adb forward` only succeeds once the socket exists, so the Editor may have to + retry: the server is spawned first, but there is no ordering guarantee between + two separate adb invocations. +6. Input injection is set up. Touch and keys share one input manager, so they are + available together or not at all. A failure here is not fatal: it is reported in + the header flags and the session continues as view-only. +7. On `accept()`, the 20-byte stream header is written straight away. That header + is what tells the Editor it has reached a real server of a protocol version it + understands, rather than a forwarded port that merely happens to connect. +8. The control reader thread starts, and the capture session with it: a + `HandlerThread`, an `ImageReader`, and a mirrored display pointed at the + reader's surface. Frames flow from the capture thread; the main thread re-reads + the display geometry every 500 ms and restarts the session if it changed. + +### Shutdown + +Every path ends in an explicit `System.exit`. `app_process` will not exit on its +own while a `Looper` or a non-daemon thread is alive, and the Editor is waiting +for its `adb shell` to return. + +| Trigger | How it is noticed | Exit code | +| --- | --- | --- | +| Editor closes the connection, its process dies, or the forward is removed | the capture thread's write fails, or the control reader reads EOF | 0 | +| No client connects within `connect_timeout_ms` (default 10 s) | a watchdog thread exits the process out from under the blocked `accept()` | 1 | +| The captured display disappears | the geometry poll gets no `DisplayInfo` | 0 | +| Neither mirroring API works | `startSession` throws | 1 | +| Invalid options | `Options.parse` throws | 2 | +| Anything unexpected | caught in `main` and logged with a stack trace | 1 | + +Three details matter for a clean stop: + +* **The accept timeout exits the process rather than closing the socket.** Closing + the server socket from the watchdog thread would look like the tidier option, but + on Linux closing a file descriptor does not interrupt an `accept()` that another + thread is already parked on - the server would stay wedged forever, which is the + exact orphan the timeout exists to prevent. At that point nothing has been claimed + that needs unwinding, so exiting is both simpler and the only thing that works. + +* **The control reader doubles as the disconnect detector, because writes alone are + not enough.** On a screen that has stopped changing no frames are produced, so + there is no write to fail: a departed client would go unnoticed and the server + would sit there mirroring a display nobody is reading. The reader treats EOF as + the end of the session. +* **The socket is closed before the streamer.** Closing it first unblocks a capture + thread parked in a write, so teardown does not have to wait for it. Teardown then + releases the mirrored display, closes the `ImageReader`, joins the capture thread + (2 s cap) and recycles the reusable bitmaps. + +### If the server is killed outright + +Killing the `adb shell`, or the process on the device, skips all of the above and +leaks nothing that survives: the mirrored display and the `ImageReader` belong to +the process, and the abstract socket name disappears with it. Only the pushed jar +remains on disk, which is inert. The Editor pushes each session's jar under a name +of its own and deletes it when the stream stops, so what a kill leaves behind is +one file that the next stream sweeps up. + +A stale server from a previous session is therefore only a problem if it is still +*running* - it would own the socket name. Passing a per-session unique +`socket_name` avoids the collision entirely, and `connect_timeout_ms` bounds how +long an orphan can linger before it gives up on its own. + +Deleting a jar out from under a server that is still running it is safe, which is +what lets the Editor sweep: the runtime keeps the file it opened, so an unlink only +removes the name. That was measured on Android 16 and Android 8.1, not assumed. + +## Wire protocol + +All integers big endian. See `Protocol.java`. + +Server to Editor: + +``` +Stream header, once, 20 bytes: + u32 magic 'U' 'L' 'S' '1' (0x554C5331) + u32 protocolVersion see serverProtocolVersion in gradle.properties + u32 codec 1 = MJPEG + u32 flags bit 0: the server can inject input + u32 serverPid this process on the device, so the Editor can name it + +Frame, repeated, 28 byte header + payload: + u64 ptsUs microseconds since the first frame + u32 width pixels of the streamed image + u32 height pixels of the streamed image + u32 displayWidth pixels of the display it was captured from + u32 displayHeight pixels of the display it was captured from + u32 payloadSize bytes of encoded frame that follow + u8[] payload JPEG +``` + +The sizes are in every frame because they change - on a rotation, on a foldable +being opened, or on `wm size` being overridden - and the server starts a new +capture session for the new geometry without announcing it on the socket. The +display size rides along so that the Editor can say what the stream is scaling +down from without asking adb, and without the two numbers being able to disagree. + +Editor to server, on the same socket (see `ControlReader.java`): + +``` +Touch, 9 bytes: + u8 type 1 = touch + u8 action 0 down, 1 up, 2 move, 3 cancel + u8 pointerId 0 based, one finger per id + u16 x position across the display, 0..65535 + u16 y position down the display, 0..65535 + u16 pressure 0..65535 + +Key, 10 bytes: + u8 type 2 = key + u8 action 0 down, 1 up + u32 keyCode Android KeyEvent.KEYCODE_* + u32 metaState Android KeyEvent.META_* + +Text, 3 bytes + payload: + u8 type 3 = text + u16 length bytes of UTF-8 that follow, max 4096 + u8[] text + +Scroll, 9 bytes: + u8 type 4 = scroll + u16 x position across the display, 0..65535 + u16 y position down the display, 0..65535 + i16 hScroll notches right, times 256 + i16 vScroll notches away from the user, times 256 +``` + +Keys and text are separate on purpose. A named key - Back, Enter, an arrow - has no +character to type and goes as a keycode. Typed characters go as text and are turned +into key events on the device by `KeyCharacterMap`, which is what makes punctuation, +shifted characters and non-US layouts work: the Editor sends the character the user +actually produced and the device works out which keystrokes would produce it, rather +than the Editor trying to model every layout. + +A scroll carries a position because that is what decides which view receives it, +and its magnitude is fixed point so that a trackpad's fractions survive without +putting a float on the wire. The server turns each one into a hover followed by an +`ACTION_SCROLL`: without the hover in front of it, the scroll is accepted and then +ignored, because Android delivers it to whatever the mouse is over. + +Touch positions are normalized rather than in pixels, so the Editor does not have +to know the device's current resolution - and cannot get it wrong, since its idea +of the screen is always at least a frame and possibly a whole rotation out of +date. The server scales them against the display it is capturing at that moment. + +`flags` exists so the Editor can tell "the user turned control off" from "this +device will not allow injection" and say so, rather than dropping every touch in +silence. + +Message sizes are known per type, so an unknown type means the reader no longer knows +where the next one starts. It stops reading control input at that point and leaves the +video stream running, which is the half worth keeping. A text message whose length +exceeds the cap is skipped by consuming its payload, so that one bad message does not +desynchronize the rest. + +Width and height travel with every frame instead of only in the stream header, +because they change when the device is rotated or the display is resized. The +Editor therefore never has to be told out of band that the geometry moved - it +just reads the next frame. + +`serverProtocolVersion` lives in `gradle.properties` and is baked into the jar as +`BuildConfig.PROTOCOL_VERSION`, so that the Editor and the server cannot silently +drift apart. Bump it whenever the packet layout changes. + +## Why JPEG + +H.264 through `MediaCodec` would cost a fraction of the bandwidth, but the Editor +would then need a video decoder, and there is no H.264 decoder reachable from +Editor C#. A JPEG frame goes straight into `Texture2D.LoadImage`. Measured on a +Pixel 2 at `max_size=512 quality=70`: ~30 KB per frame, ~3.6 Mbps at 15 fps. + +## Layout + +| File | Role | +| --- | --- | +| `Server.java` | entry point, socket setup, client lifetime | +| `ScreenStreamer.java` | display mirroring, JPEG encoding, frame pacing | +| `ControlReader.java` | control messages from the Editor, and EOF detection | +| `TouchInjector.java` | normalized positions to injected MotionEvents | +| `KeyInjector.java` | keycodes and text to injected KeyEvents | +| `Protocol.java` | wire format | +| `Options.java` | `key=value` command line | +| `DisplayInfo.java`, `Size.java` | value types | +| `Logger.java` | logging to logcat and stderr | +| `wrappers/DisplayManagerWrapper.java` | reflection over `DisplayManagerGlobal` | +| `wrappers/SurfaceControlWrapper.java` | reflection over `SurfaceControl` | +| `wrappers/InputManagerWrapper.java` | reflection over the hidden input injection API | + +Two mirroring paths are attempted in order: `DisplayManagerGlobal +.createVirtualDisplay`, then `SurfaceControl.createDisplay`. Neither works +everywhere - `SurfaceControl.createDisplay` was removed in Android 15, and the +`DisplayManagerGlobal` overload is missing on some older versions (including +Android 10, where the `SurfaceControl` path is the one that runs) - so whichever +succeeds first wins. diff --git a/External/UnityLogcatServer/build.gradle b/External/UnityLogcatServer/build.gradle new file mode 100644 index 00000000..65a3a67b --- /dev/null +++ b/External/UnityLogcatServer/build.gradle @@ -0,0 +1,283 @@ +// Build for the Unity Logcat on-device screen streaming server. +// +// The server is not an Android application: it has no manifest, no resources and +// no activity. It is a plain set of classes, dexed and archived into a jar, which +// is pushed to the device and executed by app_process as the `shell` user: +// +// adb push unity-logcat-server.jar /data/local/tmp/ +// adb shell CLASSPATH=/data/local/tmp/unity-logcat-server.jar \ +// app_process / com.unity.android.logcat.server.Server +// +// Because of that, this uses the plain `java-library` plugin plus an explicit d8 +// step instead of the Android Gradle Plugin. AGP would build an APK (which would +// then have to be renamed to a jar), pull in a large dependency tree and require +// network access to the `google()` repository - none of which buys us anything. +// +// ./gradlew dexJar -> build/outputs/unity-logcat-server.jar +// ./gradlew pushJar -> pushes it to the connected device +// ./gradlew runJar -> pushes and runs it (foreground, for manual testing) + +plugins { + id 'java-library' +} + +// --------------------------------------------------------------------------- +// Android SDK discovery +// --------------------------------------------------------------------------- + +def androidPlatform = project.property('androidPlatform') +def androidBuildTools = project.property('androidBuildTools') +def androidMinApi = project.property('androidMinApi') + +def sdkHelp = """Android SDK not found. Point the build at one of: + + * External/UnityLogcatServer/local.properties containing sdk.dir= + * the ANDROID_HOME environment variable + * the ANDROID_SDK_ROOT environment variable + +A Unity installation with Android support ships one, typically at +/Editor/Data/PlaybackEngines/AndroidPlayer/SDK. + +The build needs platforms/android-${androidPlatform}/android.jar +and build-tools/${androidBuildTools}/d8.""" + +def resolveSdkDir = { + def localProperties = file('local.properties') + if (localProperties.exists()) { + def props = new Properties() + localProperties.withInputStream { props.load(it) } + def dir = props.getProperty('sdk.dir') + if (dir) { + return file(dir) + } + } + for (name in ['ANDROID_HOME', 'ANDROID_SDK_ROOT']) { + def dir = System.getenv(name) + if (dir) { + // Gradle resolves a relative path against the project directory, not + // against wherever the build was invoked from, and with a daemon there is + // no useful "invoked from" anyway. A relative value is therefore almost + // certainly not the directory the caller meant, so say that rather than + // reporting a missing SDK at some path they never typed. + def f = new File(dir) + if (!f.isAbsolute()) { + throw new GradleException("$name is set to a relative path, '$dir'.\n" + + "It has to be absolute: Gradle would resolve it against " + + project.projectDir + ", not against the directory you ran from.") + } + return f + } + } + return null +} + +// Everything below resolves the SDK lazily, so that tasks which do not need it +// (`gradlew tasks`, `gradlew clean`) still work on a machine without one. +def sdkFile = { String relativePath -> + def sdkDir = resolveSdkDir() + if (sdkDir == null || !sdkDir.isDirectory()) { + throw new GradleException(sdkHelp) + } + def f = new File(sdkDir, relativePath) + if (!f.exists()) { + throw new GradleException("Not found: " + f + "\n\n" + sdkHelp) + } + return f +} + +def isWindows = System.getProperty('os.name').toLowerCase().contains('windows') +def androidJar = { sdkFile("platforms/android-${androidPlatform}/android.jar") } +// Needed on the compile classpath because we replace the bootclasspath with +// android.jar, which does not carry the JDK lambda metafactory stubs. +def lambdaStubs = { sdkFile("build-tools/${androidBuildTools}/core-lambda-stubs.jar") } +def d8 = { sdkFile("build-tools/${androidBuildTools}/d8" + (isWindows ? '.bat' : '')) } +def adb = { sdkFile('platform-tools/adb' + (isWindows ? '.exe' : '')) } + +// --------------------------------------------------------------------------- +// Compilation +// --------------------------------------------------------------------------- + +def generatedSrcDir = layout.buildDirectory.dir('generated/sources/buildconfig') + +// Values the Editor side also needs to agree on live in gradle.properties and are +// baked into the jar, so a protocol mismatch is detectable at runtime instead of +// being two constants that silently drift apart. +def generateBuildConfig = tasks.register('generateBuildConfig') { + description = 'Generates BuildConfig.java from gradle.properties' + def outDir = generatedSrcDir + def protocolVersion = project.property('serverProtocolVersion') + def socketName = project.property('serverSocketName') + inputs.property('protocolVersion', protocolVersion) + inputs.property('socketName', socketName) + outputs.dir(outDir) + doLast { + def pkgDir = new File(outDir.get().asFile, 'com/unity/android/logcat/server') + pkgDir.mkdirs() + new File(pkgDir, 'BuildConfig.java').text = [ + '// Generated by build.gradle from gradle.properties. Do not edit.', + 'package com.unity.android.logcat.server;', + '', + 'public final class BuildConfig {', + ' public static final int PROTOCOL_VERSION = ' + protocolVersion + ';', + ' public static final String DEFAULT_SOCKET_NAME = "' + socketName + '";', + '', + ' private BuildConfig() {', + ' }', + '}', + '' + ].join('\n') + } +} + +sourceSets { + main { + java { + srcDir generatedSrcDir + } + } +} + +tasks.named('compileJava', JavaCompile) { + dependsOn generateBuildConfig + options.encoding = 'UTF-8' + // Compile against android.jar rather than the JDK class library, so reaching + // for a desktop-only API is a compile error instead of a crash on device. + options.bootstrapClasspath = files(androidJar) + classpath = files(lambdaStubs) + sourceCompatibility = '1.8' + targetCompatibility = '1.8' + options.compilerArgs << '-Xlint:deprecation' << '-Xlint:unchecked' +} + +def classesJar = tasks.named('jar', Jar).flatMap { it.archiveFile } + +jar { + // Intermediate artifact only: plain .class files, not runnable on a device. + archiveFileName = 'unity-logcat-server-classes.jar' +} + +// --------------------------------------------------------------------------- +// Dexing: the actual deliverable +// +// These are typed Exec tasks whose command line is filled in at execution time, +// because the SDK is resolved lazily and because Gradle 9 removed the +// `project.exec {}` method that an ad-hoc task would otherwise have used. +// --------------------------------------------------------------------------- + +def serverJarName = 'unity-logcat-server.jar' +def outputJar = layout.buildDirectory.file("outputs/${serverJarName}") + +def dexJar = tasks.register('dexJar', Exec) { + group = 'build' + description = 'Dexes the compiled classes into build/outputs/unity-logcat-server.jar' + // Declaring the jar as an input is also what makes this run after it. + inputs.file(classesJar) + outputs.file(outputJar) + // d8 is a launcher script, and both of its forms have to be pointed at a JVM: + // the Windows .bat resolves java through JAVA_HOME, while the shell script on + // macOS and Linux simply runs `java`. Neither is a given - a CI agent whose only + // JDK lives inside a Unity installation has no JAVA_HOME set and no java on PATH - + // so both are handed the JVM Gradle is already running on, which is also always a + // path the platform's own shell understands. + def javaHome = System.getProperty('java.home') + // Matched to however the inherited variable is spelled, so Windows does not end up + // carrying both Path and PATH. + def pathName = System.getenv().keySet().find { it.equalsIgnoreCase('PATH') } ?: 'PATH' + environment 'JAVA_HOME', javaHome + environment pathName, new File(javaHome, 'bin').absolutePath + + File.pathSeparator + (System.getenv(pathName) ?: '') + doFirst { + def out = outputJar.get().asFile + out.parentFile.mkdirs() + // d8 writes an archive containing classes.dex when --output ends in .jar. + commandLine d8(), + '--release', + '--min-api', androidMinApi, + '--lib', androidJar(), + '--output', out, + classesJar.get().asFile + } + doLast { + def out = outputJar.get().asFile + logger.lifecycle("Server jar: " + out + " (" + out.length() + " bytes)") + } +} + +// --------------------------------------------------------------------------- +// Post-build: place the jar where the package can ship it +// +// A folder whose name ends in "~" is included in the published package but not +// imported as an asset, so the jar needs no .meta file and never enters the +// AssetDatabase. The Editor reads it straight off disk to push it to a device. +// --------------------------------------------------------------------------- + +def packageExternalDir = file('../../com.unity.mobile.android-logcat/External~') + +// Copies by hand rather than with a Copy task: Gradle creates a Copy task's +// destination directory before any task action runs, so a guard against writing +// to the wrong place can never see it missing. +def copyJarToPackage = tasks.register('copyJarToPackage') { + group = 'build' + description = "Copies the server jar into the package's External~ folder" + dependsOn dexJar + def destination = new File(packageExternalDir, serverJarName) + inputs.file(outputJar) + outputs.file(destination) + doLast { + // package.json is the marker: it says the destination really is the logcat + // package, and unlike a directory it is not something Gradle can create on + // our behalf. Catches this project being moved relative to the package + // instead of scattering an External~ folder somewhere unrelated. + def packageDir = packageExternalDir.parentFile + if (!new File(packageDir, 'package.json').isFile()) { + throw new GradleException("No Unity package found at " + packageDir + + "\nExpected this project to sit at /External/UnityLogcatServer," + + " alongside /com.unity.mobile.android-logcat.") + } + + packageExternalDir.mkdirs() + java.nio.file.Files.copy( + outputJar.get().asFile.toPath(), + destination.toPath(), + java.nio.file.StandardCopyOption.REPLACE_EXISTING) + logger.lifecycle("Copied to: " + destination) + } +} + +// Attached to dexJar rather than to assemble, so that every route which produces +// the jar - dexJar, assemble, pushJar, runJar - also refreshes the copy the +// package ships. Otherwise the two silently drift apart. +dexJar.configure { + finalizedBy copyJarToPackage +} + +tasks.named('assemble') { + dependsOn dexJar +} + +// --------------------------------------------------------------------------- +// Developer conveniences. The Editor does its own push/run at runtime; these +// exist so the server can be exercised without an Editor in the loop. +// --------------------------------------------------------------------------- + +def devicePath = project.property('serverDevicePath') + +def pushJar = tasks.register('pushJar', Exec) { + group = 'verification' + description = "Pushes the server jar to ${devicePath} on the connected device" + dependsOn dexJar + doFirst { + commandLine adb(), 'push', outputJar.get().asFile, devicePath + } +} + +tasks.register('runJar', Exec) { + group = 'verification' + description = 'Runs the server on the connected device in the foreground (Ctrl+C to stop)' + dependsOn pushJar + doFirst { + def serverArgs = project.findProperty('serverArgs') ?: 'log_level=debug' + commandLine adb(), 'shell', + "CLASSPATH=${devicePath} app_process / com.unity.android.logcat.server.Server ${serverArgs}" + } +} diff --git a/External/UnityLogcatServer/gradle.properties b/External/UnityLogcatServer/gradle.properties new file mode 100644 index 00000000..e9462285 --- /dev/null +++ b/External/UnityLogcatServer/gradle.properties @@ -0,0 +1,22 @@ +# Version of the wire protocol spoken between the Editor and the on-device server. +# The Editor refuses to talk to a server whose protocol version it does not know, +# so bump this whenever the packet layout in Protocol.java changes. +serverProtocolVersion=1 + +# Android platform the server is compiled against (android.jar is taken from +# $SDK/platforms/android-/android.jar). +androidPlatform=36 + +# Build tools providing d8 ($SDK/build-tools//d8). +androidBuildTools=36.0.0 + +# Lowest Android API level the produced dex must run on. +androidMinApi=24 + +# Name of the abstract-namespace unix socket the server listens on, and the +# on-device path the jar is pushed to. Kept here so the Editor-side constants +# have a single documented source. +serverSocketName=unity_logcat_server +serverDevicePath=/data/local/tmp/unity-logcat-server.jar + +org.gradle.jvmargs=-Xmx1024m diff --git a/External/UnityLogcatServer/gradle/wrapper/gradle-wrapper.jar b/External/UnityLogcatServer/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..490fda8577df6c95960ba7077c43220e5bb2c0d9 GIT binary patch literal 58694 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 literal 0 HcmV?d00001 diff --git a/External/UnityLogcatServer/gradle/wrapper/gradle-wrapper.properties b/External/UnityLogcatServer/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..7c4f2c7a --- /dev/null +++ b/External/UnityLogcatServer/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +# https://gradle.org/release-checksums/ +distributionSha256Sum=b266d5ff6b90eada6dc3b20cb090e3731302e553a27c5d3e4df1f0d76beaff06 +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/External/UnityLogcatServer/gradlew b/External/UnityLogcatServer/gradlew new file mode 100755 index 00000000..2fe81a7d --- /dev/null +++ b/External/UnityLogcatServer/gradlew @@ -0,0 +1,183 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/External/UnityLogcatServer/gradlew.bat b/External/UnityLogcatServer/gradlew.bat new file mode 100644 index 00000000..62bd9b9c --- /dev/null +++ b/External/UnityLogcatServer/gradlew.bat @@ -0,0 +1,103 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/External/UnityLogcatServer/settings.gradle b/External/UnityLogcatServer/settings.gradle new file mode 100644 index 00000000..3366efed --- /dev/null +++ b/External/UnityLogcatServer/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'UnityLogcatServer' diff --git a/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/ControlReader.java b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/ControlReader.java new file mode 100644 index 00000000..fb54b42b --- /dev/null +++ b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/ControlReader.java @@ -0,0 +1,198 @@ +package com.unity.android.logcat.server; + +import java.io.DataInputStream; +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +/** + * Reads control messages the Editor sends back up the video socket, and notices the + * Editor going away. + *

+ * This runs even when nothing is being written to the socket, which is what makes a + * departed client detectable on a screen that has stopped changing: with no frames + * being produced there is no write to fail, so EOF here is the only signal. + * + *

+ * Message, 1 byte type then a fixed payload per type:
+ *
+ *   TYPE_TOUCH (1), 8 byte payload:
+ *     u8   action     0 down, 1 up, 2 move, 3 cancel
+ *     u8   pointerId  0 based
+ *     u16  x          position across the display, 0..65535
+ *     u16  y          position down the display, 0..65535
+ *     u16  pressure   0..65535
+ *
+ *   TYPE_SCROLL (4), 8 byte payload:
+ *     u16  x          position across the display, 0..65535
+ *     u16  y          position down the display, 0..65535
+ *     i16  hScroll    notches right, times SCROLL_SCALE
+ *     i16  vScroll    notches away from the user, times SCROLL_SCALE
+ * 
+ * + * Positions are normalized so that the Editor does not have to know the device's + * current resolution - see {@link TouchInjector}. + */ +public final class ControlReader implements Runnable { + public static final int TYPE_TOUCH = 1; + public static final int TYPE_KEY = 2; + public static final int TYPE_TEXT = 3; + public static final int TYPE_SCROLL = 4; + + private static final int NORMALIZED_MAX = 65535; + /** + * Scroll notches are sent as fixed point, so that a trackpad's fractions survive + * the trip without the message needing a float in it. 256 leaves room for +-128 + * notches in a single message, which no mouse will ever produce. + */ + private static final int SCROLL_SCALE = 256; + /** Generous for a keystroke or a paste, small enough that a bad length cannot hurt. */ + private static final int MAX_TEXT_LENGTH = 4096; + + private final InputStream input; + private final TouchInjector touchInjector; + private final KeyInjector keyInjector; + private final ScrollInjector scrollInjector; + private final Runnable onDisconnect; + + public ControlReader(InputStream input, TouchInjector touchInjector, KeyInjector keyInjector, + ScrollInjector scrollInjector, Runnable onDisconnect) { + this.input = input; + this.touchInjector = touchInjector; + this.keyInjector = keyInjector; + this.scrollInjector = scrollInjector; + this.onDisconnect = onDisconnect; + } + + @Override + public void run() { + try { + readMessages(); + } catch (EOFException e) { + Logger.d("Client went away"); + } catch (IOException e) { + // The socket was closed, by the client or by our own shutdown. Same + // conclusion either way. + Logger.d("Control channel closed: " + e); + } finally { + onDisconnect.run(); + } + } + + private void readMessages() throws IOException { + DataInputStream in = new DataInputStream(input); + + while (true) { + int type = in.read(); + if (type == -1) { + throw new EOFException(); + } + + switch (type) { + case TYPE_TOUCH: + readTouch(in); + break; + case TYPE_KEY: + readKey(in); + break; + case TYPE_TEXT: + readText(in); + break; + case TYPE_SCROLL: + readScroll(in); + break; + default: + // Message sizes are known per type, so an unknown type means we no + // longer know where the next one starts, and reading on would + // inject garbage. Draining rather than returning keeps the other + // thing this thread is for: noticing EOF, which on a screen that + // has stopped changing is the only sign the client has gone. Return + // here instead and the video stream would be left running with + // nobody watching it - and with nothing left to notice that. + Logger.w("Unknown control message type " + type + + ", ignoring the rest of the control channel"); + drainUntilClientGoes(in); + return; + } + } + } + + /** + * Reads and discards everything the client sends until it goes away, which is + * reported as {@link EOFException} exactly as a clean end of stream would be. + */ + private void drainUntilClientGoes(DataInputStream in) throws IOException { + byte[] scratch = new byte[256]; + while (in.read(scratch) != -1) { + // Discarded on purpose: the stream cannot be resynchronized, but the + // connection is still worth watching. + } + throw new EOFException(); + } + + private void readTouch(DataInputStream in) throws IOException { + int action = in.readUnsignedByte(); + int pointerId = in.readUnsignedByte(); + int x = in.readUnsignedShort(); + int y = in.readUnsignedShort(); + int pressure = in.readUnsignedShort(); + + if (touchInjector == null) { + // Input injection was unavailable at startup; the Editor was told, but a + // message already in flight can still turn up here. + return; + } + + touchInjector.inject(action, pointerId, + x / (float)NORMALIZED_MAX, + y / (float)NORMALIZED_MAX, + pressure / (float)NORMALIZED_MAX); + } + + private void readScroll(DataInputStream in) throws IOException { + int x = in.readUnsignedShort(); + int y = in.readUnsignedShort(); + int hScroll = in.readShort(); + int vScroll = in.readShort(); + + if (scrollInjector == null) { + return; + } + + scrollInjector.inject( + x / (float)NORMALIZED_MAX, + y / (float)NORMALIZED_MAX, + hScroll / (float)SCROLL_SCALE, + vScroll / (float)SCROLL_SCALE); + } + + private void readKey(DataInputStream in) throws IOException { + int action = in.readUnsignedByte(); + int keyCode = in.readInt(); + int metaState = in.readInt(); + + if (keyInjector == null) { + return; + } + keyInjector.injectKey(action, keyCode, metaState); + } + + private void readText(DataInputStream in) throws IOException { + int length = in.readUnsignedShort(); + if (length > MAX_TEXT_LENGTH) { + // The payload still has to be consumed, or the stream desynchronizes. + in.skipBytes(length); + Logger.w("Ignoring a " + length + " byte text message, the limit is " + MAX_TEXT_LENGTH); + return; + } + + byte[] bytes = new byte[length]; + in.readFully(bytes); + + if (keyInjector == null) { + return; + } + keyInjector.injectText(new String(bytes, StandardCharsets.UTF_8)); + } +} diff --git a/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/DisplayInfo.java b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/DisplayInfo.java new file mode 100644 index 00000000..6c09b3b7 --- /dev/null +++ b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/DisplayInfo.java @@ -0,0 +1,50 @@ +package com.unity.android.logcat.server; + +/** Snapshot of a logical display, read out of the hidden {@code DisplayInfo} class. */ +public final class DisplayInfo { + private final int displayId; + private final Size size; + private final int rotation; + private final int layerStack; + private final int flags; + private final int dpi; + + public DisplayInfo(int displayId, Size size, int rotation, int layerStack, int flags, int dpi) { + this.displayId = displayId; + this.size = size; + this.rotation = rotation; + this.layerStack = layerStack; + this.flags = flags; + this.dpi = dpi; + } + + public int getDisplayId() { + return displayId; + } + + /** Logical size, already rotated: it swaps when the device is turned. */ + public Size getSize() { + return size; + } + + public int getRotation() { + return rotation; + } + + public int getLayerStack() { + return layerStack; + } + + public int getFlags() { + return flags; + } + + public int getDpi() { + return dpi; + } + + @Override + public String toString() { + return "display " + displayId + " " + size + " rotation=" + rotation + " dpi=" + dpi; + } +} diff --git a/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/KeyInjector.java b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/KeyInjector.java new file mode 100644 index 00000000..e61124da --- /dev/null +++ b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/KeyInjector.java @@ -0,0 +1,95 @@ +package com.unity.android.logcat.server; + +import com.unity.android.logcat.server.wrappers.InputManagerWrapper; + +import android.os.SystemClock; +import android.view.InputDevice; +import android.view.KeyCharacterMap; +import android.view.KeyEvent; + +/** + * Injects key events and typed text into the device. + *

+ * There are two paths on purpose. Named keys - Back, Enter, the arrows - arrive as an + * Android keycode and become a {@link KeyEvent} directly. Typed characters arrive as + * text and are turned into key events by {@link KeyCharacterMap}, which is what makes + * punctuation, shifted characters and non-US layouts work: the Editor sends the + * character the user actually produced and lets the device work out which keystrokes + * would have produced it, instead of the Editor trying to map every layout itself. + */ +public final class KeyInjector { + public static final int ACTION_DOWN = 0; + public static final int ACTION_UP = 1; + + private final InputManagerWrapper inputManager; + private final int displayId; + + private KeyCharacterMap characterMap; + + public KeyInjector(InputManagerWrapper inputManager, int displayId) { + this.inputManager = inputManager; + this.displayId = displayId; + } + + /** + * @param action ACTION_DOWN or ACTION_UP + * @param keyCode an Android {@code KeyEvent.KEYCODE_*} value + * @param metaState Android {@code KeyEvent.META_*} flags + */ + public void injectKey(int action, int keyCode, int metaState) { + int keyAction; + switch (action) { + case ACTION_DOWN: + keyAction = KeyEvent.ACTION_DOWN; + break; + case ACTION_UP: + keyAction = KeyEvent.ACTION_UP; + break; + default: + Logger.w("Ignoring unknown key action " + action); + return; + } + + long now = SystemClock.uptimeMillis(); + KeyEvent event = new KeyEvent( + now, // downTime + now, // eventTime + keyAction, + keyCode, + 0, // repeat + metaState, + KeyCharacterMap.VIRTUAL_KEYBOARD, + 0, // scanCode + 0, // flags + InputDevice.SOURCE_KEYBOARD); + + inject(event); + } + + /** Types {@code text} as if it had been entered on a keyboard. */ + public void injectText(String text) { + if (text.isEmpty()) { + return; + } + + if (characterMap == null) { + characterMap = KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD); + } + + KeyEvent[] events = characterMap.getEvents(text.toCharArray()); + if (events == null) { + // The virtual keyboard layout cannot produce one of these characters. There + // is no keystroke sequence to fall back to, so say so and move on. + Logger.w("Cannot type '" + text + "' with the virtual keyboard layout"); + return; + } + + for (KeyEvent event : events) { + inject(event); + } + } + + private void inject(KeyEvent event) { + inputManager.inject(event, displayId); + } +} diff --git a/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/Logger.java b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/Logger.java new file mode 100644 index 00000000..28a027b5 --- /dev/null +++ b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/Logger.java @@ -0,0 +1,94 @@ +package com.unity.android.logcat.server; + +import android.util.Log; + +/** + * Logging. + *

+ * Everything is written both to logcat and to stderr. stderr is what the Editor + * sees on the {@code adb shell} process it spawned, so it is the channel the + * Editor surfaces to the user when the server fails to start; logcat keeps a copy + * for after-the-fact diagnosis. + */ +public final class Logger { + public enum Level { + VERBOSE, DEBUG, INFO, WARN, ERROR + } + + private static final String TAG = "UnityLogcatServer"; + private static final String PREFIX = "[unity-logcat-server] "; + + private static Level threshold = Level.INFO; + + private Logger() { + } + + public static void setLevel(Level level) { + threshold = level; + } + + public static boolean isEnabled(Level level) { + return level.ordinal() >= threshold.ordinal(); + } + + public static void v(String message) { + log(Level.VERBOSE, message, null); + } + + public static void d(String message) { + log(Level.DEBUG, message, null); + } + + public static void i(String message) { + log(Level.INFO, message, null); + } + + public static void w(String message) { + log(Level.WARN, message, null); + } + + public static void w(String message, Throwable throwable) { + log(Level.WARN, message, throwable); + } + + public static void e(String message) { + log(Level.ERROR, message, null); + } + + public static void e(String message, Throwable throwable) { + log(Level.ERROR, message, throwable); + } + + private static void log(Level level, String message, Throwable throwable) { + if (!isEnabled(level)) { + return; + } + + switch (level) { + case VERBOSE: + Log.v(TAG, message, throwable); + break; + case DEBUG: + Log.d(TAG, message, throwable); + break; + case INFO: + Log.i(TAG, message, throwable); + break; + case WARN: + Log.w(TAG, message, throwable); + break; + case ERROR: + Log.e(TAG, message, throwable); + break; + default: + break; + } + + java.io.PrintStream stream = level.ordinal() >= Level.WARN.ordinal() ? System.err : System.out; + stream.println(PREFIX + level + ": " + message); + if (throwable != null) { + throwable.printStackTrace(stream); + } + stream.flush(); + } +} diff --git a/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/Options.java b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/Options.java new file mode 100644 index 00000000..dc007986 --- /dev/null +++ b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/Options.java @@ -0,0 +1,135 @@ +package com.unity.android.logcat.server; + +import java.util.Locale; + +/** + * Command line options, passed as {@code key=value} pairs: + * + *

+ * app_process / com.unity.android.logcat.server.Server display_id=0 max_size=1024
+ * 
+ * + * {@code key=value} rather than {@code --flags} because the whole command line is + * handed to {@code adb shell}, which passes it through a shell on the device; the + * fewer characters a shell wants to interpret, the better. + */ +public final class Options { + private String socketName = BuildConfig.DEFAULT_SOCKET_NAME; + private int displayId; + private int maxSize = 1024; + private int quality = 70; + private int maxFps = 30; + private int connectTimeoutMs = 10_000; + private Logger.Level logLevel = Logger.Level.INFO; + + private Options() { + } + + /** Abstract-namespace unix socket the server listens on. */ + public String getSocketName() { + return socketName; + } + + public int getDisplayId() { + return displayId; + } + + /** Longest side of the streamed image, in pixels. 0 means the display's own size. */ + public int getMaxSize() { + return maxSize; + } + + /** JPEG quality, 1..100. */ + public int getQuality() { + return quality; + } + + public int getMaxFps() { + return maxFps; + } + + /** How long to wait for the Editor to connect before giving up and exiting. */ + public int getConnectTimeoutMs() { + return connectTimeoutMs; + } + + public Logger.Level getLogLevel() { + return logLevel; + } + + public static Options parse(String... args) { + Options options = new Options(); + + for (String arg : args) { + if (arg.isEmpty()) { + continue; + } + + int equals = arg.indexOf('='); + if (equals == -1) { + throw new IllegalArgumentException("Expected key=value, got '" + arg + "'"); + } + String key = arg.substring(0, equals); + String value = arg.substring(equals + 1); + + switch (key) { + case "socket_name": + options.socketName = value; + break; + case "display_id": + options.displayId = parseInt(key, value, 0, Integer.MAX_VALUE); + break; + case "max_size": + options.maxSize = parseInt(key, value, 0, 16384); + break; + case "quality": + options.quality = parseInt(key, value, 1, 100); + break; + case "max_fps": + options.maxFps = parseInt(key, value, 1, 240); + break; + case "connect_timeout_ms": + options.connectTimeoutMs = parseInt(key, value, 0, 600_000); + break; + case "log_level": + options.logLevel = parseLogLevel(value); + break; + default: + throw new IllegalArgumentException("Unknown option '" + key + "'"); + } + } + + return options; + } + + private static int parseInt(String key, String value, int min, int max) { + int parsed; + try { + parsed = Integer.parseInt(value); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Option '" + key + "' is not a number: '" + value + "'"); + } + if (parsed < min || parsed > max) { + throw new IllegalArgumentException("Option '" + key + "' must be in [" + min + ".." + max + "], got " + parsed); + } + return parsed; + } + + private static Logger.Level parseLogLevel(String value) { + try { + return Logger.Level.valueOf(value.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Unknown log_level '" + value + "'"); + } + } + + @Override + public String toString() { + return "socket_name=" + socketName + + " display_id=" + displayId + + " max_size=" + maxSize + + " quality=" + quality + + " max_fps=" + maxFps + + " log_level=" + logLevel; + } +} diff --git a/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/Protocol.java b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/Protocol.java new file mode 100644 index 00000000..c2aefd6a --- /dev/null +++ b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/Protocol.java @@ -0,0 +1,78 @@ +package com.unity.android.logcat.server; + +import java.io.BufferedOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +/** + * Wire format written to the socket. All integers are big endian, matching + * {@link DataOutputStream}. + * + *
+ * Stream header, once, 20 bytes:
+ *   u32  magic            'U' 'L' 'S' '1' (0x554C5331)
+ *   u32  protocolVersion  BuildConfig.PROTOCOL_VERSION
+ *   u32  codec            CODEC_MJPEG
+ *   u32  flags            FLAG_CONTROL_SUPPORTED if touch can be injected
+ *   u32  serverPid        this process on the device, so the Editor can name it
+ *
+ * Frame, repeated, 28 byte header + payload:
+ *   u64  ptsUs            microseconds since the first frame
+ *   u32  width            pixels of the streamed image
+ *   u32  height           pixels of the streamed image
+ *   u32  displayWidth     pixels of the display it was captured from
+ *   u32  displayHeight    pixels of the display it was captured from
+ *   u32  payloadSize      bytes of encoded frame that follow
+ *   u8[] payload
+ * 
+ * + * All four sizes travel with every frame because they change when the device is + * rotated or folded, and the server starts a new capture session without saying so + * on the socket. The reader is therefore never told out of band that the geometry + * moved - it just reads the next frame. + */ +public final class Protocol { + public static final int MAGIC = 0x554C5331; + public static final int CODEC_MJPEG = 1; + + /** + * Set when the server can inject input, so the Editor can tell "control is off" + * from "control is impossible on this device" and say so instead of quietly + * dropping every touch. + */ + public static final int FLAG_CONTROL_SUPPORTED = 1; + + private final DataOutputStream out; + private long firstFrameNs = -1; + + public Protocol(OutputStream stream) { + this.out = new DataOutputStream(new BufferedOutputStream(stream, 64 * 1024)); + } + + public void writeStreamHeader(int codec, int flags, int serverPid) throws IOException { + out.writeInt(MAGIC); + out.writeInt(BuildConfig.PROTOCOL_VERSION); + out.writeInt(codec); + out.writeInt(flags); + out.writeInt(serverPid); + out.flush(); + } + + public void writeFrame(long captureNs, int width, int height, int displayWidth, int displayHeight, + byte[] payload, int payloadSize) throws IOException { + if (firstFrameNs < 0) { + firstFrameNs = captureNs; + } + out.writeLong((captureNs - firstFrameNs) / 1000L); + out.writeInt(width); + out.writeInt(height); + out.writeInt(displayWidth); + out.writeInt(displayHeight); + out.writeInt(payloadSize); + out.write(payload, 0, payloadSize); + // Flushed per frame: this is a live stream, buffering a frame to fill the + // buffer would just add latency. + out.flush(); + } +} diff --git a/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/ScreenStreamer.java b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/ScreenStreamer.java new file mode 100644 index 00000000..b287eba4 --- /dev/null +++ b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/ScreenStreamer.java @@ -0,0 +1,420 @@ +package com.unity.android.logcat.server; + +import com.unity.android.logcat.server.wrappers.DisplayManagerWrapper; +import com.unity.android.logcat.server.wrappers.SurfaceControlWrapper; + +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.hardware.display.VirtualDisplay; +import android.media.Image; +import android.media.ImageReader; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.IBinder; +import android.view.Surface; + +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * Mirrors a display into an {@link ImageReader}, encodes each frame as JPEG and + * writes it to the socket. + *

+ * JPEG rather than H.264 is a deliberate trade. H.264 through {@code MediaCodec} + * would cost a fraction of the bandwidth, but the Editor would then need a video + * decoder, and there is no H.264 decoder reachable from Editor C#. A JPEG frame + * goes straight into {@code Texture2D.LoadImage}, so the Editor side stays + * dependency-free. At 1024px and quality 70 a frame is roughly 40-120 KB, i.e. + * 1-4 MB/s at 30 fps, which fits comfortably in what adb forwards. + */ +public final class ScreenStreamer implements Closeable { + private static final String VIRTUAL_DISPLAY_NAME = "unity-logcat"; + /** + * Two buffers: one being mirrored into, one being encoded. More would only + * buy latency. + */ + private static final int MAX_IMAGES = 2; + /** How often the display is re-read to notice a rotation or a resize. */ + private static final long DISPLAY_POLL_MS = 500; + private static final long STATS_INTERVAL_NS = 5L * 1000 * 1000 * 1000; + + private final Options options; + private final Protocol protocol; + private final DisplayManagerWrapper displayManager; + private final long minFrameIntervalNs; + + /** Guards the capture session, so a restart cannot race a frame callback. */ + private final Object sessionLock = new Object(); + private final CountDownLatch streamEnded = new CountDownLatch(1); + + private HandlerThread handlerThread; + private Handler handler; + + private ImageReader imageReader; + private VirtualDisplay virtualDisplay; + private IBinder surfaceControlDisplay; + private Size videoSize; + + private long lastFrameNs; + private Bitmap paddedBitmap; + private Bitmap frameBitmap; + private Canvas frameCanvas; + private final JpegBuffer jpeg = new JpegBuffer(); + + private long statsStartNs; + private int statsFrames; + private long statsBytes; + + private volatile boolean stopped; + private volatile IOException streamError; + /** Read by TouchInjector from the control thread, hence volatile. */ + private volatile Size displaySize; + + /** + * Logical size of the display being captured, or null before capture starts. This + * is the display's own size, not the streamed size: touch positions scale to the + * former. + */ + public Size getDisplaySize() { + return displaySize; + } + + public ScreenStreamer(Options options, Protocol protocol, DisplayManagerWrapper displayManager) { + this.options = options; + this.protocol = protocol; + this.displayManager = displayManager; + this.minFrameIntervalNs = 1000000000L / options.getMaxFps(); + } + + /** + * Streams until the client disconnects, the display disappears or + * {@link #close()} is called. + */ + public void stream() throws IOException, ReflectiveOperationException, InterruptedException { + DisplayInfo info = readDisplayInfo(); + Logger.i("Capturing " + info); + + handlerThread = new HandlerThread("unity-logcat-capture"); + handlerThread.start(); + handler = new Handler(handlerThread.getLooper()); + + statsStartNs = System.nanoTime(); + startSession(info); + + Size lastSize = info.getSize(); + int lastRotation = info.getRotation(); + + while (!stopped) { + if (streamEnded.await(DISPLAY_POLL_MS, TimeUnit.MILLISECONDS)) { + break; + } + + DisplayInfo current = displayManager.getDisplayInfo(options.getDisplayId()); + if (current == null) { + Logger.w("Display " + options.getDisplayId() + " is gone, stopping"); + break; + } + + // Rotating the device changes the logical size, which means a new + // ImageReader and a new mirrored display. The client needs no warning: + // every frame carries its own dimensions. + if (!current.getSize().equals(lastSize) || current.getRotation() != lastRotation) { + Logger.d("Display changed to " + current + ", restarting capture session"); + lastSize = current.getSize(); + lastRotation = current.getRotation(); + stopSession(); + startSession(current); + } + } + + if (streamError != null) { + throw streamError; + } + } + + private DisplayInfo readDisplayInfo() throws ReflectiveOperationException, IOException { + DisplayInfo info = displayManager.getDisplayInfo(options.getDisplayId()); + if (info == null) { + StringBuilder available = new StringBuilder(); + for (int id : displayManager.getDisplayIds()) { + available.append(' ').append(id); + } + throw new IOException("Unknown display id " + options.getDisplayId() + ", available:" + available); + } + return info; + } + + private void startSession(DisplayInfo info) throws IOException { + synchronized (sessionLock) { + displaySize = info.getSize(); + videoSize = info.getSize().limit(options.getMaxSize()); + int width = videoSize.getWidth(); + int height = videoSize.getHeight(); + + imageReader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, MAX_IMAGES); + imageReader.setOnImageAvailableListener(this::onImageAvailable, handler); + Surface surface = imageReader.getSurface(); + + try { + virtualDisplay = displayManager + .createVirtualDisplay(VIRTUAL_DISPLAY_NAME, width, height, info.getDisplayId(), surface); + if (virtualDisplay == null) { + // Refused rather than thrown, on some devices. + throw new IllegalStateException("createVirtualDisplay returned null"); + } + Logger.d("Mirroring " + info.getSize() + " to " + videoSize + " via DisplayManagerGlobal"); + } catch (Exception displayManagerFailure) { + // Expected on some devices and Android versions - the fallback is + // the normal path there, so this is not a warning. + Logger.d("DisplayManagerGlobal.createVirtualDisplay unavailable (" + displayManagerFailure + + "), falling back to SurfaceControl"); + try { + startSessionWithSurfaceControl(info, surface, width, height); + Logger.d("Mirroring " + info.getSize() + " to " + videoSize + " via SurfaceControl"); + } catch (Exception surfaceControlFailure) { + Logger.e("DisplayManagerGlobal.createVirtualDisplay failed", displayManagerFailure); + Logger.e("SurfaceControl.createDisplay failed", surfaceControlFailure); + imageReader.close(); + imageReader = null; + throw new IOException("Could not mirror display " + info.getDisplayId()); + } + } + + lastFrameNs = 0; + } + } + + private void startSessionWithSurfaceControl(DisplayInfo info, Surface surface, int width, int height) + throws ReflectiveOperationException { + surfaceControlDisplay = SurfaceControlWrapper.createDisplay(VIRTUAL_DISPLAY_NAME, false); + SurfaceControlWrapper.openTransaction(); + try { + SurfaceControlWrapper.setDisplaySurface(surfaceControlDisplay, surface); + SurfaceControlWrapper.setDisplayProjection(surfaceControlDisplay, 0, + new Rect(0, 0, info.getSize().getWidth(), info.getSize().getHeight()), + new Rect(0, 0, width, height)); + SurfaceControlWrapper.setDisplayLayerStack(surfaceControlDisplay, info.getLayerStack()); + } finally { + SurfaceControlWrapper.closeTransaction(); + } + } + + private void stopSession() { + synchronized (sessionLock) { + if (virtualDisplay != null) { + virtualDisplay.release(); + virtualDisplay = null; + } + if (surfaceControlDisplay != null) { + try { + SurfaceControlWrapper.destroyDisplay(surfaceControlDisplay); + } catch (ReflectiveOperationException e) { + Logger.w("Could not destroy SurfaceControl display", e); + } + surfaceControlDisplay = null; + } + if (imageReader != null) { + imageReader.setOnImageAvailableListener(null, null); + imageReader.close(); + imageReader = null; + } + } + } + + private void onImageAvailable(ImageReader reader) { + synchronized (sessionLock) { + // A callback queued before the session was torn down. + if (stopped || reader != imageReader) { + return; + } + + Image image = null; + try { + image = reader.acquireLatestImage(); + if (image == null) { + return; + } + + long now = System.nanoTime(); + long waitNs = lastFrameNs == 0 ? 0 : minFrameIntervalNs - (now - lastFrameNs); + if (waitNs > 0) { + // Wait rather than drop: on a screen that has stopped changing + // this may be the last frame produced for a long time, and + // dropping it would leave the client showing a stale image. + TimeUnit.NANOSECONDS.sleep(waitNs); + now = System.nanoTime(); + } + lastFrameNs = now; + + encodeAndSend(image, now); + reportStats(now); + } catch (IOException e) { + // The only IOException reachable here comes from writing to the + // socket, which means the client is gone. That is how a session + // normally ends, so it is not recorded as a stream error - doing so + // would make an ordinary stop exit non-zero whenever the capture + // thread noticed the disconnect before the watch thread did. + if (!stopped) { + Logger.d("Client is gone (" + e + "), ending stream"); + } + endStream(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + endStream(); + } catch (RuntimeException e) { + Logger.e("Unexpected failure while encoding a frame", e); + streamError = new IOException("Frame encoding failed", e); + endStream(); + } finally { + if (image != null) { + image.close(); + } + } + } + } + + private void encodeAndSend(Image image, long captureNs) throws IOException { + Image.Plane plane = image.getPlanes()[0]; + ByteBuffer buffer = plane.getBuffer(); + int pixelStride = plane.getPixelStride(); + int rowStride = plane.getRowStride(); + + int width = image.getWidth(); + int height = image.getHeight(); + // The plane's rows can be wider than the image; those extra pixels have to + // be copied in and then cropped away. + int paddedWidth = rowStride / pixelStride; + + Bitmap padded = obtainPaddedBitmap(paddedWidth, height); + buffer.rewind(); + int required = padded.getRowBytes() * height; + if (buffer.remaining() < required) { + Logger.w("Frame plane is " + buffer.remaining() + " bytes, expected " + required + "; skipping frame"); + return; + } + padded.copyPixelsFromBuffer(buffer); + + Bitmap toEncode; + if (paddedWidth == width) { + toEncode = padded; + } else { + toEncode = obtainFrameBitmap(width, height); + frameCanvas.drawBitmap(padded, 0, 0, null); + } + + jpeg.reset(); + if (!toEncode.compress(Bitmap.CompressFormat.JPEG, options.getQuality(), jpeg)) { + // Keeps IOException in this method meaning "the socket died", so that a + // one-off encoder hiccup drops a frame instead of ending the session. + Logger.e("JPEG encoding failed, skipping frame"); + return; + } + + // The session's own size, which is what this frame was scaled down from. + Size display = displaySize; + protocol.writeFrame(captureNs, width, height, + display == null ? 0 : display.getWidth(), + display == null ? 0 : display.getHeight(), + jpeg.buffer(), jpeg.size()); + + statsFrames++; + statsBytes += jpeg.size(); + } + + private Bitmap obtainPaddedBitmap(int width, int height) { + if (paddedBitmap == null || paddedBitmap.getWidth() != width || paddedBitmap.getHeight() != height) { + if (paddedBitmap != null) { + paddedBitmap.recycle(); + } + paddedBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + } + return paddedBitmap; + } + + private Bitmap obtainFrameBitmap(int width, int height) { + if (frameBitmap == null || frameBitmap.getWidth() != width || frameBitmap.getHeight() != height) { + if (frameBitmap != null) { + frameBitmap.recycle(); + } + frameBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + frameCanvas = new Canvas(frameBitmap); + } + return frameBitmap; + } + + private void reportStats(long now) { + if (!Logger.isEnabled(Logger.Level.DEBUG)) { + return; + } + long elapsedNs = now - statsStartNs; + if (elapsedNs < STATS_INTERVAL_NS) { + return; + } + double seconds = elapsedNs / 1000000000.0; + Logger.d(String.format("%s: %.1f fps, %.2f Mbps", + videoSize, statsFrames / seconds, statsBytes * 8 / seconds / 1000000.0)); + statsStartNs = now; + statsFrames = 0; + statsBytes = 0; + } + + private void endStream() { + stopped = true; + streamEnded.countDown(); + } + + @Override + public synchronized void close() { + endStream(); + + HandlerThread thread = handlerThread; + if (thread != null) { + // Drops queued frame callbacks; a callback already running finishes, + // which it will do promptly since the socket is closed by now. + thread.quitSafely(); + } + + stopSession(); + + if (thread != null) { + try { + thread.join(2000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + handlerThread = null; + handler = null; + } + + // Safe now: only the capture thread touched these, and it has stopped. + if (paddedBitmap != null) { + paddedBitmap.recycle(); + paddedBitmap = null; + } + if (frameBitmap != null) { + frameBitmap.recycle(); + frameBitmap = null; + } + frameCanvas = null; + } + + /** + * {@link ByteArrayOutputStream} that hands out its backing array, so a frame + * is not copied on its way from the JPEG encoder to the socket. + */ + private static final class JpegBuffer extends ByteArrayOutputStream { + JpegBuffer() { + super(256 * 1024); + } + + byte[] buffer() { + return buf; + } + } +} diff --git a/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/ScrollInjector.java b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/ScrollInjector.java new file mode 100644 index 00000000..ff592328 --- /dev/null +++ b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/ScrollInjector.java @@ -0,0 +1,81 @@ +package com.unity.android.logcat.server; + +import com.unity.android.logcat.server.wrappers.InputManagerWrapper; + +import android.os.SystemClock; +import android.view.InputDevice; +import android.view.MotionEvent; + +import java.util.function.Supplier; + +/** + * Turns scroll wheel movement from the Editor into {@code ACTION_SCROLL} + * {@link MotionEvent}s injected into the device. + *

+ * Unlike a touch this is not part of a gesture, so there is no down time to remember + * and no pointer to keep track of - each scroll stands alone. The magnitude travels in + * the {@code VSCROLL} and {@code HSCROLL} axes rather than in the position, which is + * why this needs the {@code PointerCoords} form of {@code MotionEvent.obtain}; the + * position still matters, because a scroll goes to whatever view is under the pointer. + *

+ * The event claims to come from a mouse: a touchscreen has no scroll axis, so an event + * from {@code SOURCE_TOUCHSCREEN} carrying one would be dropped. + */ +public final class ScrollInjector { + private final InputManagerWrapper inputManager; + private final Supplier displaySize; + private final int displayId; + + public ScrollInjector(InputManagerWrapper inputManager, Supplier displaySize, int displayId) { + this.inputManager = inputManager; + this.displaySize = displaySize; + this.displayId = displayId; + } + + /** + * @param nx horizontal position, 0..1 across the display + * @param ny vertical position, 0..1 down the display + * @param hScroll notches to the right, negative for left + * @param vScroll notches away from the user, negative for towards + */ + public void inject(float nx, float ny, float hScroll, float vScroll) { + if (hScroll == 0f && vScroll == 0f) { + return; + } + + Size size = displaySize.get(); + if (size == null) { + Logger.v("Ignoring scroll, the display size is not known yet"); + return; + } + + long now = SystemClock.uptimeMillis(); + + MotionEvent.PointerProperties properties = new MotionEvent.PointerProperties(); + properties.id = 0; + properties.toolType = MotionEvent.TOOL_TYPE_MOUSE; + + MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords(); + coords.x = size.pixelX(nx); + coords.y = size.pixelY(ny); + coords.setAxisValue(MotionEvent.AXIS_VSCROLL, vScroll); + coords.setAxisValue(MotionEvent.AXIS_HSCROLL, hScroll); + + // A mouse has to be hovering over a view before a scroll means anything to it, + // and nothing else moves this pointer: the Editor sends a position with every + // scroll, not a stream of moves. So the hover is sent first, every time. + inject(MotionEvent.ACTION_HOVER_MOVE, now, properties, coords); + inject(MotionEvent.ACTION_SCROLL, now, properties, coords); + } + + private void inject(int action, long now, MotionEvent.PointerProperties properties, + MotionEvent.PointerCoords coords) { + // downTime is now: a scroll has no gesture behind it, so it is its own. + MotionEvent event = InputManagerWrapper.obtainMotionEvent( + now, now, action, properties, coords, InputDevice.SOURCE_MOUSE); + + if (!inputManager.inject(event, displayId)) { + Logger.d("Scroll event " + action + " was rejected"); + } + } +} diff --git a/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/Server.java b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/Server.java new file mode 100644 index 00000000..066e01ac --- /dev/null +++ b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/Server.java @@ -0,0 +1,229 @@ +package com.unity.android.logcat.server; + +import com.unity.android.logcat.server.wrappers.DisplayManagerWrapper; +import com.unity.android.logcat.server.wrappers.InputManagerWrapper; + +import android.net.LocalServerSocket; +import android.net.LocalSocket; + +import java.io.IOException; +import java.io.InputStream; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Entry point of the on-device server. + *

+ * It is started by the Editor as: + * + *

+ * adb push unity-logcat-server.jar /data/local/tmp/
+ * adb shell CLASSPATH=/data/local/tmp/unity-logcat-server.jar \
+ *     app_process / com.unity.android.logcat.server.Server max_size=1024
+ * 
+ * + * and then reached over an abstract unix socket that the Editor forwards to a + * local TCP port: + * + *
+ * adb forward tcp:0 localabstract:unity_logcat_server
+ * 
+ * + *

Why a socket and not stdout

+ * Frames could be written to stdout and read from the {@code adb shell} process, + * as {@code screenrecord} does. A socket is used instead because it is + * bidirectional - the same connection can later carry input events from the + * Editor back to the device - and because it keeps frame data off a stream that + * also carries log output. + */ +public final class Server { + private static final String USAGE = "Usage: app_process / com.unity.android.logcat.server.Server [key=value ...]\n" + + " socket_name= abstract unix socket to listen on\n" + + " display_id= display to capture (default 0)\n" + + " max_size= longest side of the stream, 0 for native (default 1024)\n" + + " quality=<1..100> JPEG quality (default 70)\n" + + " max_fps=<1..240> frame rate cap (default 30)\n" + + " connect_timeout_ms= how long to wait for the Editor (default 10000, 0 waits forever)\n" + + " log_level="; + + private Server() { + } + + public static void main(String... args) { + Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> + Logger.e("Uncaught exception on thread " + thread.getName(), throwable)); + + int exitCode = 0; + try { + Options options = Options.parse(args); + Logger.setLevel(options.getLogLevel()); + Logger.d("Protocol version " + BuildConfig.PROTOCOL_VERSION + ", options: " + options); + run(options); + Logger.i("Stopped"); + } catch (IllegalArgumentException e) { + Logger.e(e.getMessage()); + Logger.e(USAGE); + exitCode = 2; + } catch (Throwable t) { + Logger.e("Server failed", t); + exitCode = 1; + } + + // app_process does not exit on its own while a Looper or a non-daemon + // thread is alive, and the Editor is waiting for its adb shell to return. + System.exit(exitCode); + } + + private static void run(Options options) throws Exception { + DisplayManagerWrapper displayManager = DisplayManagerWrapper.create(); + + LocalServerSocket serverSocket = new LocalServerSocket(options.getSocketName()); + Logger.i("Listening on localabstract:" + options.getSocketName()); + + LocalSocket socket = null; + ScreenStreamer streamer = null; + try { + socket = accept(serverSocket, options.getConnectTimeoutMs()); + Logger.d("Client connected"); + + Protocol protocol = new Protocol(socket.getOutputStream()); + + streamer = new ScreenStreamer(options, protocol, displayManager); + + // Input injection is optional: a device that will not allow it still + // streams fine, so a failure here is reported in the header rather than + // taken as fatal. Touch and keys share one input manager, so they are + // available together or not at all. + InputManagerWrapper inputManager = createInputManager(); + TouchInjector touchInjector = inputManager == null + ? null + : new TouchInjector(inputManager, streamer::getDisplaySize, options.getDisplayId()); + KeyInjector keyInjector = inputManager == null + ? null + : new KeyInjector(inputManager, options.getDisplayId()); + ScrollInjector scrollInjector = inputManager == null + ? null + : new ScrollInjector(inputManager, streamer::getDisplaySize, options.getDisplayId()); + int flags = inputManager != null ? Protocol.FLAG_CONTROL_SUPPORTED : 0; + + // Sent before anything else: `adb forward` succeeds as soon as the + // socket exists, so the header is what tells the Editor it is really + // talking to a server of a version it understands. + protocol.writeStreamHeader(Protocol.CODEC_MJPEG, flags, android.os.Process.myPid()); + + startControlReader(socket, streamer, touchInjector, keyInjector, scrollInjector); + streamer.stream(); + } finally { + // Socket first: it unblocks a capture thread parked in a write, so + // that closing the streamer does not have to wait for the timeout. + closeQuietly(socket); + if (streamer != null) { + streamer.close(); + } + closeQuietly(serverSocket); + } + } + + /** + * Waits for the Editor to connect, giving up after {@code timeoutMs} so that a + * server whose Editor died does not sit on the device forever. A timeout of 0 + * waits indefinitely. + */ + private static LocalSocket accept(LocalServerSocket serverSocket, int timeoutMs) throws IOException { + if (timeoutMs <= 0) { + return serverSocket.accept(); + } + + // Whichever of the two paths wins this CAS decides the outcome, so a client + // arriving exactly as the timeout expires cannot be half-accepted. + AtomicBoolean decided = new AtomicBoolean(); + + Thread watchdog = new Thread(() -> { + try { + Thread.sleep(timeoutMs); + } catch (InterruptedException e) { + return; + } + if (decided.compareAndSet(false, true)) { + Logger.e("No client connected within " + timeoutMs + " ms, giving up"); + // Note: closing the server socket here would NOT unblock the + // accept() below. On Linux, closing a file descriptor from another + // thread does not interrupt an accept() already parked on it, so + // that leaves the process wedged forever - the exact orphan this + // timeout exists to prevent. Exiting is what actually works, and + // nothing has been claimed yet that needs unwinding: no client, no + // capture session, and the kernel reclaims the socket. + System.exit(1); + } + }, "unity-logcat-accept-timeout"); + watchdog.setDaemon(true); + watchdog.start(); + + try { + LocalSocket socket = serverSocket.accept(); + if (!decided.compareAndSet(false, true)) { + // The watchdog got there first and the process is already exiting. + closeQuietly(socket); + throw new IOException("Client connected as the accept timeout expired"); + } + return socket; + } finally { + watchdog.interrupt(); + } + } + + private static InputManagerWrapper createInputManager() { + try { + return InputManagerWrapper.create(); + } catch (ReflectiveOperationException | RuntimeException e) { + Logger.w("Input injection is unavailable, the stream will be view-only", e); + return null; + } + } + + /** + * Reads control messages from the Editor, and notices it going away. + *

+ * Reading is also what detects a disconnect while the screen is static: with no + * frames being produced there is no write to fail, so EOF here is the only signal. + */ + private static void startControlReader(LocalSocket socket, ScreenStreamer streamer, + TouchInjector touchInjector, KeyInjector keyInjector, ScrollInjector scrollInjector) + throws IOException { + InputStream input = socket.getInputStream(); + Thread thread = new Thread( + new ControlReader(input, touchInjector, keyInjector, scrollInjector, streamer::close), + "unity-logcat-control"); + thread.setDaemon(true); + thread.start(); + } + + private static void closeQuietly(LocalSocket socket) { + if (socket == null) { + return; + } + try { + socket.close(); + } catch (IOException e) { + Logger.v("Ignoring close failure: " + e); + } + } + + /** + * Deliberately typed to the class rather than to {@link java.io.Closeable}: + * {@code LocalServerSocket} only declares that interface from API 29, so on an older + * device closing it through the interface throws {@code IncompatibleClassChangeError} + * - an Error, which escaped the shutdown path and made every stop report a failure. + * Calling the class's own {@code close()} compiles to a virtual call that works on + * every API level we support. + */ + private static void closeQuietly(LocalServerSocket serverSocket) { + if (serverSocket == null) { + return; + } + try { + serverSocket.close(); + } catch (IOException e) { + Logger.v("Ignoring close failure: " + e); + } + } +} diff --git a/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/Size.java b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/Size.java new file mode 100644 index 00000000..274ea4da --- /dev/null +++ b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/Size.java @@ -0,0 +1,98 @@ +package com.unity.android.logcat.server; + +/** Immutable width/height pair. */ +public final class Size { + /** + * Captured dimensions are rounded down to a multiple of this. Matching the + * buffer alignment the graphics stack wants keeps {@code Image.Plane} row + * padding at zero on most devices, which lets a frame be turned into a + * {@link android.graphics.Bitmap} without an intermediate copy. + */ + private static final int ALIGNMENT = 8; + + private final int width; + private final int height; + + public Size(int width, int height) { + this.width = width; + this.height = height; + } + + public int getWidth() { + return width; + } + + public int getHeight() { + return height; + } + + /** + * Maps a normalized horizontal position, 0..1, onto these pixels. Clamped rather + * than rejected: a drag that runs off the edge of the view in the Editor should + * still read as a swipe to the edge of the screen. + */ + public float pixelX(float normalized) { + return clamp01(normalized) * width; + } + + /** The same down the display. */ + public float pixelY(float normalized) { + return clamp01(normalized) * height; + } + + private static float clamp01(float value) { + if (value < 0f) { + return 0f; + } + return value > 1f ? 1f : value; + } + + /** + * Scales down so that the longest side is at most {@code maxSize}, preserving + * aspect ratio. A {@code maxSize} of 0 means "do not scale", but the result is + * aligned either way. + */ + public Size limit(int maxSize) { + int w = width; + int h = height; + + if (maxSize > 0 && (w > maxSize || h > maxSize)) { + if (w > h) { + h = h * maxSize / w; + w = maxSize; + } else { + w = w * maxSize / h; + h = maxSize; + } + } + + return new Size(align(w), align(h)); + } + + private static int align(int value) { + int aligned = value & ~(ALIGNMENT - 1); + return aligned < ALIGNMENT ? ALIGNMENT : aligned; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Size)) { + return false; + } + Size other = (Size) o; + return width == other.width && height == other.height; + } + + @Override + public int hashCode() { + return width * 31 + height; + } + + @Override + public String toString() { + return width + "x" + height; + } +} diff --git a/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/TouchInjector.java b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/TouchInjector.java new file mode 100644 index 00000000..8f8396f6 --- /dev/null +++ b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/TouchInjector.java @@ -0,0 +1,115 @@ +package com.unity.android.logcat.server; + +import com.unity.android.logcat.server.wrappers.InputManagerWrapper; + +import android.os.SystemClock; +import android.view.InputDevice; +import android.view.MotionEvent; + +import java.util.function.Supplier; + +/** + * Turns normalized touch positions from the Editor into {@link MotionEvent}s injected + * into the device. + *

+ * Positions arrive normalized rather than in pixels because the Editor's idea of the + * screen size is always at least one frame stale, and can be a whole rotation stale. + * Scaling here, against the display size the capture session is currently using, means + * a touch always lands where the user pointed even if the display changed size in the + * meantime. + */ +public final class TouchInjector { + public static final int ACTION_DOWN = 0; + public static final int ACTION_UP = 1; + public static final int ACTION_MOVE = 2; + public static final int ACTION_CANCEL = 3; + + private static final int MAX_POINTERS = 10; + + private final InputManagerWrapper inputManager; + private final Supplier displaySize; + private final int displayId; + + // One gesture per pointer. MotionEvent needs the time of the DOWN that started the + // gesture on every later event, so it is remembered here rather than sent over the + // wire: 0 means "this pointer is not down". + private final long[] downTimes = new long[MAX_POINTERS]; + + public TouchInjector(InputManagerWrapper inputManager, Supplier displaySize, int displayId) { + this.inputManager = inputManager; + this.displaySize = displaySize; + this.displayId = displayId; + } + + /** + * @param action one of the ACTION_* constants + * @param pointerId which finger, 0 based + * @param nx horizontal position, 0..1 across the display + * @param ny vertical position, 0..1 down the display + * @param pressure 0..1 + */ + public void inject(int action, int pointerId, float nx, float ny, float pressure) { + if (pointerId < 0 || pointerId >= MAX_POINTERS) { + Logger.w("Ignoring touch for pointer " + pointerId + ", only 0.." + (MAX_POINTERS - 1) + " are supported"); + return; + } + + Size size = displaySize.get(); + if (size == null) { + Logger.v("Ignoring touch, the display size is not known yet"); + return; + } + + long now = SystemClock.uptimeMillis(); + int motionAction; + + switch (action) { + case ACTION_DOWN: + downTimes[pointerId] = now; + motionAction = MotionEvent.ACTION_DOWN; + break; + case ACTION_MOVE: + motionAction = MotionEvent.ACTION_MOVE; + break; + case ACTION_UP: + motionAction = MotionEvent.ACTION_UP; + break; + case ACTION_CANCEL: + motionAction = MotionEvent.ACTION_CANCEL; + break; + default: + Logger.w("Ignoring unknown touch action " + action); + return; + } + + long downTime = downTimes[pointerId]; + if (downTime == 0) { + // A move or an up with no down in front of it - the Editor and the device + // disagree about the gesture, most likely because the stream restarted + // mid-drag. Dropping it is better than injecting a malformed gesture. + Logger.v("Ignoring touch action " + action + " for pointer " + pointerId + ", it is not down"); + return; + } + + if (motionAction == MotionEvent.ACTION_UP || motionAction == MotionEvent.ACTION_CANCEL) { + downTimes[pointerId] = 0; + } + + MotionEvent.PointerProperties properties = new MotionEvent.PointerProperties(); + properties.id = pointerId; + properties.toolType = MotionEvent.TOOL_TYPE_FINGER; + + MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords(); + coords.x = size.pixelX(nx); + coords.y = size.pixelY(ny); + // A touchscreen event with zero pressure and size reads as a hover on some + // devices, so an active pointer always reports some. + coords.pressure = motionAction == MotionEvent.ACTION_UP + ? 0f + : Math.min(Math.max(pressure, 0.1f), 1f); + coords.size = 1f; + + inputManager.inject(InputManagerWrapper.obtainMotionEvent( + downTime, now, motionAction, properties, coords, InputDevice.SOURCE_TOUCHSCREEN), displayId); + } +} diff --git a/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/wrappers/DisplayManagerWrapper.java b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/wrappers/DisplayManagerWrapper.java new file mode 100644 index 00000000..bf593a75 --- /dev/null +++ b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/wrappers/DisplayManagerWrapper.java @@ -0,0 +1,81 @@ +package com.unity.android.logcat.server.wrappers; + +import com.unity.android.logcat.server.DisplayInfo; +import com.unity.android.logcat.server.Logger; +import com.unity.android.logcat.server.Size; + +import android.annotation.SuppressLint; +import android.hardware.display.VirtualDisplay; +import android.view.Surface; + +import java.lang.reflect.Method; + +/** + * Reflection over {@code android.hardware.display.DisplayManagerGlobal}. + *

+ * The public {@code DisplayManager} API cannot mirror a display into a surface + * without the {@code CAPTURE_VIDEO_OUTPUT} permission, which a shell process + * cannot hold. The hidden API can, and the server runs as {@code shell} via + * app_process, so it is allowed to call it. + */ +@SuppressLint("PrivateApi") +public final class DisplayManagerWrapper { + private final Object manager; // android.hardware.display.DisplayManagerGlobal + private Method getDisplayInfoMethod; + private Method createVirtualDisplayMethod; + + private DisplayManagerWrapper(Object manager) { + this.manager = manager; + } + + public static DisplayManagerWrapper create() throws ReflectiveOperationException { + Class clazz = Class.forName("android.hardware.display.DisplayManagerGlobal"); + Object instance = clazz.getDeclaredMethod("getInstance").invoke(null); + return new DisplayManagerWrapper(instance); + } + + /** @return null when the display does not exist. */ + public DisplayInfo getDisplayInfo(int displayId) throws ReflectiveOperationException { + if (getDisplayInfoMethod == null) { + getDisplayInfoMethod = manager.getClass().getMethod("getDisplayInfo", int.class); + } + Object displayInfo = getDisplayInfoMethod.invoke(manager, displayId); + if (displayInfo == null) { + return null; + } + + Class cls = displayInfo.getClass(); + // logicalWidth/logicalHeight already account for the current rotation. + int width = cls.getDeclaredField("logicalWidth").getInt(displayInfo); + int height = cls.getDeclaredField("logicalHeight").getInt(displayInfo); + int rotation = cls.getDeclaredField("rotation").getInt(displayInfo); + int layerStack = cls.getDeclaredField("layerStack").getInt(displayInfo); + int flags = cls.getDeclaredField("flags").getInt(displayInfo); + int dpi = cls.getDeclaredField("logicalDensityDpi").getInt(displayInfo); + + return new DisplayInfo(displayId, new Size(width, height), rotation, layerStack, flags, dpi); + } + + public int[] getDisplayIds() { + try { + return (int[]) manager.getClass().getMethod("getDisplayIds").invoke(manager); + } catch (ReflectiveOperationException e) { + Logger.w("Could not list display ids", e); + return new int[] { 0 }; + } + } + + /** + * Creates a virtual display mirroring {@code displayIdToMirror} into + * {@code surface}, via the hidden static + * {@code DisplayManager.createVirtualDisplay(String, int, int, int, Surface)}. + */ + public VirtualDisplay createVirtualDisplay(String name, int width, int height, int displayIdToMirror, Surface surface) + throws ReflectiveOperationException { + if (createVirtualDisplayMethod == null) { + createVirtualDisplayMethod = android.hardware.display.DisplayManager.class + .getMethod("createVirtualDisplay", String.class, int.class, int.class, int.class, Surface.class); + } + return (VirtualDisplay) createVirtualDisplayMethod.invoke(null, name, width, height, displayIdToMirror, surface); + } +} diff --git a/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/wrappers/InputManagerWrapper.java b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/wrappers/InputManagerWrapper.java new file mode 100644 index 00000000..8ddf9cc2 --- /dev/null +++ b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/wrappers/InputManagerWrapper.java @@ -0,0 +1,149 @@ +package com.unity.android.logcat.server.wrappers; + +import com.unity.android.logcat.server.Logger; + +import android.annotation.SuppressLint; +import android.view.InputEvent; +import android.view.MotionEvent; + +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Map; + +/** + * Reflection over the hidden input injection API. + *

+ * Injecting an event into a window the caller does not own needs the + * {@code INJECT_EVENTS} signature permission, which an app cannot hold but the + * {@code shell} user can - so this works here and would not work from a normal app. + */ +@SuppressLint("PrivateApi") +public final class InputManagerWrapper { + /** android.hardware.input.InputManager.INJECT_INPUT_EVENT_MODE_ASYNC */ + private static final int INJECT_INPUT_EVENT_MODE_ASYNC = 0; + + private final Object manager; + private final Method injectInputEventMethod; + + private InputManagerWrapper(Object manager, Method injectInputEventMethod) { + this.manager = manager; + this.injectInputEventMethod = injectInputEventMethod; + } + + public static InputManagerWrapper create() throws ReflectiveOperationException { + Object manager; + try { + // Android 14 moved the singleton to InputManagerGlobal and removed + // InputManager.getInstance(). + Class globalClass = Class.forName("android.hardware.input.InputManagerGlobal"); + manager = globalClass.getDeclaredMethod("getInstance").invoke(null); + Logger.d("Injecting input via InputManagerGlobal"); + } catch (ClassNotFoundException | NoSuchMethodException e) { + Class managerClass = Class.forName("android.hardware.input.InputManager"); + manager = managerClass.getDeclaredMethod("getInstance").invoke(null); + Logger.d("Injecting input via InputManager"); + } + + if (manager == null) { + throw new ReflectiveOperationException("Could not obtain an input manager instance"); + } + + Method method = manager.getClass().getMethod("injectInputEvent", InputEvent.class, int.class); + return new InputManagerWrapper(manager, method); + } + + /** + * Cached per concrete event class, not once for all of them: {@code KeyEvent} and + * {@code MotionEvent} each declare their own {@code setDisplayId}, so a method + * resolved from one and invoked on the other throws + * {@code IllegalArgumentException} - which is not a + * {@code ReflectiveOperationException}, so it would escape the catch below, take + * out the control reader thread and stop the stream with it. + */ + private static final Map, Method> setDisplayIdMethods = new HashMap<>(); + private static boolean setDisplayIdUnavailable; + + /** + * Targets an event at a specific display. Without this an event goes to the default + * display, which is wrong when capturing any other one. The setter is hidden API, so + * a device without it means input on secondary displays does not work - the video + * stream is unaffected, hence a warning rather than a failure. + */ + public static void setDisplayId(InputEvent event, int displayId) { + if (setDisplayIdUnavailable) { + return; + } + Class eventClass = event.getClass(); + try { + // Resolved on the concrete class: KeyEvent and MotionEvent each declare + // their own, and which one exists on InputEvent varies by version. + Method method = setDisplayIdMethods.get(eventClass); + if (method == null) { + method = eventClass.getMethod("setDisplayId", int.class); + setDisplayIdMethods.put(eventClass, method); + } + method.invoke(event, displayId); + } catch (ReflectiveOperationException | IllegalArgumentException e) { + setDisplayIdUnavailable = true; + Logger.w("setDisplayId is unavailable, input will go to the default display", e); + } + } + + /** + * A one pointer {@link MotionEvent} at the given position. The arguments the + * injectors never vary are fixed here, so that the long {@code obtain} call is + * written once. + */ + public static MotionEvent obtainMotionEvent(long downTime, long eventTime, int action, + MotionEvent.PointerProperties properties, MotionEvent.PointerCoords coords, int source) { + return MotionEvent.obtain( + downTime, + eventTime, + action, + 1, // pointerCount + new MotionEvent.PointerProperties[] { properties }, + new MotionEvent.PointerCoords[] { coords }, + 0, // metaState + 0, // buttonState + 1f, // xPrecision + 1f, // yPrecision + 0, // deviceId + 0, // edgeFlags + source, + 0); // flags + } + + /** + * Sends an event to the display being captured and recycles it, which is what every + * injector does with one. + * + * @return false when the event was rejected, which the caller should not treat as fatal. + */ + public boolean inject(InputEvent event, int displayId) { + try { + if (displayId != 0) { + setDisplayId(event, displayId); + } + return injectInputEvent(event); + } finally { + if (event instanceof MotionEvent) { + // A KeyEvent from KeyCharacterMap is not ours to recycle, and recycling + // one that is still referenced is worse than not recycling it at all. + ((MotionEvent) event).recycle(); + } + } + } + + /** @return false when the event was rejected, which the caller should not treat as fatal. */ + public boolean injectInputEvent(InputEvent event) { + try { + // Async: we do not wait for the event to be dispatched. A live view sends a + // steady stream of moves and none of them is worth a round trip. + Object result = injectInputEventMethod.invoke(manager, event, INJECT_INPUT_EVENT_MODE_ASYNC); + return !(result instanceof Boolean) || (Boolean) result; + } catch (ReflectiveOperationException e) { + Logger.w("Failed to inject an input event", e); + return false; + } + } +} diff --git a/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/wrappers/SurfaceControlWrapper.java b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/wrappers/SurfaceControlWrapper.java new file mode 100644 index 00000000..f7c277da --- /dev/null +++ b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/wrappers/SurfaceControlWrapper.java @@ -0,0 +1,64 @@ +package com.unity.android.logcat.server.wrappers; + +import android.annotation.SuppressLint; +import android.graphics.Rect; +import android.os.IBinder; +import android.view.Surface; + +import java.lang.reflect.Method; + +/** + * Reflection over {@code android.view.SurfaceControl}. + *

+ * This is the fallback path for creating a mirrored display. It predates + * {@code DisplayManagerGlobal.createVirtualDisplay} and still works on devices + * where that call is missing or broken, but {@code createDisplay} was removed in + * Android 15, so it cannot be the primary path either. Whichever one works first + * wins - see {@code ScreenStreamer.startSession}. + */ +@SuppressLint("PrivateApi") +public final class SurfaceControlWrapper { + private static final Class CLASS; + + static { + try { + CLASS = Class.forName("android.view.SurfaceControl"); + } catch (ClassNotFoundException e) { + throw new AssertionError(e); + } + } + + private SurfaceControlWrapper() { + } + + public static IBinder createDisplay(String name, boolean secure) throws ReflectiveOperationException { + Method method = CLASS.getMethod("createDisplay", String.class, boolean.class); + return (IBinder) method.invoke(null, name, secure); + } + + public static void destroyDisplay(IBinder displayToken) throws ReflectiveOperationException { + CLASS.getMethod("destroyDisplay", IBinder.class).invoke(null, displayToken); + } + + public static void setDisplaySurface(IBinder displayToken, Surface surface) throws ReflectiveOperationException { + CLASS.getMethod("setDisplaySurface", IBinder.class, Surface.class).invoke(null, displayToken, surface); + } + + public static void setDisplayProjection(IBinder displayToken, int orientation, Rect layerStackRect, Rect displayRect) + throws ReflectiveOperationException { + CLASS.getMethod("setDisplayProjection", IBinder.class, int.class, Rect.class, Rect.class) + .invoke(null, displayToken, orientation, layerStackRect, displayRect); + } + + public static void setDisplayLayerStack(IBinder displayToken, int layerStack) throws ReflectiveOperationException { + CLASS.getMethod("setDisplayLayerStack", IBinder.class, int.class).invoke(null, displayToken, layerStack); + } + + public static void openTransaction() throws ReflectiveOperationException { + CLASS.getMethod("openTransaction").invoke(null); + } + + public static void closeTransaction() throws ReflectiveOperationException { + CLASS.getMethod("closeTransaction").invoke(null); + } +} From f5ba24ad90896247b95969b573c6992cf1d3c522 Mon Sep 17 00:00:00 2001 From: Tomas Date: Wed, 23 Sep 2026 15:40:11 +0300 Subject: [PATCH 02/10] CI: build the server jar before packing the package The jar the live stream pushes to the device is a build output and is not committed, so a clone has none. A build_server_jar job produces it and the pack job now depends on it, which is also what puts it in the tarball the test jobs install. Co-Authored-By: Claude Opus 5 --- .gitignore | 4 ++ .yamato/build-server-jar.yml | 44 +++++++++++++++++++ .yamato/wrench/package-pack-jobs.yml | 2 + .../CI/Settings/UnityMobileLogcatSettings.cs | 9 ++++ 4 files changed, 59 insertions(+) create mode 100644 .yamato/build-server-jar.yml diff --git a/.gitignore b/.gitignore index 6a3db807..84800a9b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ [Tt]emp/ [Ss]treaming[Aa]ssets/ [Ll]ogs/ +LocalTestResults/ **/UserSettings/** .utmp .vs @@ -31,3 +32,6 @@ .vsconfig packages-lock.json Tools/CI/bin + +# Built from External/UnityLogcatServer by "gradlew dexJar"; CI produces it on demand +com.unity.mobile.android-logcat/External~/unity-logcat-server.jar diff --git a/.yamato/build-server-jar.yml b/.yamato/build-server-jar.yml new file mode 100644 index 00000000..fffc4f34 --- /dev/null +++ b/.yamato/build-server-jar.yml @@ -0,0 +1,44 @@ +build_server_jar: + name: Build live stream server jar + agent: + type: Unity::VM + image: package-ci/ubuntu-22.04:v4 + flavor: b1.large + commands: + # The Android module ships everything the build needs: platforms/android-36, + # build-tools/36.0.0 and OpenJDK 17. Nothing else has to be provisioned. + - unity-downloader-cli -u 6000.0 -c editor -c android --wait + # One entry, several lines: Yamato writes each entry to its own script, so an + # `export` reaches the lines below it but not the next entry. + # + # The SDK and JDK are located rather than hardcoded, because the editor layout + # differs between platforms. Absolute paths, from $PWD: Gradle resolves a relative + # ANDROID_HOME against the project directory - External/UnityLogcatServer - rather + # than against the directory this runs in, which is what made the first attempt + # fail with "Android SDK not found". `find -L` because those directories can be + # symlinks into the editor's own layout, which plain `-type d` would skip. + # + # Run through `sh` rather than as `./gradlew`, so the job does not depend on the + # wrapper's executable bit surviving in git. It is set, but this repo is worked on + # from Windows where `core.fileMode` is false, so the bit is invisible there and a + # wrapper upgrade can drop it without anyone noticing until this job fails. + - | + set -e + export ANDROID_HOME="$(find -L "$PWD/.Editor" -type d -path '*AndroidPlayer/SDK' | head -1)" + export JAVA_HOME="$(find -L "$PWD/.Editor" -type d -path '*AndroidPlayer/OpenJDK' | head -1)" + # Echoed so that a layout change shows up here, rather than as a puzzling + # failure fifteen seconds later inside Gradle. + echo "ANDROID_HOME=$ANDROID_HOME" + echo "JAVA_HOME=$JAVA_HOME" + test -d "$ANDROID_HOME" || { echo "No Android SDK under $PWD/.Editor"; exit 1; } + test -d "$JAVA_HOME" || { echo "No JDK under $PWD/.Editor"; exit 1; } + sh External/UnityLogcatServer/gradlew -p External/UnityLogcatServer dexJar + artifacts: + # The build copies the jar into the package; this is the path the pack job needs + # it at, and Yamato restores a dependency's artifacts into the same relative + # location in the dependent job's workspace. + server_jar: + paths: + - "com.unity.mobile.android-logcat/External~/unity-logcat-server.jar" + # No triggers: this runs as a dependency of the package pack job. Giving it its own + # branch trigger would just build the same jar twice per push. diff --git a/.yamato/wrench/package-pack-jobs.yml b/.yamato/wrench/package-pack-jobs.yml index e891afa1..20259e7c 100644 --- a/.yamato/wrench/package-pack-jobs.yml +++ b/.yamato/wrench/package-pack-jobs.yml @@ -25,6 +25,8 @@ package_pack_-_mobile_android-logcat: packages: paths: - upm-ci~/packages/**/* + dependencies: + - path: .yamato/build-server-jar.yml#build_server_jar variables: UPMCI_ACK_LARGE_PACKAGE: 1 WRENCH_LOCALAPV_URL: https://artifactory.prd.it.unity3d.com/artifactory/stevedore-unity-internal/wrench-localapv/1-3-18_814805bc36916a15e7c4fffd9156635086c96203ca783d5e2fb2e47f160d4eca.zip diff --git a/Tools/CI/Settings/UnityMobileLogcatSettings.cs b/Tools/CI/Settings/UnityMobileLogcatSettings.cs index ec22088b..f36091e2 100644 --- a/Tools/CI/Settings/UnityMobileLogcatSettings.cs +++ b/Tools/CI/Settings/UnityMobileLogcatSettings.cs @@ -20,6 +20,15 @@ public class UnityMobileLogcatSettings : AnnotatedSettingsBase { IsReleasing = true }, + PackJobOptions = new PackJobOptions() + { + Dependencies = new List() + { + // External~/unity-logcat-server.jar is a build output and is not + // committed, so it has to be built before the package is packed. + new("build-server-jar", "build_server_jar") + } + }, CustomChecks = new HashSet() { new Dependency("upm-ci", "test_all_trigger") From b90bedb01c689da1aa9c04157c69281b5e9c0f31 Mon Sep 17 00:00:00 2001 From: Tomas Date: Wed, 30 Sep 2026 14:51:47 +0300 Subject: [PATCH 03/10] bump to .net 10 --- Tools/CI/UnityMobileLogcat.Cookbook.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tools/CI/UnityMobileLogcat.Cookbook.csproj b/Tools/CI/UnityMobileLogcat.Cookbook.csproj index ab654610..98d0df4e 100644 --- a/Tools/CI/UnityMobileLogcat.Cookbook.csproj +++ b/Tools/CI/UnityMobileLogcat.Cookbook.csproj @@ -2,7 +2,7 @@ Exe - net8.0 + net10.0 enable enable From 66e586713392f72704037984e4f9f8f8a28c543e Mon Sep 17 00:00:00 2001 From: Tomas Date: Wed, 30 Sep 2026 14:56:44 +0300 Subject: [PATCH 04/10] Add a helper for running the server's Gradle build from the Editor RunGradle runs a task in a Gradle project behind a cancelable progress bar, taking the JDK and SDK from Preferences > External Tools rather than from the environment, streaming Gradle's output to Editor.log as it arrives and killing the process tree - launcher script and daemon both - when a build is cancelled or runs past its timeout. Nothing calls it on this branch yet; the live stream's debug button that builds the server jar stayed behind on the livestream branch. Co-Authored-By: Claude Opus 5 --- .../Editor/AndroidLogcatUtilities.cs | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatUtilities.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatUtilities.cs index 60cb6cbc..59b26b03 100644 --- a/com.unity.mobile.android-logcat/Editor/AndroidLogcatUtilities.cs +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatUtilities.cs @@ -89,6 +89,187 @@ public static string SanitizeFileName(string name) } + // Long enough for a first run, which downloads Gradle itself. + const int kGradleTimeoutMs = 5 * 60 * 1000; + const int kGradleProgressUpdateMs = 200; + + ///

+ /// Kills a process and whatever it started. Gradle runs behind a launcher + /// script and does its work in a daemon, so killing only the process we started + /// leaves the build running. + /// + static void KillProcessTree(System.Diagnostics.Process process) + { + try + { + if (Application.platform == RuntimePlatform.WindowsEditor) + { + var killer = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = "taskkill", + Arguments = $"/T /F /PID {process.Id}", + UseShellExecute = false, + CreateNoWindow = true + }); + killer?.WaitForExit(5000); + killer?.Dispose(); + } + else + { + process.Kill(); + } + } + catch (Exception ex) + { + Debug.LogWarning($"Failed to stop Gradle.\n{ex.Message}"); + } + } + + /// + /// Runs a Gradle task in a project directory and says whether it succeeded, + /// logging its output either way. + /// + /// The JDK and SDK come from Unity's own External Tools settings rather than + /// from the environment: the Editor may not have inherited a shell environment + /// at all, the one it did inherit is not necessarily the one this build wants, + /// and a user who pointed Unity at their own SDK or JDK means it. The wrapper + /// is run through sh off Windows, so that this does not depend on its + /// executable bit, which is invisible to anyone working from Windows. + /// + /// + /// Blocking, behind a progress bar. This is a developer action - there is no + /// hot path here - and a Gradle build wants the Editor to sit still anyway. + /// + /// + internal static bool RunGradle(string projectDirectory, string task) + { + if (string.IsNullOrEmpty(projectDirectory) || !Directory.Exists(projectDirectory)) + { + Debug.LogError($"No Gradle project at '{projectDirectory}'."); + return false; + } + + string androidHome; + string javaHome; + try + { + androidHome = AndroidBridge.AndroidExternalToolsSettings.sdkRootPath; + javaHome = AndroidBridge.AndroidExternalToolsSettings.jdkRootPath; + } + catch (Exception ex) + { + Debug.LogError("Could not read the Android SDK and JDK locations from " + + $"Preferences > External Tools.\n{ex.Message}"); + return false; + } + + var windows = Application.platform == RuntimePlatform.WindowsEditor; + + var process = new System.Diagnostics.Process(); + var si = process.StartInfo; + si.WorkingDirectory = projectDirectory; + si.FileName = windows ? Path.Combine(projectDirectory, "gradlew.bat") : "sh"; + si.Arguments = windows ? task : $"gradlew {task}"; + // Left unset when a path is not configured, rather than pointed at nothing: + // Gradle then falls back to local.properties or an inherited variable, which + // is a better answer than a directory that does not exist. + if (!string.IsNullOrEmpty(javaHome) && Directory.Exists(javaHome)) + si.EnvironmentVariables["JAVA_HOME"] = javaHome; + if (!string.IsNullOrEmpty(androidHome) && Directory.Exists(androidHome)) + si.EnvironmentVariables["ANDROID_HOME"] = androidHome; + si.UseShellExecute = false; + si.CreateNoWindow = true; + si.RedirectStandardOutput = true; + si.RedirectStandardError = true; + + var output = new System.Text.StringBuilder(); + // What Gradle said last, which is the only sign of progress it gives while + // a build runs. + var lastLine = string.Empty; + + try + { + var title = $"Running Gradle in {Path.GetFileName(projectDirectory)}"; + var command = $"{si.FileName} {si.Arguments}"; + EditorUtility.DisplayProgressBar(title, command, 0); + + System.Diagnostics.DataReceivedEventHandler record = (s, e) => + { + if (string.IsNullOrEmpty(e.Data)) + return; + // Straight to Editor.log as it arrives: the collected log is only + // reported once the build is over, which is no help while watching + // one that is stuck. + Console.WriteLine(e.Data); + lock (output) + { + output.AppendLine(e.Data); + lastLine = e.Data; + } + }; + + process.OutputDataReceived += record; + process.ErrorDataReceived += record; + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + var started = DateTime.Now; + while (!process.WaitForExit(kGradleProgressUpdateMs)) + { + string message; + lock (output) + message = string.IsNullOrEmpty(lastLine) ? command : lastLine; + + var elapsed = DateTime.Now - started; + // Gradle reports no progress of its own, so the bar only says the + // build is still alive - it fills over ten seconds and starts over. + var progress = (float)(elapsed.TotalSeconds % 10.0) / 10.0f; + + if (EditorUtility.DisplayCancelableProgressBar(title, message, progress)) + { + KillProcessTree(process); + Debug.LogWarning($"'gradlew {task}' was cancelled."); + return false; + } + + if (elapsed.TotalMilliseconds >= kGradleTimeoutMs) + { + KillProcessTree(process); + Debug.LogError($"Gradle did not finish within {kGradleTimeoutMs / 1000} s."); + return false; + } + } + + // The redirected output is read on other threads, and the wait above + // only waits for the process: this one waits for that output too. + process.WaitForExit(); + + string log; + lock (output) + log = output.ToString(); + AndroidLogcatInternalLog.Log(log); + + if (process.ExitCode != 0) + { + Debug.LogError($"'gradlew {task}' failed with exit code {process.ExitCode}.\n{log}"); + return false; + } + + return true; + } + catch (Exception ex) + { + Debug.LogError($"Failed to run Gradle in '{projectDirectory}'.\n{ex.Message}"); + return false; + } + finally + { + process.Dispose(); + EditorUtility.ClearProgressBar(); + } + } + /// /// Get the top activity on the given device. /// From 2d8c163e69b1190eea2196fdabd3d4f9d7ee531f Mon Sep 17 00:00:00 2001 From: Tomas Date: Wed, 30 Sep 2026 15:10:04 +0300 Subject: [PATCH 05/10] apply review fix --- .../android/logcat/server/KeyInjector.java | 20 ++++++++++++++++++- .../com/unity/android/logcat/server/Size.java | 10 ++++++++-- .../android/logcat/server/TouchInjector.java | 5 +++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/KeyInjector.java b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/KeyInjector.java index e61124da..685d46ce 100644 --- a/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/KeyInjector.java +++ b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/KeyInjector.java @@ -7,6 +7,9 @@ import android.view.KeyCharacterMap; import android.view.KeyEvent; +import java.util.HashMap; +import java.util.Map; + /** * Injects key events and typed text into the device. *

@@ -26,6 +29,10 @@ public final class KeyInjector { private KeyCharacterMap characterMap; + // When each held key went down. A KeyEvent carries that time on every later event + // for the same key, so an app can tell how long the key was held from its up. + private final Map downTimes = new HashMap<>(); + public KeyInjector(InputManagerWrapper inputManager, int displayId) { this.inputManager = inputManager; this.displayId = displayId; @@ -51,8 +58,19 @@ public void injectKey(int action, int keyCode, int metaState) { } long now = SystemClock.uptimeMillis(); + long downTime; + if (keyAction == KeyEvent.ACTION_DOWN) { + Long held = downTimes.get(keyCode); + // A repeat belongs to the press that started it. + downTime = held != null ? held : now; + downTimes.put(keyCode, downTime); + } else { + Long held = downTimes.remove(keyCode); + downTime = held != null ? held : now; + } + KeyEvent event = new KeyEvent( - now, // downTime + downTime, now, // eventTime keyAction, keyCode, diff --git a/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/Size.java b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/Size.java index 274ea4da..d9659519 100644 --- a/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/Size.java +++ b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/Size.java @@ -32,12 +32,18 @@ public int getHeight() { * still read as a swipe to the edge of the screen. */ public float pixelX(float normalized) { - return clamp01(normalized) * width; + return clamp01(normalized) * lastPixel(width); } /** The same down the display. */ public float pixelY(float normalized) { - return clamp01(normalized) * height; + return clamp01(normalized) * lastPixel(height); + } + + // 1 maps to the last pixel of the display rather than to the first one past it, + // which is outside its bounds and would hit nothing. + private static int lastPixel(int size) { + return size > 0 ? size - 1 : 0; } private static float clamp01(float value) { diff --git a/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/TouchInjector.java b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/TouchInjector.java index 8f8396f6..18b2bca0 100644 --- a/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/TouchInjector.java +++ b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/TouchInjector.java @@ -17,6 +17,11 @@ * Scaling here, against the display size the capture session is currently using, means * a touch always lands where the user pointed even if the display changed size in the * meantime. + *

+ * One finger at a time: the Editor drives this from a mouse and only ever sends pointer + * 0. Real multi-touch would need every active pointer in a single event, with + * ACTION_POINTER_DOWN and ACTION_POINTER_UP from the second finger onwards - a second + * ACTION_DOWN as sent here replaces the first gesture rather than adding to it. */ public final class TouchInjector { public static final int ACTION_DOWN = 0; From 34ce87369181102ca2c36d2ba93576a366575e36 Mon Sep 17 00:00:00 2001 From: Tomas Date: Wed, 30 Sep 2026 15:10:11 +0300 Subject: [PATCH 06/10] apply review fix --- Tools/CI/global.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tools/CI/global.json b/Tools/CI/global.json index 18b689d1..cb40a970 100644 --- a/Tools/CI/global.json +++ b/Tools/CI/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "8.0.0", + "version": "10.0.0", "rollForward": "latestMinor", "allowPrerelease": false } From b05b79d8244bcb14e5be009696e62ed8fa85d261 Mon Sep 17 00:00:00 2001 From: Tomas Date: Wed, 30 Sep 2026 15:23:11 +0300 Subject: [PATCH 07/10] apply review --- .../android/logcat/server/ScreenStreamer.java | 36 +++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/ScreenStreamer.java b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/ScreenStreamer.java index b287eba4..54d31781 100644 --- a/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/ScreenStreamer.java +++ b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/ScreenStreamer.java @@ -63,6 +63,7 @@ public final class ScreenStreamer implements Closeable { private long lastFrameNs; private Bitmap paddedBitmap; + private ByteBuffer paddingBuffer; private Bitmap frameBitmap; private Canvas frameCanvas; private final JpegBuffer jpeg = new JpegBuffer(); @@ -294,9 +295,18 @@ private void encodeAndSend(Image image, long captureNs) throws IOException { Bitmap padded = obtainPaddedBitmap(paddedWidth, height); buffer.rewind(); int required = padded.getRowBytes() * height; - if (buffer.remaining() < required) { - Logger.w("Frame plane is " + buffer.remaining() + " bytes, expected " + required + "; skipping frame"); - return; + int available = buffer.remaining(); + if (available < required) { + // A plane only has to hold the visible pixels of its last row - the + // padding that follows every other row may be missing from it. The + // bitmap wants a full row either way, so the frame is copied into one + // that has it rather than dropped. + int minimum = rowStride * (height - 1) + width * pixelStride; + if (available < minimum) { + Logger.w("Frame plane is " + available + " bytes, expected " + minimum + "; skipping frame"); + return; + } + buffer = padToFullRows(buffer, required); } padded.copyPixelsFromBuffer(buffer); @@ -327,6 +337,26 @@ private void encodeAndSend(Image image, long captureNs) throws IOException { statsBytes += jpeg.size(); } + /** + * Copies a plane whose last row stops at its last visible pixel into a buffer of + * whole rows, which is what {@link Bitmap#copyPixelsFromBuffer} expects. Reused + * between frames: this runs per frame on the devices that need it at all. + */ + private ByteBuffer padToFullRows(ByteBuffer source, int required) { + if (paddingBuffer == null || paddingBuffer.capacity() < required) { + paddingBuffer = ByteBuffer.allocateDirect(required); + } + + paddingBuffer.clear(); + paddingBuffer.limit(required); + paddingBuffer.put(source); + while (paddingBuffer.hasRemaining()) { + paddingBuffer.put((byte) 0); + } + paddingBuffer.rewind(); + return paddingBuffer; + } + private Bitmap obtainPaddedBitmap(int width, int height) { if (paddedBitmap == null || paddedBitmap.getWidth() != width || paddedBitmap.getHeight() != height) { if (paddedBitmap != null) { From c36685d2fc1ad3cac5a4dc1ac06c902cd4c43af7 Mon Sep 17 00:00:00 2001 From: Tomas Date: Wed, 30 Sep 2026 15:35:24 +0300 Subject: [PATCH 08/10] apply review --- .../server/wrappers/InputManagerWrapper.java | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/wrappers/InputManagerWrapper.java b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/wrappers/InputManagerWrapper.java index 8ddf9cc2..e9d71242 100644 --- a/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/wrappers/InputManagerWrapper.java +++ b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/wrappers/InputManagerWrapper.java @@ -68,10 +68,14 @@ public static InputManagerWrapper create() throws ReflectiveOperationException { * display, which is wrong when capturing any other one. The setter is hidden API, so * a device without it means input on secondary displays does not work - the video * stream is unaffected, hence a warning rather than a failure. + * + * @return false when the event could not be aimed, in which case it must not be + * injected: it would act on the default display, which is not the one the + * user is looking at. */ - public static void setDisplayId(InputEvent event, int displayId) { + public static boolean setDisplayId(InputEvent event, int displayId) { if (setDisplayIdUnavailable) { - return; + return false; } Class eventClass = event.getClass(); try { @@ -83,9 +87,11 @@ public static void setDisplayId(InputEvent event, int displayId) { setDisplayIdMethods.put(eventClass, method); } method.invoke(event, displayId); + return true; } catch (ReflectiveOperationException | IllegalArgumentException e) { setDisplayIdUnavailable = true; - Logger.w("setDisplayId is unavailable, input will go to the default display", e); + Logger.w("setDisplayId is unavailable, input to other displays will be dropped", e); + return false; } } @@ -121,8 +127,8 @@ public static MotionEvent obtainMotionEvent(long downTime, long eventTime, int a */ public boolean inject(InputEvent event, int displayId) { try { - if (displayId != 0) { - setDisplayId(event, displayId); + if (displayId != 0 && !setDisplayId(event, displayId)) { + return false; } return injectInputEvent(event); } finally { From 35ff047e55ea50dfb7de0373b52e8f5bd5ab3594 Mon Sep 17 00:00:00 2001 From: Tomas Date: Thu, 1 Oct 2026 16:15:27 +0300 Subject: [PATCH 09/10] apply review --- .../android/logcat/server/ScreenStreamer.java | 7 +++++++ .../unity/android/logcat/server/Server.java | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/ScreenStreamer.java b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/ScreenStreamer.java index 54d31781..4fa7939d 100644 --- a/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/ScreenStreamer.java +++ b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/ScreenStreamer.java @@ -153,6 +153,13 @@ private DisplayInfo readDisplayInfo() throws ReflectiveOperationException, IOExc private void startSession(DisplayInfo info) throws IOException { synchronized (sessionLock) { + if (stopped) { + // close() can land between the poll loop deciding to restart after a + // rotation and this: the handler the session would be driven by is + // already gone, and a session started now would never be torn down. + return; + } + displaySize = info.getSize(); videoSize = info.getSize().limit(options.getMaxSize()); int width = videoSize.getWidth(); diff --git a/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/Server.java b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/Server.java index 066e01ac..81862b00 100644 --- a/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/Server.java +++ b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/Server.java @@ -83,6 +83,7 @@ private static void run(Options options) throws Exception { ScreenStreamer streamer = null; try { socket = accept(serverSocket, options.getConnectTimeoutMs()); + rejectUnlessAdb(socket); Logger.d("Client connected"); Protocol protocol = new Protocol(socket.getOutputStream()); @@ -128,6 +129,24 @@ private static void run(Options options) throws Exception { * server whose Editor died does not sit on the device forever. A timeout of 0 * waits indefinitely. */ + // Process.ROOT_UID and Process.SHELL_UID, which are hidden API. + private static final int ROOT_UID = 0; + private static final int SHELL_UID = 2000; + + /** + * An abstract socket carries no filesystem permissions, so anything running on the + * device can reach it - and a client of this one gets the screen and the ability to + * inject input. Only adb's forwarded connections are meant to: adbd runs as + * {@code shell}, or as root on a userdebug build, while an app always has a uid of + * its own from 10000 up. + */ + private static void rejectUnlessAdb(LocalSocket socket) throws IOException { + int uid = socket.getPeerCredentials().getUid(); + if (uid != ROOT_UID && uid != SHELL_UID) { + throw new IOException("Rejected a connection from uid " + uid + ", only adb may connect"); + } + } + private static LocalSocket accept(LocalServerSocket serverSocket, int timeoutMs) throws IOException { if (timeoutMs <= 0) { return serverSocket.accept(); From 97c2dac7f6fb668dfafe358ce0d819e1cd46b2dd Mon Sep 17 00:00:00 2001 From: Tomas Date: Thu, 1 Oct 2026 18:00:51 +0300 Subject: [PATCH 10/10] appy review fix --- External/UnityLogcatServer/README.md | 26 +++++- .../android/logcat/server/KeyInjector.java | 81 +++++++++++++++++-- 2 files changed, 97 insertions(+), 10 deletions(-) diff --git a/External/UnityLogcatServer/README.md b/External/UnityLogcatServer/README.md index d09afa61..b24c105e 100644 --- a/External/UnityLogcatServer/README.md +++ b/External/UnityLogcatServer/README.md @@ -242,10 +242,28 @@ Scroll, 9 bytes: Keys and text are separate on purpose. A named key - Back, Enter, an arrow - has no character to type and goes as a keycode. Typed characters go as text and are turned -into key events on the device by `KeyCharacterMap`, which is what makes punctuation, -shifted characters and non-US layouts work: the Editor sends the character the user -actually produced and the device works out which keystrokes would produce it, rather -than the Editor trying to model every layout. +into key events on the device by `KeyCharacterMap`: the Editor sends the character the +user actually produced and the device works out which keystrokes would produce it, +rather than the Editor trying to model every layout. + +How far that reaches is the device's keyboard layout's decision, and it is worth +knowing where the edge is. ASCII, punctuation and shifted characters included, is typed +directly. An accented character is typed the way a keyboard with dead keys types it, as +the accent followed by the base letter; the pair comes from the character's canonical +Unicode decomposition, and the accent is sent in its combining form, U+0301 rather than +U+00B4, because that is what a dead key produces. + +That only works for the accents the layout actually has a dead key for. A Pixel's +`Virtual.kcm` has five, all on Alt: grave, acute, circumflex, tilde and diaeresis. So +`a` with an acute accent types, while a caron or an ogonek - and therefore most of +Lithuanian, Czech or Polish - does not, and neither does anything outside the Latin +script. `adb shell input text` fails on the same characters for the same reason; this +is the keyboard layout's limit, not the server's. + +`getEvents` refuses the whole array when a single character is unavailable, so text is +retried character by character: everything typable arrives and only the characters that +cannot be typed are dropped, named together in one warning. Typing those would need the +device's clipboard instead of its keyboard. A scroll carries a position because that is what decides which view receives it, and its magnitude is fixed point so that a trackpad's fractions survive without diff --git a/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/KeyInjector.java b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/KeyInjector.java index 685d46ce..a588ac79 100644 --- a/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/KeyInjector.java +++ b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/KeyInjector.java @@ -7,6 +7,7 @@ import android.view.KeyCharacterMap; import android.view.KeyEvent; +import java.text.Normalizer; import java.util.HashMap; import java.util.Map; @@ -15,10 +16,18 @@ *

* There are two paths on purpose. Named keys - Back, Enter, the arrows - arrive as an * Android keycode and become a {@link KeyEvent} directly. Typed characters arrive as - * text and are turned into key events by {@link KeyCharacterMap}, which is what makes - * punctuation, shifted characters and non-US layouts work: the Editor sends the + * text and are turned into key events by {@link KeyCharacterMap}: the Editor sends the * character the user actually produced and lets the device work out which keystrokes * would have produced it, instead of the Editor trying to map every layout itself. + *

+ * How far that reaches is the device's keyboard layout's decision. ASCII is typed + * directly. An accented character is typed the way a keyboard with dead keys types it, + * as the accent followed by the base letter, which works only for the accents that + * layout has a dead key for - a Pixel's {@code Virtual.kcm} has five: grave, acute, + * circumflex, tilde and diaeresis, so a caron or an ogonek cannot be typed at all. + * Nor can anything outside the Latin script. Those characters are skipped and + * reported, and the rest of the text still arrives; sending them would need the + * device's clipboard rather than its keyboard. */ public final class KeyInjector { public static final int ACTION_DOWN = 0; @@ -95,16 +104,76 @@ public void injectText(String text) { } KeyEvent[] events = characterMap.getEvents(text.toCharArray()); - if (events == null) { - // The virtual keyboard layout cannot produce one of these characters. There - // is no keystroke sequence to fall back to, so say so and move on. - Logger.w("Cannot type '" + text + "' with the virtual keyboard layout"); + if (events != null) { + for (KeyEvent event : events) { + inject(event); + } return; } + // getEvents gives up on the whole array when a single character cannot be + // typed, so the fallback goes character by character: one 'a' the keyboard has + // never heard of no longer costs the rest of the message. + StringBuilder skipped = null; + for (char c : text.toCharArray()) { + if (injectChar(c)) { + continue; + } + if (skipped == null) { + skipped = new StringBuilder(); + } + skipped.append(c); + } + + if (skipped != null) { + Logger.w("Cannot type '" + skipped + "' with the virtual keyboard layout"); + } + } + + /** @return false when there is no way to type this character. */ + private boolean injectChar(char c) { + KeyEvent[] events = characterMap.getEvents(new char[] { c }); + if (events == null) { + char[] composed = decompose(c); + events = composed == null ? null : characterMap.getEvents(composed); + } + if (events == null) { + return false; + } + for (KeyEvent event : events) { inject(event); } + return true; + } + + /** + * The keystrokes that type an accented character on a keyboard with dead keys: the + * accent, then the letter it belongs to. + *

+ * Unicode already knows how every accented character is built, so the pair comes + * from a canonical decomposition rather than from a table of our own. The accent + * has to be the combining form, U+0301 and not U+00B4 - that is what a dead key on + * the device's keyboard layout produces, while the spacing form matches no key at + * all. + * + * @return null when the character is not an accented letter. + */ + private static char[] decompose(char c) { + String decomposed = Normalizer.normalize(String.valueOf(c), Normalizer.Form.NFD); + if (decomposed.length() != 2) { + return null; + } + + char base = decomposed.charAt(0); + char accent = decomposed.charAt(1); + // Combining Diacritical Marks. Any other decomposition is not something a dead + // key types. + if (accent < '\u0300' || accent > '\u036F') { + return null; + } + + return new char[] { accent, base }; } private void inject(KeyEvent event) {