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/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..b24c105e --- /dev/null +++ b/External/UnityLogcatServer/README.md @@ -0,0 +1,327 @@ +# 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`: 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 +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 00000000..490fda85 Binary files /dev/null and b/External/UnityLogcatServer/gradle/wrapper/gradle-wrapper.jar differ 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..a588ac79 --- /dev/null +++ b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/KeyInjector.java @@ -0,0 +1,182 @@ +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; + +import java.text.Normalizer; +import java.util.HashMap; +import java.util.Map; + +/** + * 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}: 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; + public static final int ACTION_UP = 1; + + private final InputManagerWrapper inputManager; + private final int displayId; + + 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; + } + + /** + * @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(); + 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( + 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) { + 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) { + 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..4fa7939d --- /dev/null +++ b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/ScreenStreamer.java @@ -0,0 +1,457 @@ +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 ByteBuffer paddingBuffer; + 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) { + 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(); + 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; + 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); + + 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(); + } + + /** + * 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) { + 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..81862b00 --- /dev/null +++ b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/Server.java @@ -0,0 +1,248 @@ +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()); + rejectUnlessAdb(socket); + 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. + */ + // 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(); + } + + // 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..d9659519 --- /dev/null +++ b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/Size.java @@ -0,0 +1,104 @@ +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) * lastPixel(width); + } + + /** The same down the display. */ + public float pixelY(float normalized) { + 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) { + 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..18b2bca0 --- /dev/null +++ b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/TouchInjector.java @@ -0,0 +1,120 @@ +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. + *

+ * 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; + 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..e9d71242 --- /dev/null +++ b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/wrappers/InputManagerWrapper.java @@ -0,0 +1,155 @@ +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. + * + * @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 boolean setDisplayId(InputEvent event, int displayId) { + if (setDisplayIdUnavailable) { + return false; + } + 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); + return true; + } catch (ReflectiveOperationException | IllegalArgumentException e) { + setDisplayIdUnavailable = true; + Logger.w("setDisplayId is unavailable, input to other displays will be dropped", e); + return false; + } + } + + /** + * 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 false; + } + 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); + } +} 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") 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 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 } 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. ///