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..d09afa61 --- /dev/null +++ b/External/UnityLogcatServer/README.md @@ -0,0 +1,309 @@ +# UnityLogcatServer + +On-device server for the Android Logcat package's live screen streaming. It +mirrors a device display, encodes each frame as JPEG and writes the frames to a +socket that the Unity Editor reads. The same socket carries touch and key events back +the other way, so the live view is interactive. + +This is not an Android application. It has no manifest, no resources and no +activity - it is a dexed jar started by `app_process`, running as the `shell` +user, which is what lets it call the hidden display-mirroring APIs, and hold the +`INJECT_EVENTS` permission, that a normal app cannot. + +## Building + +Requires a JDK 17+ and an Android SDK with `platforms/android-36` and +`build-tools/36.0.0`. A Unity installation with Android support ships both. + +Point the build at the SDK with any one of: + +* `local.properties` in this directory, containing `sdk.dir=` (gitignored) +* the `ANDROID_HOME` environment variable +* the `ANDROID_SDK_ROOT` environment variable + +Then: + +```sh +./gradlew dexJar +``` + +which produces `build/outputs/unity-logcat-server.jar` and then copies it to +`com.unity.mobile.android-logcat/External~/unity-logcat-server.jar`, which is the +copy the package ships and the Editor pushes to the device. + +That copy step (`copyJarToPackage`) hangs off `dexJar`, so every route that +produces the jar - `dexJar`, `assemble`, `pushJar`, `runJar` - refreshes it, and +the two cannot silently drift apart. Deleting the copy is enough to make the next +build put it back, even when nothing else needs rebuilding. + +It copies the file itself instead of using a `Copy` task, because Gradle creates a +`Copy` task's destination directory before any task action runs - so a guard +against writing to the wrong place could never see it missing. The step instead +checks for the package's `package.json`, which is both a stronger check (it +confirms the destination really is this package) and something Gradle cannot +create on our behalf. If the project is ever moved relative to the package the +build fails naming the expected layout, though Gradle will already have created an +empty `External~` on its way to that failure. + +`External~` is named with a trailing `~` so that Unity ships the folder in the +package but does not import its contents as assets: the jar needs no `.meta` file +and never enters the AssetDatabase. The Editor reads it straight off disk. + +**The copy is a build output and is not committed.** The root `.gitignore` excludes +`com.unity.mobile.android-logcat/External~/unity-logcat-server.jar`; CI builds it +on demand before the package is published. The `External~` folder therefore does +not exist in a fresh checkout - the copy step creates it. Only the jar itself is +ignored, not the folder, so anything else put there later stays visible to git. + +The build uses the plain `java-library` plugin plus an explicit `d8` step rather +than the Android Gradle Plugin. AGP would produce an APK that then has to be +renamed, needs network access to the `google()` repository and pulls in a large +dependency tree - none of which this project has any use for. + +Sources are compiled with `android.jar` replacing the JDK bootclasspath, so +reaching for a desktop-only API is a compile error rather than a crash on the +device. + +## Running it by hand + +`./gradlew runJar` pushes the jar and runs it in the foreground. Or, spelled out: + +```sh +adb push build/outputs/unity-logcat-server.jar /data/local/tmp/ +adb shell CLASSPATH=/data/local/tmp/unity-logcat-server.jar \ + app_process / com.unity.android.logcat.server.Server log_level=debug + +# from another shell +adb forward tcp:27183 localabstract:unity_logcat_server +``` + +and then read frames from `127.0.0.1:27183`. + +Options are `key=value` pairs; `Server.USAGE` lists them: + +| Option | Default | Meaning | +| --- | --- | --- | +| `socket_name` | `unity_logcat_server` | abstract unix socket to listen on | +| `display_id` | `0` | display to capture | +| `max_size` | `1024` | longest side of the stream in pixels, 0 for native | +| `quality` | `70` | JPEG quality, 1..100 | +| `max_fps` | `30` | frame rate cap | +| `connect_timeout_ms` | `10000` | how long to wait for the Editor, 0 waits forever | +| `log_level` | `info` | `verbose`, `debug`, `info`, `warn`, `error` | + +Log output goes to both logcat (tag `UnityLogcatServer`) and stderr, so the +Editor can surface a startup failure from the `adb shell` process it spawned. + +## Lifecycle + +The server handles exactly one client and then exits. There is no daemon, nothing +is left listening between sessions, and a second live-stream session is a second +process. + +### Startup + +1. The Editor pushes the jar to `/data/local/tmp/` and spawns + `adb shell CLASSPATH= app_process / com.unity.android.logcat.server.Server `. + The process runs as the `shell` user, which is what makes the hidden + display-mirroring APIs callable. +2. `Server.main` parses the options and sets the log level. Bad options exit + immediately with the usage text. +3. `DisplayManagerGlobal` is resolved by reflection. If that class is missing the + server fails here, before it has claimed anything. +4. A `LocalServerSocket` is opened on `socket_name` and `Listening on + localabstract:` is logged. Nothing is captured yet - the display is only + mirrored once a client is actually there. +5. The Editor runs `adb forward tcp: localabstract:` and connects. + `adb forward` only succeeds once the socket exists, so the Editor may have to + retry: the server is spawned first, but there is no ordering guarantee between + two separate adb invocations. +6. Input injection is set up. Touch and keys share one input manager, so they are + available together or not at all. A failure here is not fatal: it is reported in + the header flags and the session continues as view-only. +7. On `accept()`, the 20-byte stream header is written straight away. That header + is what tells the Editor it has reached a real server of a protocol version it + understands, rather than a forwarded port that merely happens to connect. +8. The control reader thread starts, and the capture session with it: a + `HandlerThread`, an `ImageReader`, and a mirrored display pointed at the + reader's surface. Frames flow from the capture thread; the main thread re-reads + the display geometry every 500 ms and restarts the session if it changed. + +### Shutdown + +Every path ends in an explicit `System.exit`. `app_process` will not exit on its +own while a `Looper` or a non-daemon thread is alive, and the Editor is waiting +for its `adb shell` to return. + +| Trigger | How it is noticed | Exit code | +| --- | --- | --- | +| Editor closes the connection, its process dies, or the forward is removed | the capture thread's write fails, or the control reader reads EOF | 0 | +| No client connects within `connect_timeout_ms` (default 10 s) | a watchdog thread exits the process out from under the blocked `accept()` | 1 | +| The captured display disappears | the geometry poll gets no `DisplayInfo` | 0 | +| Neither mirroring API works | `startSession` throws | 1 | +| Invalid options | `Options.parse` throws | 2 | +| Anything unexpected | caught in `main` and logged with a stack trace | 1 | + +Three details matter for a clean stop: + +* **The accept timeout exits the process rather than closing the socket.** Closing + the server socket from the watchdog thread would look like the tidier option, but + on Linux closing a file descriptor does not interrupt an `accept()` that another + thread is already parked on - the server would stay wedged forever, which is the + exact orphan the timeout exists to prevent. At that point nothing has been claimed + that needs unwinding, so exiting is both simpler and the only thing that works. + +* **The control reader doubles as the disconnect detector, because writes alone are + not enough.** On a screen that has stopped changing no frames are produced, so + there is no write to fail: a departed client would go unnoticed and the server + would sit there mirroring a display nobody is reading. The reader treats EOF as + the end of the session. +* **The socket is closed before the streamer.** Closing it first unblocks a capture + thread parked in a write, so teardown does not have to wait for it. Teardown then + releases the mirrored display, closes the `ImageReader`, joins the capture thread + (2 s cap) and recycles the reusable bitmaps. + +### If the server is killed outright + +Killing the `adb shell`, or the process on the device, skips all of the above and +leaks nothing that survives: the mirrored display and the `ImageReader` belong to +the process, and the abstract socket name disappears with it. Only the pushed jar +remains on disk, which is inert. The Editor pushes each session's jar under a name +of its own and deletes it when the stream stops, so what a kill leaves behind is +one file that the next stream sweeps up. + +A stale server from a previous session is therefore only a problem if it is still +*running* - it would own the socket name. Passing a per-session unique +`socket_name` avoids the collision entirely, and `connect_timeout_ms` bounds how +long an orphan can linger before it gives up on its own. + +Deleting a jar out from under a server that is still running it is safe, which is +what lets the Editor sweep: the runtime keeps the file it opened, so an unlink only +removes the name. That was measured on Android 16 and Android 8.1, not assumed. + +## Wire protocol + +All integers big endian. See `Protocol.java`. + +Server to Editor: + +``` +Stream header, once, 20 bytes: + u32 magic 'U' 'L' 'S' '1' (0x554C5331) + u32 protocolVersion see serverProtocolVersion in gradle.properties + u32 codec 1 = MJPEG + u32 flags bit 0: the server can inject input + u32 serverPid this process on the device, so the Editor can name it + +Frame, repeated, 28 byte header + payload: + u64 ptsUs microseconds since the first frame + u32 width pixels of the streamed image + u32 height pixels of the streamed image + u32 displayWidth pixels of the display it was captured from + u32 displayHeight pixels of the display it was captured from + u32 payloadSize bytes of encoded frame that follow + u8[] payload JPEG +``` + +The sizes are in every frame because they change - on a rotation, on a foldable +being opened, or on `wm size` being overridden - and the server starts a new +capture session for the new geometry without announcing it on the socket. The +display size rides along so that the Editor can say what the stream is scaling +down from without asking adb, and without the two numbers being able to disagree. + +Editor to server, on the same socket (see `ControlReader.java`): + +``` +Touch, 9 bytes: + u8 type 1 = touch + u8 action 0 down, 1 up, 2 move, 3 cancel + u8 pointerId 0 based, one finger per id + u16 x position across the display, 0..65535 + u16 y position down the display, 0..65535 + u16 pressure 0..65535 + +Key, 10 bytes: + u8 type 2 = key + u8 action 0 down, 1 up + u32 keyCode Android KeyEvent.KEYCODE_* + u32 metaState Android KeyEvent.META_* + +Text, 3 bytes + payload: + u8 type 3 = text + u16 length bytes of UTF-8 that follow, max 4096 + u8[] text + +Scroll, 9 bytes: + u8 type 4 = scroll + u16 x position across the display, 0..65535 + u16 y position down the display, 0..65535 + i16 hScroll notches right, times 256 + i16 vScroll notches away from the user, times 256 +``` + +Keys and text are separate on purpose. A named key - Back, Enter, an arrow - has no +character to type and goes as a keycode. Typed characters go as text and are turned +into key events on the device by `KeyCharacterMap`, which is what makes punctuation, +shifted characters and non-US layouts work: the Editor sends the character the user +actually produced and the device works out which keystrokes would produce it, rather +than the Editor trying to model every layout. + +A scroll carries a position because that is what decides which view receives it, +and its magnitude is fixed point so that a trackpad's fractions survive without +putting a float on the wire. The server turns each one into a hover followed by an +`ACTION_SCROLL`: without the hover in front of it, the scroll is accepted and then +ignored, because Android delivers it to whatever the mouse is over. + +Touch positions are normalized rather than in pixels, so the Editor does not have +to know the device's current resolution - and cannot get it wrong, since its idea +of the screen is always at least a frame and possibly a whole rotation out of +date. The server scales them against the display it is capturing at that moment. + +`flags` exists so the Editor can tell "the user turned control off" from "this +device will not allow injection" and say so, rather than dropping every touch in +silence. + +Message sizes are known per type, so an unknown type means the reader no longer knows +where the next one starts. It stops reading control input at that point and leaves the +video stream running, which is the half worth keeping. A text message whose length +exceeds the cap is skipped by consuming its payload, so that one bad message does not +desynchronize the rest. + +Width and height travel with every frame instead of only in the stream header, +because they change when the device is rotated or the display is resized. The +Editor therefore never has to be told out of band that the geometry moved - it +just reads the next frame. + +`serverProtocolVersion` lives in `gradle.properties` and is baked into the jar as +`BuildConfig.PROTOCOL_VERSION`, so that the Editor and the server cannot silently +drift apart. Bump it whenever the packet layout changes. + +## Why JPEG + +H.264 through `MediaCodec` would cost a fraction of the bandwidth, but the Editor +would then need a video decoder, and there is no H.264 decoder reachable from +Editor C#. A JPEG frame goes straight into `Texture2D.LoadImage`. Measured on a +Pixel 2 at `max_size=512 quality=70`: ~30 KB per frame, ~3.6 Mbps at 15 fps. + +## Layout + +| File | Role | +| --- | --- | +| `Server.java` | entry point, socket setup, client lifetime | +| `ScreenStreamer.java` | display mirroring, JPEG encoding, frame pacing | +| `ControlReader.java` | control messages from the Editor, and EOF detection | +| `TouchInjector.java` | normalized positions to injected MotionEvents | +| `KeyInjector.java` | keycodes and text to injected KeyEvents | +| `Protocol.java` | wire format | +| `Options.java` | `key=value` command line | +| `DisplayInfo.java`, `Size.java` | value types | +| `Logger.java` | logging to logcat and stderr | +| `wrappers/DisplayManagerWrapper.java` | reflection over `DisplayManagerGlobal` | +| `wrappers/SurfaceControlWrapper.java` | reflection over `SurfaceControl` | +| `wrappers/InputManagerWrapper.java` | reflection over the hidden input injection API | + +Two mirroring paths are attempted in order: `DisplayManagerGlobal +.createVirtualDisplay`, then `SurfaceControl.createDisplay`. Neither works +everywhere - `SurfaceControl.createDisplay` was removed in Android 15, and the +`DisplayManagerGlobal` overload is missing on some older versions (including +Android 10, where the `SurfaceControl` path is the one that runs) - so whichever +succeeds first wins. diff --git a/External/UnityLogcatServer/build.gradle b/External/UnityLogcatServer/build.gradle new file mode 100644 index 00000000..65a3a67b --- /dev/null +++ b/External/UnityLogcatServer/build.gradle @@ -0,0 +1,283 @@ +// Build for the Unity Logcat on-device screen streaming server. +// +// The server is not an Android application: it has no manifest, no resources and +// no activity. It is a plain set of classes, dexed and archived into a jar, which +// is pushed to the device and executed by app_process as the `shell` user: +// +// adb push unity-logcat-server.jar /data/local/tmp/ +// adb shell CLASSPATH=/data/local/tmp/unity-logcat-server.jar \ +// app_process / com.unity.android.logcat.server.Server +// +// Because of that, this uses the plain `java-library` plugin plus an explicit d8 +// step instead of the Android Gradle Plugin. AGP would build an APK (which would +// then have to be renamed to a jar), pull in a large dependency tree and require +// network access to the `google()` repository - none of which buys us anything. +// +// ./gradlew dexJar -> build/outputs/unity-logcat-server.jar +// ./gradlew pushJar -> pushes it to the connected device +// ./gradlew runJar -> pushes and runs it (foreground, for manual testing) + +plugins { + id 'java-library' +} + +// --------------------------------------------------------------------------- +// Android SDK discovery +// --------------------------------------------------------------------------- + +def androidPlatform = project.property('androidPlatform') +def androidBuildTools = project.property('androidBuildTools') +def androidMinApi = project.property('androidMinApi') + +def sdkHelp = """Android SDK not found. Point the build at one of: + + * External/UnityLogcatServer/local.properties containing sdk.dir= + * the ANDROID_HOME environment variable + * the ANDROID_SDK_ROOT environment variable + +A Unity installation with Android support ships one, typically at +/Editor/Data/PlaybackEngines/AndroidPlayer/SDK. + +The build needs platforms/android-${androidPlatform}/android.jar +and build-tools/${androidBuildTools}/d8.""" + +def resolveSdkDir = { + def localProperties = file('local.properties') + if (localProperties.exists()) { + def props = new Properties() + localProperties.withInputStream { props.load(it) } + def dir = props.getProperty('sdk.dir') + if (dir) { + return file(dir) + } + } + for (name in ['ANDROID_HOME', 'ANDROID_SDK_ROOT']) { + def dir = System.getenv(name) + if (dir) { + // Gradle resolves a relative path against the project directory, not + // against wherever the build was invoked from, and with a daemon there is + // no useful "invoked from" anyway. A relative value is therefore almost + // certainly not the directory the caller meant, so say that rather than + // reporting a missing SDK at some path they never typed. + def f = new File(dir) + if (!f.isAbsolute()) { + throw new GradleException("$name is set to a relative path, '$dir'.\n" + + "It has to be absolute: Gradle would resolve it against " + + project.projectDir + ", not against the directory you ran from.") + } + return f + } + } + return null +} + +// Everything below resolves the SDK lazily, so that tasks which do not need it +// (`gradlew tasks`, `gradlew clean`) still work on a machine without one. +def sdkFile = { String relativePath -> + def sdkDir = resolveSdkDir() + if (sdkDir == null || !sdkDir.isDirectory()) { + throw new GradleException(sdkHelp) + } + def f = new File(sdkDir, relativePath) + if (!f.exists()) { + throw new GradleException("Not found: " + f + "\n\n" + sdkHelp) + } + return f +} + +def isWindows = System.getProperty('os.name').toLowerCase().contains('windows') +def androidJar = { sdkFile("platforms/android-${androidPlatform}/android.jar") } +// Needed on the compile classpath because we replace the bootclasspath with +// android.jar, which does not carry the JDK lambda metafactory stubs. +def lambdaStubs = { sdkFile("build-tools/${androidBuildTools}/core-lambda-stubs.jar") } +def d8 = { sdkFile("build-tools/${androidBuildTools}/d8" + (isWindows ? '.bat' : '')) } +def adb = { sdkFile('platform-tools/adb' + (isWindows ? '.exe' : '')) } + +// --------------------------------------------------------------------------- +// Compilation +// --------------------------------------------------------------------------- + +def generatedSrcDir = layout.buildDirectory.dir('generated/sources/buildconfig') + +// Values the Editor side also needs to agree on live in gradle.properties and are +// baked into the jar, so a protocol mismatch is detectable at runtime instead of +// being two constants that silently drift apart. +def generateBuildConfig = tasks.register('generateBuildConfig') { + description = 'Generates BuildConfig.java from gradle.properties' + def outDir = generatedSrcDir + def protocolVersion = project.property('serverProtocolVersion') + def socketName = project.property('serverSocketName') + inputs.property('protocolVersion', protocolVersion) + inputs.property('socketName', socketName) + outputs.dir(outDir) + doLast { + def pkgDir = new File(outDir.get().asFile, 'com/unity/android/logcat/server') + pkgDir.mkdirs() + new File(pkgDir, 'BuildConfig.java').text = [ + '// Generated by build.gradle from gradle.properties. Do not edit.', + 'package com.unity.android.logcat.server;', + '', + 'public final class BuildConfig {', + ' public static final int PROTOCOL_VERSION = ' + protocolVersion + ';', + ' public static final String DEFAULT_SOCKET_NAME = "' + socketName + '";', + '', + ' private BuildConfig() {', + ' }', + '}', + '' + ].join('\n') + } +} + +sourceSets { + main { + java { + srcDir generatedSrcDir + } + } +} + +tasks.named('compileJava', JavaCompile) { + dependsOn generateBuildConfig + options.encoding = 'UTF-8' + // Compile against android.jar rather than the JDK class library, so reaching + // for a desktop-only API is a compile error instead of a crash on device. + options.bootstrapClasspath = files(androidJar) + classpath = files(lambdaStubs) + sourceCompatibility = '1.8' + targetCompatibility = '1.8' + options.compilerArgs << '-Xlint:deprecation' << '-Xlint:unchecked' +} + +def classesJar = tasks.named('jar', Jar).flatMap { it.archiveFile } + +jar { + // Intermediate artifact only: plain .class files, not runnable on a device. + archiveFileName = 'unity-logcat-server-classes.jar' +} + +// --------------------------------------------------------------------------- +// Dexing: the actual deliverable +// +// These are typed Exec tasks whose command line is filled in at execution time, +// because the SDK is resolved lazily and because Gradle 9 removed the +// `project.exec {}` method that an ad-hoc task would otherwise have used. +// --------------------------------------------------------------------------- + +def serverJarName = 'unity-logcat-server.jar' +def outputJar = layout.buildDirectory.file("outputs/${serverJarName}") + +def dexJar = tasks.register('dexJar', Exec) { + group = 'build' + description = 'Dexes the compiled classes into build/outputs/unity-logcat-server.jar' + // Declaring the jar as an input is also what makes this run after it. + inputs.file(classesJar) + outputs.file(outputJar) + // d8 is a launcher script, and both of its forms have to be pointed at a JVM: + // the Windows .bat resolves java through JAVA_HOME, while the shell script on + // macOS and Linux simply runs `java`. Neither is a given - a CI agent whose only + // JDK lives inside a Unity installation has no JAVA_HOME set and no java on PATH - + // so both are handed the JVM Gradle is already running on, which is also always a + // path the platform's own shell understands. + def javaHome = System.getProperty('java.home') + // Matched to however the inherited variable is spelled, so Windows does not end up + // carrying both Path and PATH. + def pathName = System.getenv().keySet().find { it.equalsIgnoreCase('PATH') } ?: 'PATH' + environment 'JAVA_HOME', javaHome + environment pathName, new File(javaHome, 'bin').absolutePath + + File.pathSeparator + (System.getenv(pathName) ?: '') + doFirst { + def out = outputJar.get().asFile + out.parentFile.mkdirs() + // d8 writes an archive containing classes.dex when --output ends in .jar. + commandLine d8(), + '--release', + '--min-api', androidMinApi, + '--lib', androidJar(), + '--output', out, + classesJar.get().asFile + } + doLast { + def out = outputJar.get().asFile + logger.lifecycle("Server jar: " + out + " (" + out.length() + " bytes)") + } +} + +// --------------------------------------------------------------------------- +// Post-build: place the jar where the package can ship it +// +// A folder whose name ends in "~" is included in the published package but not +// imported as an asset, so the jar needs no .meta file and never enters the +// AssetDatabase. The Editor reads it straight off disk to push it to a device. +// --------------------------------------------------------------------------- + +def packageExternalDir = file('../../com.unity.mobile.android-logcat/External~') + +// Copies by hand rather than with a Copy task: Gradle creates a Copy task's +// destination directory before any task action runs, so a guard against writing +// to the wrong place can never see it missing. +def copyJarToPackage = tasks.register('copyJarToPackage') { + group = 'build' + description = "Copies the server jar into the package's External~ folder" + dependsOn dexJar + def destination = new File(packageExternalDir, serverJarName) + inputs.file(outputJar) + outputs.file(destination) + doLast { + // package.json is the marker: it says the destination really is the logcat + // package, and unlike a directory it is not something Gradle can create on + // our behalf. Catches this project being moved relative to the package + // instead of scattering an External~ folder somewhere unrelated. + def packageDir = packageExternalDir.parentFile + if (!new File(packageDir, 'package.json').isFile()) { + throw new GradleException("No Unity package found at " + packageDir + + "\nExpected this project to sit at /External/UnityLogcatServer," + + " alongside /com.unity.mobile.android-logcat.") + } + + packageExternalDir.mkdirs() + java.nio.file.Files.copy( + outputJar.get().asFile.toPath(), + destination.toPath(), + java.nio.file.StandardCopyOption.REPLACE_EXISTING) + logger.lifecycle("Copied to: " + destination) + } +} + +// Attached to dexJar rather than to assemble, so that every route which produces +// the jar - dexJar, assemble, pushJar, runJar - also refreshes the copy the +// package ships. Otherwise the two silently drift apart. +dexJar.configure { + finalizedBy copyJarToPackage +} + +tasks.named('assemble') { + dependsOn dexJar +} + +// --------------------------------------------------------------------------- +// Developer conveniences. The Editor does its own push/run at runtime; these +// exist so the server can be exercised without an Editor in the loop. +// --------------------------------------------------------------------------- + +def devicePath = project.property('serverDevicePath') + +def pushJar = tasks.register('pushJar', Exec) { + group = 'verification' + description = "Pushes the server jar to ${devicePath} on the connected device" + dependsOn dexJar + doFirst { + commandLine adb(), 'push', outputJar.get().asFile, devicePath + } +} + +tasks.register('runJar', Exec) { + group = 'verification' + description = 'Runs the server on the connected device in the foreground (Ctrl+C to stop)' + dependsOn pushJar + doFirst { + def serverArgs = project.findProperty('serverArgs') ?: 'log_level=debug' + commandLine adb(), 'shell', + "CLASSPATH=${devicePath} app_process / com.unity.android.logcat.server.Server ${serverArgs}" + } +} diff --git a/External/UnityLogcatServer/gradle.properties b/External/UnityLogcatServer/gradle.properties new file mode 100644 index 00000000..08fbbe23 --- /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=6 + +# 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..e61124da --- /dev/null +++ b/External/UnityLogcatServer/src/main/java/com/unity/android/logcat/server/KeyInjector.java @@ -0,0 +1,95 @@ +package com.unity.android.logcat.server; + +import com.unity.android.logcat.server.wrappers.InputManagerWrapper; + +import android.os.SystemClock; +import android.view.InputDevice; +import android.view.KeyCharacterMap; +import android.view.KeyEvent; + +/** + * Injects key events and typed text into the device. + *

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

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

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

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

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

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

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

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

Why a socket and not stdout

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

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

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

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

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

+ * This is the fallback path for creating a mirrored display. It predates + * {@code DisplayManagerGlobal.createVirtualDisplay} and still works on devices + * where that call is missing or broken, but {@code createDisplay} was removed in + * Android 15, so it cannot be the primary path either. Whichever one works first + * wins - see {@code ScreenStreamer.startSession}. + */ +@SuppressLint("PrivateApi") +public final class SurfaceControlWrapper { + private static final Class CLASS; + + static { + try { + CLASS = Class.forName("android.view.SurfaceControl"); + } catch (ClassNotFoundException e) { + throw new AssertionError(e); + } + } + + private SurfaceControlWrapper() { + } + + public static IBinder createDisplay(String name, boolean secure) throws ReflectiveOperationException { + Method method = CLASS.getMethod("createDisplay", String.class, boolean.class); + return (IBinder) method.invoke(null, name, secure); + } + + public static void destroyDisplay(IBinder displayToken) throws ReflectiveOperationException { + CLASS.getMethod("destroyDisplay", IBinder.class).invoke(null, displayToken); + } + + public static void setDisplaySurface(IBinder displayToken, Surface surface) throws ReflectiveOperationException { + CLASS.getMethod("setDisplaySurface", IBinder.class, Surface.class).invoke(null, displayToken, surface); + } + + public static void setDisplayProjection(IBinder displayToken, int orientation, Rect layerStackRect, Rect displayRect) + throws ReflectiveOperationException { + CLASS.getMethod("setDisplayProjection", IBinder.class, int.class, Rect.class, Rect.class) + .invoke(null, displayToken, orientation, layerStackRect, displayRect); + } + + public static void setDisplayLayerStack(IBinder displayToken, int layerStack) throws ReflectiveOperationException { + CLASS.getMethod("setDisplayLayerStack", IBinder.class, int.class).invoke(null, displayToken, layerStack); + } + + public static void openTransaction() throws ReflectiveOperationException { + CLASS.getMethod("openTransaction").invoke(null); + } + + public static void closeTransaction() throws ReflectiveOperationException { + CLASS.getMethod("closeTransaction").invoke(null); + } +} 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/com.unity.mobile.android-logcat/CHANGELOG.md b/com.unity.mobile.android-logcat/CHANGELOG.md index cfe34db8..89fec163 100644 --- a/com.unity.mobile.android-logcat/CHANGELOG.md +++ b/com.unity.mobile.android-logcat/CHANGELOG.md @@ -8,7 +8,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Changes & Improvements: - Unity 6.0 or later is required. - + - Screenshots are now saved to `Library/AndroidLogcat/Screenshots` as `_.png`. Previously each capture overwrote a single file in the project's `Temp` folder, so earlier screenshots were lost. + - The Screen Capture window can be opened from **Window** > **Analysis** > **Android Screen Capture**, as well as from the Android Logcat window's **Tools** menu. + - The Screen Capture window now lists every saved screenshot. Click a row or use the Up and Down keys to cycle through them, or the cross next to a row to delete that screenshot. + - Ctrl+Shift+S, or Cmd+Shift+S on macOS, captures a screenshot while the Screen Capture window has focus. It appears in Edit > Shortcuts under "Android Logcat" and can be rebound there. + - Capturing a screenshot also writes a `.json` file beside it, recording the device it was captured from and when. + - The details beside a selected screenshot show the device, its Android version and display size, along with the image size, the file size and when it was captured. The device details read `Undefined` for a screenshot saved without them. + - The screenshot and the live view can be zoomed with Ctrl+Wheel (Cmd+Wheel on macOS), and the zoomed image moved with a Ctrl+middle mouse button drag or with the scrollbars that appear. + - The device screen can be viewed live from the Screen Capture window: select the "Live" row at the top of the screenshot list. Clicks, drags, the scroll wheel and typing are sent to the device as touch, scroll and key events, and Ctrl+A, Ctrl+C and Ctrl+V (Cmd on macOS) select all, copy and paste on the device using its own clipboard, and Back / Home / Overview buttons sit beside the image with the stream details. + ## [1.4.7] - 2025-12-12 ### Fixes & Improvements - Periodic device queries no longer occur when no devices are connected and Logcat window is unfocused. diff --git a/com.unity.mobile.android-logcat/Documentation~/TableOfContents.md b/com.unity.mobile.android-logcat/Documentation~/TableOfContents.md index 657a421f..e2bb5701 100644 --- a/com.unity.mobile.android-logcat/Documentation~/TableOfContents.md +++ b/com.unity.mobile.android-logcat/Documentation~/TableOfContents.md @@ -13,6 +13,7 @@ * [Device Screen Capture window reference](screen-capture-window-reference.md) * [Capture a screenshot](screen-capture-screenshot.md) * [Capture a video](screen-capture-video.md) + * [View the device screen live](screen-capture-live-stream.md) * [Stacktace Utility](stacktrace-utility.md) * [Stacktrace Utility window reference](stacktrace-utility-window-reference.md) * [Set up Stacktrace Utility](stacktrace-utility-set-up.md) diff --git a/com.unity.mobile.android-logcat/Documentation~/android-logcat-settings.md b/com.unity.mobile.android-logcat/Documentation~/android-logcat-settings.md index aeb20b2d..afe0bb91 100644 --- a/com.unity.mobile.android-logcat/Documentation~/android-logcat-settings.md +++ b/com.unity.mobile.android-logcat/Documentation~/android-logcat-settings.md @@ -7,6 +7,7 @@ To open the Android Logcat Settings window in the Unity Editor, go to **Edit** > * [Message Colors](#message-colors) * [Memory Window](#memory-window) * [Packages](#packages) +* [Live Stream](#live-stream) * [Stacktrace Regex](#stacktrace-regex) * [Symbol Extensions](#symbol-extensions) @@ -48,6 +49,20 @@ Use the **Request Interval ms** setting to specify a time interval to request me Use the **Max Exited Packages** setting to specify the maximum number for the applications selected in the Package Selector that are now closed. This allows you to restrict the number of entries in the Package Selector dropdown for closed applications to prevent overpopulating the dropdown. +## Live Stream + +Use the **Live Stream** settings to control the [live view of the device's screen](screen-capture-live-stream.md). Streaming a display uses the device's CPU to compress each frame and the connection to your computer to carry it, so these settings trade image quality for both. + +|**Setting**|**Description**| +|---|---| +|**Max Size**|Specifies the longest side of the streamed image in pixels, between 256 and 2048. The device's display is scaled down to fit. The default value is 1024.| +|**JPEG Quality**|Specifies the quality of each streamed frame, between 1 and 100. Lower values produce smaller frames and use less bandwidth. The default value is 70.| +|**Max Frame Rate**|Specifies the highest number of frames per second the device sends, between 1 and 120. The device only sends a frame when its screen changes, so this is a limit rather than a rate. The default value is 30.| + +These settings apply when a stream starts. To apply them to a stream that is already running, right-click the **Live** row in the [Device Screen Capture window](screen-capture-window-reference.md#capture-list) and select **Reconnect**. + +Use the **Reset** button in this section to restore the three Live Stream settings without changing any other setting. + ## Stacktrace Regex ![Stacktrace Regex](images/logcat-settings-stacktrace-regex.png) @@ -66,4 +81,4 @@ For more information, refer to [configure stacktrace regular expressions](stackt Use the **Symbol Extensions** setting to specify file extensions which are appended to symbol file names during stacktrace resolving. -For example, if the symbol file name is `libunity` and the specified symbol extensions are `.so.sym`, `.so.dbg`, the Stacktrace Utility tool looks for `libunity.so.sym` and `libunity.so.dbg` symbols. \ No newline at end of file +For example, if the symbol file name is `libunity` and the specified symbol extensions are `.so.sym`, `.so.dbg`, the Stacktrace Utility tool looks for `libunity.so.sym` and `libunity.so.dbg` symbols. diff --git a/com.unity.mobile.android-logcat/Documentation~/images/capture_screenshot.png b/com.unity.mobile.android-logcat/Documentation~/images/capture_screenshot.png index ced35dd7..ba9a2fc4 100644 Binary files a/com.unity.mobile.android-logcat/Documentation~/images/capture_screenshot.png and b/com.unity.mobile.android-logcat/Documentation~/images/capture_screenshot.png differ diff --git a/com.unity.mobile.android-logcat/Documentation~/screen-capture-live-stream.md b/com.unity.mobile.android-logcat/Documentation~/screen-capture-live-stream.md new file mode 100644 index 00000000..58957e21 --- /dev/null +++ b/com.unity.mobile.android-logcat/Documentation~/screen-capture-live-stream.md @@ -0,0 +1,52 @@ +# View the device screen live + +This page explains how to use the [Screen Capture tool](screen-capture.md) to view the screen of the connected device as it changes, and how to control the device from the Unity Editor. + +The live view mirrors the device's display into the Unity Editor. Unlike a screenshot or a video, there is nothing to save: the view is live and stops when you select something else. + +## View the screen + +1. Open the [Device Screen Capture window](screen-capture-window-reference.md). +2. In the [Toolbar](screen-capture-window-reference.md#toolbar), use **Device Selector** to specify the device to view. +3. In the [Capture list](screen-capture-window-reference.md#capture-list), select the **Live** row. The Screen Capture tool starts streaming and displays the device's screen in the [Capture preview](screen-capture-window-reference.md#capture-preview). + +The stream stops when you select another row in the list, close the window, or disconnect the device. Selecting a different device restarts the stream against the newly selected one. + +The device only sends a frame when its screen changes, so **Frame Rate** in the [Live view details](screen-capture-window-reference.md#live-view-details) drops to almost nothing while the device shows a still screen. This is expected: the last frame stays on display. + +If the stream stops on its own, for example because the device restarted, right-click the **Live** row and select **Reconnect**. + +## Control the device + +While the live view is streaming, the Screen Capture tool sends your input to the device. **Input** in the [Live view details](screen-capture-window-reference.md#live-view-details) shows whether the device accepts it. + +| **Input** | **Result on the device** | +| ------------------------------------------ | ------------------------------------------------------------ | +| Click or drag the image | A tap or a swipe at the same place on the device's screen. | +| Scroll the wheel over the image | Scrolls whatever is under the pointer. | +| Click the image, then type | Sends the keys you type, including Backspace, Enter, Tab and the arrow keys. | +| Shift and an arrow key | Extends the selection in a text field on the device, as it would on the device's own keyboard. | +| Escape | Sends the Back key. | +| Ctrl+A, Ctrl+C, Ctrl+V (Cmd on macOS) | Select all, copy and paste on the device, using the device's own clipboard. | +| The **◄**, **●** and **■** buttons | Sends the Back, Home and Overview keys. Useful on a device that uses gesture navigation, where the mirrored image has no navigation bar to tap. | + +Other Ctrl and Cmd combinations are left to the Unity Editor, so its own shortcuts keep working while the image has focus. Ctrl and the wheel zoom the view instead of scrolling the device: refer to [Zoom into the image](screen-capture-window-reference.md#zoom-into-the-image). + +> [!NOTE] +> Nothing is exchanged between the device's clipboard and your computer's. Ctrl+C copies on the device, and Ctrl+V pastes what was copied there. + +## Change the size, quality and frame rate + +Streaming a display uses both the device's CPU, to compress each frame, and the connection to your computer, to carry it. To trade quality for either, go to **Edit** > **Preferences** > **Analysis** > **Android Logcat Settings** (Windows) or **Unity** > **Settings** > **Analysis** > **Android Logcat Settings** (macOS) and use the [Live Stream](android-logcat-settings.md#live-stream) settings. + +The settings apply when a stream starts. To apply them to a stream that is already running, right-click the **Live** row and select **Reconnect**. + +> [!NOTE] +> If you connected the device with `adb connect` rather than by USB, the stream shares the device's Wi-Fi connection with everything else adb does, including the message log. Lower **Max Size** and **Max Frame Rate**, or connect the device by USB, if the connection struggles. + +## Additional resources + +* [Device Screen Capture window reference](screen-capture-window-reference.md) +* [Capture a screenshot](screen-capture-screenshot.md) +* [Capture a video](screen-capture-video.md) +* [Connect to a device](connect-to-a-device.md) diff --git a/com.unity.mobile.android-logcat/Documentation~/screen-capture-screenshot.md b/com.unity.mobile.android-logcat/Documentation~/screen-capture-screenshot.md index 79ef3de8..09a0cc53 100644 --- a/com.unity.mobile.android-logcat/Documentation~/screen-capture-screenshot.md +++ b/com.unity.mobile.android-logcat/Documentation~/screen-capture-screenshot.md @@ -5,10 +5,17 @@ This page explains how to use the [Screen Capture tool](screen-capture.md) to ca 1. Open the [Device Screen Capture window](screen-capture-window-reference.md). 2. In the [Toolbar](screen-capture-window-reference.md#toolbar), use **Device Selector** to specify to device to take a screenshot of. 3. Set **Screen Capture Mode** to **Screenshot**. -4. Select **Capture**. The Screen Capture tool takes a screenshot of the connected device and displays it in the [Capture preview](screen-capture-window-reference.md#capture-preview). -5. Select **Save As** and use the file explorer to save the image file to your computer. +4. Select **Capture**, or press Ctrl+Shift+S (Cmd+Shift+S on macOS). The Screen Capture tool takes a screenshot of the connected device, displays it in the [Capture preview](screen-capture-window-reference.md#capture-preview), and adds it to the [Capture list](screen-capture-window-reference.md#capture-list). +5. Select **Save As** and use the file explorer to save a copy of the image file elsewhere on your computer. + +Every screenshot you capture is kept, so you do not have to save one before taking the next. Screenshots are stored in your project, in `Library/AndroidLogcat/Screenshots`, and named `_.png`. That folder is local to your machine and is not part of your build, so use **Save As** to keep a screenshot somewhere permanent. + +Each screenshot is saved with a `.json` file of the same name beside it, recording the device it came from - its name, id, Android version, API level, ABI and display size - and when it was captured. The [Screenshot details](screen-capture-window-reference.md#screenshot-details) beside the image read it, and it is renamed, deleted and saved along with the image, so a copy you keep elsewhere still knows where it came from. A screenshot without one still opens; its device details read `Undefined`. + +To rename or delete a screenshot, or to show it in Explorer or Finder, use the [Capture list](screen-capture-window-reference.md#capture-list). To look at part of a screenshot more closely, [zoom into it](screen-capture-window-reference.md#zoom-into-the-image). ## Additional resources * [Device Screen Capture window reference](screen-capture-window-reference.md) -* [Capture a video](screen-capture-video.md) \ No newline at end of file +* [Capture a video](screen-capture-video.md) +* [View the device screen live](screen-capture-live-stream.md) \ No newline at end of file diff --git a/com.unity.mobile.android-logcat/Documentation~/screen-capture-window-reference.md b/com.unity.mobile.android-logcat/Documentation~/screen-capture-window-reference.md index a5bcd513..8f3f5fb4 100644 --- a/com.unity.mobile.android-logcat/Documentation~/screen-capture-window-reference.md +++ b/com.unity.mobile.android-logcat/Documentation~/screen-capture-window-reference.md @@ -2,19 +2,23 @@ This page introduces the Device Screen Capture window's interface. -To open the Device Screen Capture window: +To open the Device Screen Capture window, from the main menu in Unity select **Window** > **Analysis** > **Android Screen Capture**. + +You can also open it from the Android Logcat window: 1. Open the [Android Logcat window](android-logcat-window.md). 2. From the [toolbar](android-logcat-window-reference.md#toolbar), select **Tools** > **Screen Capture**. -![](images/capture_video.png) -> The Device Screen Capture window. +The window is split into two: a list of captures on the left, and whatever the list has selected on the right. -| **Label** | **Description** | -| ----------------------- | ------------------------------------------------------------ | -| ![Label A](images/label-a.png) | [Toolbar](#toolbar): Contains options for the Device Screen Capture window. | -| ![Label B](images/label-b.png) | [Recorder settings](#recorder-settings): Contains settings for video recording. | -| ![Label C](images/label-c.png) | [Capture preview](#capture-preview): A preview of the screenshot or video captured from the device. | +| **Area** | **Description** | +| --------------------------------------- | ------------------------------------------------------------ | +| [Toolbar](#toolbar) | Contains options for the Device Screen Capture window. | +| [Capture list](#capture-list) | The live view and every screenshot you have taken. Drag the divider to resize it. | +| [Recorder settings](#recorder-settings) | Contains settings for video recording. | +| [Capture preview](#capture-preview) | The screenshot, video or live view that the list has selected. | +| [Screenshot details](#screenshot-details) | Information about the selected screenshot. | +| [Live view details](#live-view-details) | Information about the live stream, and the device navigation buttons. | ## Toolbar @@ -27,10 +31,33 @@ The toolbar contains options to control the Screen Capture tool. | ----------------------- | ------------------------------------------------------------ | | **Device Selector** | Specifies the Android device to capture the screen of. | | **Screen Capture Mode** | Specifies the screen capture mode to use. The options are:
• **Screenshot**: Switches the Screen Capture tool to screenshot mode. When you click **Capture**, the Screen Capture tool takes a screenshot and displays it in the [Capture preview](#capture-preview).
• **Video**: Switches the Screen Capture tool to video mode. When you click **Capture**, the Screen Capture tool begins capturing a video of the selected device. When you click **Stop**, the Screen capture tool finishes capturing the video and displays it in the [Capture preview](#capture-preview). | -| **Capture** | If **Screen Capture Mode** is **Screenshot**, this captures a screenshot from the Android device. If **Screen Capture Mode** is **Video**, this begins video recording. | +| **Capture** | If **Screen Capture Mode** is **Screenshot**, this captures a screenshot from the Android device. If **Screen Capture Mode** is **Video**, this begins video recording.
Ctrl+Shift+S (Cmd+Shift+S on macOS) captures a screenshot while this window has focus. | | **Stop** | Stops video recording.
This option only appears while the Screen Capture tool is recording a video. | | **Open** | Opens the screen capture using the application associate with the file extension. The file extension is `.png` for screenshots and `.mp4` for videos. | -| **Save As** | Saves the screen capture as a file on your computer. | +| **Save As** | Saves the screen capture as a file on your computer. A screenshot's details file is saved next to the copy. | + +## Capture list + +The list on the left of the window holds the live view and every screenshot you have taken, from every device. Select a row to show it in the [Capture preview](#capture-preview), or use the Up and Down arrow keys to move through the list. Drag the divider between the list and the preview to resize the list. + +| **Row** | **Description** | +| ------------------ | ------------------------------------------------------------ | +| **Live** | The first row. Select it to view the selected device's screen live. Refer to [View the device screen live](screen-capture-live-stream.md). | +| A screenshot | Named after its file, without the `.png` extension. Screenshots are saved automatically when you capture them, so every capture stays until you delete it. | + +Screenshots are stored in your project, in `Library/AndroidLogcat/Screenshots`, and are named `_.png`. They are not part of your build, and deleting the `Library` folder deletes them with it. + +To work with a screenshot in the list: + +| **Action** | **Result** | +| ------------------------------------ | ------------------------------------------------------- | +| Double-click a row | Opens the image in the application associated with `.png`. | +| Click the **×** at the end of a row | Deletes the screenshot from disk, after asking you to confirm. The Delete key (Cmd+Backspace on macOS) does the same to the selected row. | +| Right-click a row | Opens a menu with **Show In Explorer** (**Show In Finder** on macOS), **Open**, **Save As** and **Rename**. | +| Press F2 (Enter on macOS) | Renames the selected screenshot. Enter confirms the new name and Escape cancels. | + +> [!NOTE] +> Renaming a screenshot to something other than `_` keeps it in the list, but it no longer counts towards that device's numbering. ## Recorder settings @@ -48,9 +75,62 @@ Contains settings for video recording. The Screen Capture tool contains default ## Capture preview -After you capture a screenshot or video, this section of the window displays the screenshot or video captured from the device. You can use this to check the quality of the screen capture before you save it as a file on your computer. +This section of the window displays whatever the [Capture list](#capture-list) has selected: a screenshot, a recorded video, or the live view of the device's screen. You can use this to check the quality of the screen capture before you save it as a file on your computer. + +### Zoom into the image + +A screenshot and the live view are both fitted to the window, which can be too small to read a log line or see a single pixel. To look closer: + +| **Action** | **Result** | +| ------------------------------------------------------ | ------------------------------------------------------------ | +| Ctrl+Wheel (Cmd+Wheel on macOS) over the image | Zooms between 100% and 4000%, around the pointer, so whatever you point at stays where it is. The current zoom appears in the corner of the image while it is above 100%. | +| Ctrl+Left or middle mouse button drag (Cmd on macOS) | Moves the zoomed image, to bring another part of it into view. | +| The scrollbars | The same, and they appear as soon as the image is larger than the space for it. | + +Zooming and moving the image only change how you see it. In the live view, the device still receives your clicks, drags and keys at the place on its screen you are pointing at, and the wheel on its own still scrolls the device rather than the view. + +The zoom of the live view and the zoom of the screenshots are separate, and both go back to 100% when scripts recompile. + +## Screenshot details + +This section appears to the right of the image while a screenshot is selected. + +| **Property** | **Description** | +| ------------------ | ------------------------------------------------------------ | +| **Device** | The device the screenshot was captured from. Hover over it for the device id. | +| **OS** | The Android version and API level the device was running. | +| **Display Size** | The device's display resolution when the screenshot was taken. This differs from **Image Size** if the display was rotated or its size overridden. | +| **Image Size** | The size of the image in pixels. | +| **File Size** | The size of the `.png` file on disk. | +| **Captured** | When the file was last written. Hover over it for the full date and time. | + +**Device**, **OS** and **Display Size** come from the details file saved next to the screenshot, so they read `Undefined` for a screenshot captured before this package wrote one, or for an image added to the folder by hand. + +## Live view details + +This section appears to the right of the image while the **Live** row is selected. + +| **Property** | **Description** | +| --------------- | ------------------------------------------------------------ | +| **Display Size** | The resolution of the display being mirrored. Compare it with **Stream Size** to see how much the stream is scaling down. It follows the device, so it changes when the device is rotated or a foldable is opened. | +| **Stream Size** | The size of the streamed image. This is the device display scaled down to fit the **Max Size** setting, not the device's own resolution. | +| **Frame Rate** | How many frames per second are arriving. The device only sends a frame when its screen changes, so a device showing a still screen sends almost none. | +| **Bandwidth** | How much data per second is arriving from the device. | +| **Input** | Whether the device accepts the touch, scroll and key events this window sends it. Devices that refuse input injection still stream. | + +Below the properties are the device navigation buttons, which work while the live view is streaming and the device accepts input: + +| **Button** | **Description** | +| ---------- | ------------------------------------------------------------ | +| **◄** | Sends the Back key. The Escape key does the same once you click the image. | +| **●** | Sends the Home key. | +| **■** | Sends the Overview (recent apps) key. | + +For how to interact with the device and how to change the size, quality and frame rate of the stream, refer to [View the device screen live](screen-capture-live-stream.md). ## Additional resources * [Capture a screenshot](screen-capture-screenshot.md) -* [Capture a video](screen-capture-video.md) \ No newline at end of file +* [Capture a video](screen-capture-video.md) +* [View the device screen live](screen-capture-live-stream.md) +* [Android Logcat Settings](android-logcat-settings.md#live-stream) diff --git a/com.unity.mobile.android-logcat/Documentation~/screen-capture.md b/com.unity.mobile.android-logcat/Documentation~/screen-capture.md index e97ca8a4..029d15df 100644 --- a/com.unity.mobile.android-logcat/Documentation~/screen-capture.md +++ b/com.unity.mobile.android-logcat/Documentation~/screen-capture.md @@ -1,9 +1,10 @@ # Screen Capture tool -The Screen Capture tool can capture a screenshot of the [selected device](connect-to-a-device.md) and save the screenshot as a file. +The Screen Capture tool can capture a screenshot or a video of the [selected device](connect-to-a-device.md) and save it as a file. It can also view the device's screen live and send touch, scroll and key input back to the device. | **Topic** | **Description** | | ------------------------------------------------------------ | ------------------------------------------------------------ | | [Device Screen Capture window reference](screen-capture-window-reference.md) | Understand the Screen Capture window interface. | | [Capture a screenshot](screen-capture-screenshot.md) | Capture a screenshot of the connected Android device and save it as a file on your computer. | -| [Capture a video](screen-capture-video.md) | Capture a video of the connected Android device's screen and save it as a file on your computer. | \ No newline at end of file +| [Capture a video](screen-capture-video.md) | Capture a video of the connected Android device's screen and save it as a file on your computer. | +| [View the device screen live](screen-capture-live-stream.md) | View the connected Android device's screen as it changes, and control the device from the Unity Editor. | \ No newline at end of file diff --git a/com.unity.mobile.android-logcat/Documentation~/tools.md b/com.unity.mobile.android-logcat/Documentation~/tools.md index ada6cfda..95f476a5 100644 --- a/com.unity.mobile.android-logcat/Documentation~/tools.md +++ b/com.unity.mobile.android-logcat/Documentation~/tools.md @@ -4,7 +4,7 @@ The Android Logcat Package contains additional tools to help you debug your appl | **Topic** | **Description** | | ------------------------------------------- | ------------------------------------------------------------ | -| [Screen capture tool](screen-capture.md) | Capture screenshots and videos of applications running on a connected Android device. | +| [Screen capture tool](screen-capture.md) | Capture screenshots and videos of applications running on a connected Android device, or view the device's screen live. | | [Stacktrace utility](stacktrace-utility.md) | Resolves stacktraces and displays custom logs. | | [Memory window](memory-window.md) | Displays the memory allocated for your application. | | [Inputs window](inputs-window.md) | Displays input injection window for your application. | \ No newline at end of file diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatActivityManager.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatActivityManager.cs index 8e3f53cf..97010403 100644 --- a/com.unity.mobile.android-logcat/Editor/AndroidLogcatActivityManager.cs +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatActivityManager.cs @@ -5,6 +5,7 @@ namespace Unity.Android.Logcat internal abstract class IAndroidLogcatActivityManager { internal virtual void StartOrResumePackage(string packageName, string activityName = null) { } + internal virtual void StartAction(string action) { } internal virtual void StopPackage(string packageName) { } internal virtual void StopProcess(int processId) { } internal virtual void CrashPackage(string packageName) { } @@ -60,6 +61,28 @@ internal override void StartOrResumePackage(string packageName, string activityN m_ADB.Run(args.ToArray(), $"Failed to start package '{packageName}'"); } + ///

+ /// Starts whatever handles an intent action, for screens that are reached by + /// action rather than by naming a package - which activity serves one differs + /// between devices, the action does not. + /// + internal override void StartAction(string action) + { + var args = new[] + { + "-s", + m_DeviceId, + "shell", + "am", + "start", + "-a", + action + }; + AndroidLogcatInternalLog.Log($"adb {string.Join(" ", args)}"); + + m_ADB.Run(args, $"Failed to start '{action}'"); + } + internal override void StopPackage(string packageName) { var args = new[] diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatCaptureScreenshot.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatCaptureScreenshot.cs index 18487fd0..60ce1f89 100644 --- a/com.unity.mobile.android-logcat/Editor/AndroidLogcatCaptureScreenshot.cs +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatCaptureScreenshot.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using UnityEngine; using UnityEditor; using System.IO; @@ -12,31 +13,339 @@ internal class AndroidLogcatCaptureScreenCaptureInput : IAndroidLogcatTaskInput internal AndroidBridge.ADB adb; internal string imagePath; internal string deviceId; + internal IAndroidLogcatDevice device; internal Action onCompleted; } internal class AndroidLogcatCaptureScreenCaptureResult : IAndroidLogcatTaskResult { internal string imagePath; + // The path AllocateImagePath handed out, which is imagePath on success and + // still needed on failure - that is the reservation to release. + internal string reservedPath; + internal string deviceId; internal string error; + internal AndroidLogcatScreenshotInfo info; internal Action onCompleted; } private AndroidLogcatRuntimeBase m_Runtime; + // Where captures are kept, and whether previous ones are kept with them. The + // Screen Capture window numbers its screenshots and keeps them all; the Layout + // Viewer has its own directory holding one. + private readonly string m_Directory; + private readonly bool m_KeepHistory; private Texture2D m_ImageTexture = null; private int m_CaptureCount; private string m_Error; private Rect m_ScreenshotDrawingRect; + /// One saved screenshot on disk. + internal readonly struct Screenshot + { + internal string Path { get; } + /// File name without extension, which is what the list view shows. + internal string Name { get; } + /// + /// The device part of the file name. This is the sanitized device id, so + /// comparing it to a device means sanitizing that id too. + /// + internal string DevicePrefix { get; } + internal int Number { get; } + + internal Screenshot(string path, string devicePrefix, int number) + { + Path = path; + Name = System.IO.Path.GetFileNameWithoutExtension(path); + DevicePrefix = devicePrefix; + Number = number; + } + } + + // Every saved screenshot, of every device. Cached because the window asks for + // this from OnGUI, and scanning the directory every repaint would be disk I/O + // per frame. Rescanned when a capture lands. + private List m_Screenshots; + + // Paths handed out for captures that have not produced a file yet. Held apart + // from the cache above, because dropping that cache must not lose them: a + // capture still in flight has to keep its number reserved or the next capture + // takes the same one and overwrites it. A rescan puts them back. + private readonly HashSet m_ReservedPaths = new HashSet(); + + // What LoadImage last put on screen, which is what Open and Save As act on. + private string m_SelectedImagePath; + public bool IsCapturing => m_CaptureCount > 0; public Texture2D ImageTexture => m_ImageTexture; public string Error => m_Error; public Rect ScreenshotDrawingRect => m_ScreenshotDrawingRect; - public string GetImagePath(IAndroidLogcatDevice device) + + /// The screenshot currently displayed, or empty if there is none. + public string SelectedImagePath => m_SelectedImagePath; + + /// + /// Drops the cached listing so the next reads the + /// directory again. For changes this class did not make - a file added, removed + /// or replaced from outside the Editor - which nothing else can notice. + /// + public void InvalidateScreenshots() + { + m_Screenshots = null; + } + + /// + /// Every saved screenshot, of every device, grouped by device and numbered + /// ascending within each. + /// + public IReadOnlyList GetScreenshots() + { + if (m_Screenshots == null) + m_Screenshots = ScanScreenshots(); + return m_Screenshots; + } + + /// + /// The most recent screenshot captured for this device, or empty when there is + /// none. Screenshots are numbered rather than overwritten, so "the" path is + /// whichever one was taken last. + /// + public string GetLatestImagePath(IAndroidLogcatDevice device) { if (device == null) return string.Empty; - return AndroidLogcatUtilities.GetTemporaryPath(device, "screenshot", GetImageExtension()); + + var prefix = AndroidLogcatUtilities.SanitizeFileName(device.Id); + var screenshots = GetScreenshots(); + // Ordered by number within a device, so the last match is the newest. + for (var i = screenshots.Count - 1; i >= 0; i--) + { + if (screenshots[i].DevicePrefix == prefix) + return screenshots[i].Path; + } + return string.Empty; + } + + /// + /// Reserves the next free path, <device_id>_<number>.png under + /// the capture directory, and makes sure it exists - adb pull will not create it. + /// + private string AllocateImagePath(IAndroidLogcatDevice device) + { + var directory = m_Directory; + Directory.CreateDirectory(directory); + + var prefix = AndroidLogcatUtilities.SanitizeFileName(device.Id); + var screenshots = GetScreenshots(); + + if (!m_KeepHistory) + return AllocateSingleImagePath(directory, prefix); + + // Numbering is per device, so only this device's entries count. + var number = 1; + foreach (var screenshot in screenshots) + { + if (screenshot.DevicePrefix == prefix && screenshot.Number >= number) + number = screenshot.Number + 1; + } + + var path = Path.Combine(directory, $"{prefix}_{number}{GetImageExtension()}").Replace("\\", "/"); + + // The reservation is what stops a second capture queued before this file + // exists from picking the same number - the list is counted from, not the + // directory. It is both recorded and added to the live list, so it survives + // a rescan and is visible to the next allocation either way. The completion + // handler releases it. + m_ReservedPaths.Add(path); + m_Screenshots.Add(new Screenshot(path, prefix, number)); + m_Screenshots.Sort(CompareScreenshots); + return path; + } + + /// + /// The single slot of a capture that keeps no history: always the same file, + /// with everything captured before it - including a capture of another device - + /// deleted, so the directory holds one screenshot and no more. + /// + private string AllocateSingleImagePath(string directory, string prefix) + { + var path = Path.Combine(directory, $"{prefix}_1{GetImageExtension()}").Replace("\\", "/"); + + foreach (var screenshot in GetScreenshots()) + { + // Not a capture in flight: that file is about to be written. + if (screenshot.Path == path || m_ReservedPaths.Contains(screenshot.Path)) + continue; + DeleteQuietly(screenshot.Path); + AndroidLogcatScreenshotInfo.Delete(screenshot.Path); + } + + m_ReservedPaths.Add(path); + // Rather than editing the cached list: the deletions above have already + // made it wrong, and the rescan puts the reservation back. + InvalidateScreenshots(); + return path; + } + + private List ScanScreenshots() + { + var screenshots = new List(); + var directory = m_Directory; + if (!Directory.Exists(directory)) + return screenshots; + + foreach (var file in Directory.GetFiles(directory, $"*{GetImageExtension()}")) + { + var name = Path.GetFileNameWithoutExtension(file); + + // Split at the last underscore: a device id can contain one itself once + // sanitized, e.g. an ip:port becomes 192.168.1.5_5555, so only the part + // after the final underscore is the number. + // + // A name that does not match is still listed, with no device and no + // number. Renaming is allowed, and a file dropped in here by hand should + // show up too - being unable to see a file that is plainly in the folder + // would be worse than not knowing which device it came from. + var devicePrefix = string.Empty; + var number = 0; + var separator = name.LastIndexOf('_'); + if (separator > 0 && int.TryParse(name.Substring(separator + 1), out number)) + devicePrefix = name.Substring(0, separator); + else + number = 0; + + screenshots.Add(new Screenshot(file.Replace("\\", "/"), devicePrefix, number)); + } + + // Captures that are still in flight have no file yet, so a scan would not + // see them - and the number they reserved would be handed out twice. + foreach (var reserved in m_ReservedPaths) + { + if (File.Exists(reserved)) + continue; + + var name = Path.GetFileNameWithoutExtension(reserved); + var separator = name.LastIndexOf('_'); + if (separator > 0 && int.TryParse(name.Substring(separator + 1), out var reservedNumber)) + screenshots.Add(new Screenshot(reserved, name.Substring(0, separator), reservedNumber)); + } + + screenshots.Sort(CompareScreenshots); + return screenshots; + } + + /// + /// Groups by device, then orders by number. GetFiles order is filesystem + /// dependent, and sorting the names as strings would put #10 before #2. Renamed + /// files have no device or number, so they sort last, by name. + /// + private static int CompareScreenshots(Screenshot a, Screenshot b) + { + var aNamed = string.IsNullOrEmpty(a.DevicePrefix); + var bNamed = string.IsNullOrEmpty(b.DevicePrefix); + if (aNamed != bNamed) + return aNamed ? 1 : -1; + if (aNamed) + return string.Compare(a.Name, b.Name, StringComparison.Ordinal); + + var byDevice = string.Compare(a.DevicePrefix, b.DevicePrefix, StringComparison.Ordinal); + return byDevice != 0 ? byDevice : a.Number.CompareTo(b.Number); + } + + /// + /// Renames a saved screenshot, keeping it in the same directory and keeping its + /// extension. A name that no longer matches + /// <device_id>_<number> is fine: it stays in the list, just + /// without a device or a number, and is never picked as "the latest" for a device. + /// + /// False if the name is unusable or the move failed, which is logged. + public bool RenameScreenshot(string path, string newName) + { + if (string.IsNullOrEmpty(path) || !File.Exists(path)) + return false; + + newName = newName == null ? string.Empty : newName.Trim(); + if (newName.Length == 0) + return false; + + if (newName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + { + UnityEngine.Debug.LogError($"'{newName}' is not a usable file name. " + + "File names cannot contain \\ / : * ? \" < > | or control characters. " + + "Choose a different name."); + return false; + } + + var directory = Path.GetDirectoryName(path); + var target = Path.Combine(directory, newName + GetImageExtension()).Replace("\\", "/"); + if (target == path) + return true; + + if (File.Exists(target)) + { + UnityEngine.Debug.LogError( + $"'{newName}{GetImageExtension()}' already exists. Choose a different name."); + return false; + } + + // Checked before the image moves, so the two cannot end up apart. + if (!AndroidLogcatScreenshotInfo.CanWriteBeside(target)) + { + UnityEngine.Debug.LogError( + $"'{Path.GetFileName(AndroidLogcatScreenshotInfo.PathFor(target))}' already exists " + + "and was not written by Android Logcat. Choose a different name."); + return false; + } + + try + { + File.Move(path, target); + } + catch (Exception ex) + { + UnityEngine.Debug.LogError($"Failed to rename '{path}' to '{target}'.\n{ex.Message}"); + return false; + } + + AndroidLogcatScreenshotInfo.Move(path, target); + + // Rescan, so the list picks up the new name and reorders. + InvalidateScreenshots(); + + // Keep showing the same image, now under its new path. + if (m_SelectedImagePath == path) + m_SelectedImagePath = target; + + return true; + } + + /// + /// Removes a saved screenshot from disk. If it was the one on screen, the image + /// is cleared too - the caller decides what to show instead. + /// + /// False if the file could not be removed, which is already logged. + public bool DeleteScreenshot(string path) + { + try + { + if (File.Exists(path)) + File.Delete(path); + } + catch (Exception ex) + { + UnityEngine.Debug.LogError($"Failed to delete '{path}'.\n{ex.Message}"); + return false; + } + + AndroidLogcatScreenshotInfo.Delete(path); + + // Rescan, so the list loses the row. + InvalidateScreenshots(); + + if (m_SelectedImagePath == path) + LoadImage(string.Empty); + + return true; } public string GetImageExtension() @@ -44,9 +353,11 @@ public string GetImageExtension() return ".png"; } - internal AndroidLogcatCaptureScreenshot(AndroidLogcatRuntimeBase runtime) + internal AndroidLogcatCaptureScreenshot(AndroidLogcatRuntimeBase runtime, string directory, bool keepHistory) { m_Runtime = runtime; + m_Directory = directory; + m_KeepHistory = keepHistory; } public void QueueScreenCapture(IAndroidLogcatDevice device, Action onCompleted) @@ -58,8 +369,10 @@ public void QueueScreenCapture(IAndroidLogcatDevice device, Action onCompleted) new AndroidLogcatCaptureScreenCaptureInput() { adb = m_Runtime.Tools.ADB, - imagePath = GetImagePath(device), + // Allocated here on the main thread, before the task is scheduled. + imagePath = AllocateImagePath(device), deviceId = device.Id, + device = device, onCompleted = onCompleted }, ExecuteScreenCapture, @@ -76,31 +389,101 @@ private static IAndroidLogcatTaskResult ExecuteScreenCapture(IAndroidLogcatTaskI return new AndroidLogcatCaptureScreenCaptureResult() { imagePath = result ? i.imagePath : null, + reservedPath = i.imagePath, + deviceId = i.deviceId, error = error, + // Read here because it asks the device; written on the main thread, + // where JsonUtility is safe to call. + info = result ? AndroidLogcatScreenshotInfo.Create(i.device) : null, onCompleted = i.onCompleted }; } + static void DeleteQuietly(string path) + { + if (string.IsNullOrEmpty(path) || !File.Exists(path)) + return; + + try + { + File.Delete(path); + } + catch (Exception ex) + { + AndroidLogcatInternalLog.Log($"Failed to delete '{path}': {ex.Message}"); + } + } + private void IntegrateCaptureScreenShot(IAndroidLogcatTaskResult result) { if (m_CaptureCount > 0) m_CaptureCount--; var captureResult = (AndroidLogcatCaptureScreenCaptureResult)result; m_Error = captureResult.error; + + // This capture's reservation is done with: the file either exists now or + // never will. Only this one is released - reservations for captures still + // in flight have to stand, which is why they do not live in the cache. + m_ReservedPaths.Remove(captureResult.reservedPath); + + captureResult.info?.Save(captureResult.imagePath); + + // A pull that failed part way still leaves what it had written, and the + // rescan below would list that as a screenshot. + if (string.IsNullOrEmpty(captureResult.imagePath)) + DeleteQuietly(captureResult.reservedPath); + + // Drop the cache so the new file appears in the list, and so a failed + // capture's entry disappears again. One rescan per capture, rather than per + // repaint, which is what the cache is there for. + InvalidateScreenshots(); + LoadImage(captureResult.imagePath); captureResult.onCompleted(); } + /// + /// Records which screenshot is selected without touching + /// , for a window that shows the image itself. + /// + /// The texture here is the last capture, which is what a window drawing an + /// overlay over it has queried against. Loading a historical screenshot into it + /// from the capture list would put that overlay on an unrelated image, so the + /// list keeps its own texture and only the path is shared. + /// + /// + public void SelectImage(string imagePath) + { + m_SelectedImagePath = string.IsNullOrEmpty(imagePath) + ? string.Empty + : imagePath.Replace("\\", "/"); + + // As in LoadImage: an image to show supersedes the last capture's error. + if (!string.IsNullOrEmpty(m_SelectedImagePath)) + m_Error = string.Empty; + } + public void LoadImage(string imagePath) { m_ImageTexture = null; + m_SelectedImagePath = string.Empty; if (string.IsNullOrEmpty(imagePath)) return; if (!File.Exists(imagePath)) return; + // An image to show supersedes the last capture's error, which DoGUI draws + // in preference to the texture and nothing else would ever clear - leaving + // every saved screenshot hidden behind it until a capture succeeded. Done + // after the returns above, so a failed capture keeps the error it just set. + m_Error = string.Empty; + + // Normalized so it compares equal to the paths in the screenshot list, + // which the list view uses to mark the selected row. + m_SelectedImagePath = imagePath.Replace("\\", "/"); + var imageData = File.ReadAllBytes(imagePath); m_ImageTexture = new Texture2D(2, 2); diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatConsoleWindow.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatConsoleWindow.cs index 5ce27cda..73efafb3 100644 --- a/com.unity.mobile.android-logcat/Editor/AndroidLogcatConsoleWindow.cs +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatConsoleWindow.cs @@ -154,7 +154,7 @@ private void TagSelectionChanged() RestartLogCat(); } - private void FilterByProcessId(int processId) + internal void FilterByProcessId(int processId) { var selectedDevice = m_Runtime.DeviceQuery.SelectedDevice; var processes = m_Runtime.UserSettings.GetKnownProcesses(selectedDevice); diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatContextMenu.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatContextMenu.cs index 46b747e1..979c833a 100644 --- a/com.unity.mobile.android-logcat/Editor/AndroidLogcatContextMenu.cs +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatContextMenu.cs @@ -39,6 +39,16 @@ internal enum FilterContextMenu MatchCase } + internal enum ScreenshotContextMenu + { + None, + ShowInFileBrowser, + Open, + SaveAs, + Rename, + Reconnect + } + class AndroidContextMenu { internal class MenuItemData diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatImageViewer.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatImageViewer.cs new file mode 100644 index 00000000..3a45412e --- /dev/null +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatImageViewer.cs @@ -0,0 +1,300 @@ +using System; +using UnityEditor; +using UnityEngine; + +namespace Unity.Android.Logcat +{ + /// + /// Zoom and pan for an image drawn into a rect handed down by a window - the live + /// view and the saved screenshots both draw through it. Ctrl and the wheel zoom, + /// Ctrl and a left or middle mouse drag move the zoomed image, and scrollbars appear with + /// it. Everything else is left alone, because the live view forwards clicks, the + /// plain wheel and keys to the device. + /// + internal class AndroidLogcatImageViewer + { + static class Styles + { + static readonly string kModifier = + Application.platform == RuntimePlatform.OSXEditor ? "Cmd" : "Ctrl"; + + static readonly string Gestures = + $"{kModifier}+Wheel over the image zooms between 100% and 4000%, " + + $"{kModifier}+Left or middle mouse drag moves the zoomed image"; + + internal static GUIContent Zoom(int percent) + { + return new GUIContent($"{percent}%", Gestures); + } + + static GUIStyle s_Badge; + + /// + /// A help box that does not wrap. The standard one does, and CalcSize being + /// a fraction short of what it then needs breaks "168%" across two lines. + /// + internal static GUIStyle Badge + { + get + { + if (s_Badge == null) + { + s_Badge = new GUIStyle(EditorStyles.helpBox); + s_Badge.wordWrap = false; + s_Badge.alignment = TextAnchor.MiddleCenter; + s_Badge.padding = new RectOffset(6, 6, 2, 2); + } + return s_Badge; + } + } + } + + internal const float kMinZoom = 1.0f; + internal const float kMaxZoom = 40.0f; + + // Four notches of the wheel double the zoom. A factor rather than a fixed step, + // because a step that is a sensible move at 100% is invisible at 4000%. + const float kWheelDeltaPerNotch = 3.0f; + const float kNotchesPerDoubling = 4.0f; + const int kLeftMouseButton = 0; + const int kMiddleMouseButton = 2; + const float kBadgeMargin = 4; + // Deliberately more than a scrollbar takes. It only bounds how far the image can + // be moved, and BeginScrollView clamps what it is handed, so guessing high costs + // nothing where guessing low leaves a strip of the image unreachable. + const float kScrollbarSize = 20; + + float m_Zoom = kMinZoom; + Vector2 m_Scroll; + bool m_Panning; + + internal float Zoom => m_Zoom; + internal Vector2 Scroll => m_Scroll; + internal int ZoomPercent => Mathf.RoundToInt(m_Zoom * 100); + internal bool IsZoomed => m_Zoom > kMinZoom; + + internal void Reset() + { + m_Zoom = kMinZoom; + m_Scroll = Vector2.zero; + } + + /// + /// Draws an image of the given aspect ratio into and + /// returns the box it is seen through, for laying out whatever sits beside it. + /// is handed the image rect, which is only + /// meaningful inside the scroll view - the same space the live view reads the + /// mouse in. covers the views that are not already + /// repainting, a stopped stream or a screenshot. + /// + internal Rect DoGUI(Rect area, float aspect, Action drawContents, Action repaint) + { + // Allocated on every pass whatever the state, so that the ids handed out + // after it do not shift between the Layout and Repaint passes. + var controlId = GUIUtility.GetControlID(FocusType.Passive); + + // Both before the scroll view, so neither it nor the contents see these + // events first: the live view forwards a plain wheel to the device, and the + // scroll view would scroll on it. + var box = ViewBox(area, aspect, out _); + HandleZoom(area, aspect, box, repaint); + HandlePan(controlId, area, aspect, box, repaint); + + box = ViewBox(area, aspect, out var image); + var content = new Rect(0, 0, image.x, image.y); + + m_Scroll = GUI.BeginScrollView(box, m_Scroll, content); + drawContents(content); + GUI.EndScrollView(); + + // Outside the scroll view, or it would scroll away with the image. + if (IsZoomed) + DoZoomBadgeGUI(box); + + return box; + } + + /// + /// The box the image is seen through, centred in the area, and the size the + /// image is drawn at. The box is the image's own size until the image outgrows + /// the area: at 100% that is exactly the fitted image, and a zoomed portrait + /// screen keeps the scrollbar against its edge rather than across the letterbox. + /// + Rect ViewBox(Rect area, float aspect, out Vector2 image) + { + var fitted = FitRect(area, aspect); + image = new Vector2(fitted.width, fitted.height) * m_Zoom; + + // Room for whichever scrollbar the image is about to need. + var want = image; + if (image.y > area.height) + want.x += kScrollbarSize; + if (image.x > area.width) + want.y += kScrollbarSize; + + var size = new Vector2(Mathf.Min(area.width, want.x), Mathf.Min(area.height, want.y)); + return new Rect( + area.x + (area.width - size.x) * 0.5f, + area.y + (area.height - size.y) * 0.5f, + size.x, size.y); + } + + static Rect FitRect(Rect container, float aspect) + { + if (container.width <= 0 || container.height <= 0 || aspect <= 0) + return container; + + if (aspect > container.width / container.height) + { + var height = container.width / aspect; + return new Rect(container.x, container.y + (container.height - height) * 0.5f, container.width, height); + } + + var width = container.height * aspect; + return new Rect(container.x + (container.width - width) * 0.5f, container.y, width, container.height); + } + + /// + /// Zooms by one wheel movement, keeping whatever is under + /// where it is. Positive deltas zoom out, matching + /// the wheel. Returns false when the zoom was already at the end of its range. + /// + internal bool ZoomAt(Rect area, float aspect, Vector2 pointer, float wheelDelta) + { + if (area.width <= 0 || area.height <= 0) + return false; + + var doublings = -wheelDelta / (kWheelDeltaPerNotch * kNotchesPerDoubling); + var zoom = Mathf.Clamp(m_Zoom * Mathf.Pow(2.0f, doublings), kMinZoom, kMaxZoom); + if (Mathf.Approximately(zoom, m_Zoom)) + return false; + + var box = ViewBox(area, aspect, out var image); + if (image.x <= 0 || image.y <= 0) + { + m_Zoom = zoom; + return true; + } + + var pointOnImage = new Vector2( + (pointer.x - box.x + m_Scroll.x) / image.x, + (pointer.y - box.y + m_Scroll.y) / image.y); + + m_Zoom = zoom; + + // The box moves as well as the image, growing until it fills the area, so + // the same point is somewhere else on screen even before scrolling. + var zoomedBox = ViewBox(area, aspect, out var zoomedImage); + m_Scroll = new Vector2( + pointOnImage.x * zoomedImage.x - (pointer.x - zoomedBox.x), + pointOnImage.y * zoomedImage.y - (pointer.y - zoomedBox.y)); + ClampScroll(area, aspect); + return true; + } + + /// Moves the image with the mouse, so the view moves the other way. + internal void Pan(Rect area, float aspect, Vector2 mouseDelta) + { + m_Scroll -= mouseDelta; + ClampScroll(area, aspect); + } + + void HandleZoom(Rect area, float aspect, Rect box, Action repaint) + { + var e = Event.current; + if (e.type != EventType.ScrollWheel || !IsViewModifier(e)) + return; + if (!box.Contains(e.mousePosition)) + return; + + // Used at either end of the range too: the wheel is still zooming, and + // letting it through would scroll the view or, in the live view, the device. + e.Use(); + + if (ZoomAt(area, aspect, e.mousePosition, e.delta.y)) + repaint?.Invoke(); + } + + void HandlePan(int controlId, Rect area, float aspect, Rect box, Action repaint) + { + var e = Event.current; + + switch (e.type) + { + case EventType.MouseDown: + // The left button as well as the middle one: a trackpad has no + // middle button, so on macOS there would be no way to pan at all. + // The modifier is what keeps a plain drag going to the device. + if ((e.button != kMiddleMouseButton && e.button != kLeftMouseButton) + || !IsViewModifier(e) || !IsZoomed) + break; + // Not while something else is being dragged - a touch being held on + // the device, say. + if (GUIUtility.hotControl != 0 || !box.Contains(e.mousePosition)) + break; + // Routes the rest of the drag here, including outside the box. + GUIUtility.hotControl = controlId; + m_Panning = true; + e.Use(); + break; + + case EventType.MouseDrag: + if (!m_Panning) + break; + // The modifier is deliberately not rechecked: letting go of Ctrl + // halfway through a drag should not abandon it. + Pan(area, aspect, e.delta); + e.Use(); + repaint?.Invoke(); + break; + + case EventType.MouseUp: + if (!m_Panning) + break; + EndPan(controlId); + e.Use(); + break; + } + + // A drag that left the window never reports its button going up. + if (m_Panning && e.type == EventType.MouseLeaveWindow) + EndPan(controlId); + } + + void EndPan(int controlId) + { + m_Panning = false; + if (GUIUtility.hotControl == controlId) + GUIUtility.hotControl = 0; + } + + static bool IsViewModifier(Event e) + { + return (e.modifiers & (EventModifiers.Control | EventModifiers.Command)) != 0; + } + + void ClampScroll(Rect area, float aspect) + { + var box = ViewBox(area, aspect, out var image); + + // The scrollbars sit inside the box, so each takes a strip off what is left. + var visible = new Vector2( + box.width - (image.y > box.height ? kScrollbarSize : 0), + box.height - (image.x > box.width ? kScrollbarSize : 0)); + + m_Scroll = new Vector2( + Mathf.Clamp(m_Scroll.x, 0, Mathf.Max(0, image.x - visible.x)), + Mathf.Clamp(m_Scroll.y, 0, Mathf.Max(0, image.y - visible.y))); + } + + void DoZoomBadgeGUI(Rect area) + { + var content = Styles.Zoom(ZoomPercent); + var size = Styles.Badge.CalcSize(content); + var rect = new Rect(area.x + kBadgeMargin, area.y + kBadgeMargin, + Mathf.Min(Mathf.Ceil(size.x), area.width), + Mathf.Min(Mathf.Ceil(size.y), area.height)); + GUI.Label(rect, content, Styles.Badge); + } + } +} diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatImageViewer.cs.meta b/com.unity.mobile.android-logcat/Editor/AndroidLogcatImageViewer.cs.meta new file mode 100644 index 00000000..7e5002d8 --- /dev/null +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatImageViewer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 12a67dd26b8f42789faffcba19296bf6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatLayoutViewerWindow.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatLayoutViewerWindow.cs index aacd5d1c..f2a155b4 100644 --- a/com.unity.mobile.android-logcat/Editor/AndroidLogcatLayoutViewerWindow.cs +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatLayoutViewerWindow.cs @@ -48,7 +48,7 @@ private void OnEnable() m_Runtime = AndroidLogcatManager.instance.Runtime; m_Runtime.Closing += OnDisable; m_DeviceSelection = new AndroidLogcatDeviceSelection(m_Runtime, null, nameof(AndroidLogcatLayoutViewerWindow) + "_DeviceId"); - m_CaptureScreenshot = m_Runtime.CaptureScreenshot; + m_CaptureScreenshot = m_Runtime.LayoutCaptureScreenshot; m_QueryLayout = m_Runtime.QueryLayout; LoadUI(); @@ -258,7 +258,7 @@ private string ResolveDisplaySizeString() } private void DoScreenshotSaveAsGUI() { - var srcPath = m_CaptureScreenshot.GetImagePath(m_DeviceSelection.SelectedDevice); + var srcPath = m_CaptureScreenshot.GetLatestImagePath(m_DeviceSelection.SelectedDevice); EditorGUI.BeginDisabledGroup(string.IsNullOrEmpty(srcPath) || m_CaptureScreenshot.ImageTexture == null); if (GUILayout.Button(Styles.SaveScreenshot, AndroidLogcatStyles.toolbarButton)) { diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatLiveStream.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatLiveStream.cs new file mode 100644 index 00000000..20cc7f81 --- /dev/null +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatLiveStream.cs @@ -0,0 +1,2113 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using UnityEditor; +using UnityEngine; + +namespace Unity.Android.Logcat +{ + /// + /// Live view of an Android device screen. + /// + /// A small server jar (built from External~/UnityLogcatServer, shipped in the + /// package as External~/unity-logcat-server.jar) is pushed to the device and run + /// by app_process as the shell user. It mirrors a display, encodes each frame as + /// JPEG and writes the frames to an abstract unix socket, which adb forwards to a + /// local TCP port that we read here. + /// + /// + /// The transport is a socket rather than the server's stdout because it is + /// bidirectional - the same connection can later carry input events to the + /// device - and it keeps frame data off a stream that also carries log output. + /// + /// + /// Threading: a reader thread does the blocking socket reads and hands the newest + /// frame over; turns it into a texture on the main thread, + /// because Texture2D can only be touched there. + /// + /// + internal class AndroidLogcatLiveStream + { + internal enum Result + { + Success, + Failure + } + + internal enum FailureType + { + None, + JarNotFound + } + + /// + /// The type byte that starts every Editor to server control message. Values match + /// the TYPE_* constants in ControlReader.java, and the server stops reading + /// control input on one it does not recognize, since it would no longer know + /// where the next message begins. + /// + internal enum ControlMessage : byte + { + Touch = 1, + Key = 2, + Text = 3, + Scroll = 4 + } + + /// Values match the action byte in ControlReader.java. + internal enum TouchAction : byte + { + Down = 0, + Up = 1, + Move = 2, + /// Abandons the gesture without a tap, e.g. the mouse left the window. + Cancel = 3 + } + + /// Values match the action byte in ControlReader.java. + internal enum KeyAction : byte + { + Down = 0, + Up = 1 + } + + readonly AndroidLogcatImageViewer m_Viewer = new AndroidLogcatImageViewer(); + + static string m_ServerJarPath; + + internal static string GetServerJarPath() + { + if (!string.IsNullOrEmpty(m_ServerJarPath)) + return m_ServerJarPath; + + var path = AndroidLogcatUtilities.ResolvePath(kServerExternalFolder, kServerJarName); + if (path == null) + throw new FileNotFoundException($"Couldn't locate the Android Logcat package to find {kServerJarName} in it."); + + m_ServerJarPath = path; + return m_ServerJarPath; + } + + // Android KeyEvent.META_* flags. Both the basic flag and the left variant, + // the way a keyboard reports a modifier that is actually held down. + const int kMetaShiftOn = 0x1 | 0x40; + const int kMetaAltOn = 0x2 | 0x10; + const int kMetaCtrlOn = 0x1000 | 0x2000; + + // Must stay in step with External/UnityLogcatServer: Protocol.java and the + // serverProtocolVersion / serverSocketName / serverDevicePath entries in + // gradle.properties. The server sends its version in the stream header, so a + // mismatch is reported rather than misparsed. + const uint kProtocolMagic = 0x554C5331; // "ULS1" + const int kProtocolVersion = 6; + const int kCodecMjpeg = 1; + const int kStreamHeaderSize = 20; // magic + version + codec + flags + serverPid + // ptsUs + width + height + displayWidth + displayHeight + payloadSize + const int kFrameHeaderSize = 28; + + // Protocol.FLAG_CONTROL_SUPPORTED: the server was able to set up input + // injection, so touch messages will actually do something. + const int kFlagControlSupported = 1; + + // Largest fixed-size message: the key one, at type + action + keyCode + metaState. + const int kControlMessageSize = 10; + const int kMaxTextBytes = 4096; + // Positions go over the wire normalized, so the server can scale them against + // the display size it is currently capturing rather than trusting ours, which + // is always at least a frame - and possibly a whole rotation - out of date. + const float kNormalizedMax = 65535.0f; + // Scroll notches go over as fixed point, so a trackpad's fractions survive + // without putting a float on the wire. Must match SCROLL_SCALE in ControlReader. + const float kScrollScale = 256.0f; + // Unity reports roughly three lines per wheel notch, where Android counts + // notches, so the delta is divided by this on the way out. + const float kUnityScrollLinesPerNotch = 3.0f; + + const string kServerJarName = "unity-logcat-server.jar"; + // The jar is pushed under a name of its own per session - see StartStreaming. + const string kServerDeviceFolder = "/data/local/tmp"; + const string kServerDeviceNamePrefix = "unity-logcat-server"; + const string kServerMainClass = "com.unity.android.logcat.server.Server"; + const string kServerExternalFolder = "External~"; + + // Stream settings live in AndroidLogcatSettings, under Preferences. The + // arguments of StartStreaming override them, which is what the tests use. + + // How long the server waits for us, and how long we spend trying to reach it. + // The server's own timeout is the longer of the two, so that it is always us + // who gives up first and the server is never left listening for a client that + // has already stopped trying. + const int kConnectTimeoutMs = 10000; + const int kServerConnectTimeoutMs = 15000; + const int kConnectRetryDelayMs = 100; + + // A frame is a JPEG of a phone screen; anything this large means the stream + // has desynchronized and we should fail instead of allocating wildly. + const int kMaxFrameSize = 32 * 1024 * 1024; + + const float kNavigationSpacing = 6; + const float kBuildJarButtonWidth = 180; + const float kReconnectButtonWidth = 100; + const float kNavigationButtonWidth = 60; + // A row each: both labels are too wide for the two of them to share the stats + // column without being clipped. + const float kDebugButtonWidth = 150; + + static class Styles + { + internal static readonly GUIContent DisplaySize = new GUIContent("Display Size", + "Resolution of the display being mirrored, as the frames report it. The streamed " + + "image is this scaled down to fit Max Size, so the two rows together say how much " + + "detail the stream is giving up."); + internal static readonly GUIContent StreamSize = new GUIContent("Stream Size", + "Size of the streamed image, which is the device display scaled down to fit max_size."); + internal static readonly GUIContent FrameRate = new GUIContent("Frame Rate", + "Frames arriving per second. A mirrored display only produces a frame when the screen changes, so an idle device sends almost none."); + internal static readonly GUIContent Bandwidth = new GUIContent("Bandwidth", + "Megabits per second arriving over adb."); + internal static readonly GUIContent Input = new GUIContent("Input", + "Click or drag the image to send touch events to the device, scroll the wheel over it to " + + "scroll on the device, and click it then type to send keys. Select all, copy and paste go " + + "to the device and use its clipboard; other Ctrl and Cmd combinations stay in the Editor."); + + // Same glyphs and wording as the navigation row in the Inputs window. + internal static readonly GUIContent NavigationKeys = new GUIContent("Navigation Keys"); + internal static readonly GUIContent Back = new GUIContent("◄", + "Send Back key event. The Escape key does the same once the image has focus."); + internal static readonly GUIContent Home = new GUIContent("●", "Send Home key event"); + internal static readonly GUIContent Recents = new GUIContent("■", "Send Overview key event"); + + internal static readonly GUIContent DeviceRotation = new GUIContent("Device Rotation", + "Rotate the device itself. Auto hands the rotation back to its accelerometer."); + internal static readonly GUIContent[] Rotations = + { + new GUIContent("Auto", "Let the device rotate with its accelerometer again"), + new GUIContent("0°", "Lock the device to its natural orientation"), + new GUIContent("90°", "Lock the device rotated 90°"), + new GUIContent("180°", "Lock the device rotated 180°"), + new GUIContent("270°", "Lock the device rotated 270°") + }; + + internal static readonly GUIContent DeveloperMode = new GUIContent("Developer Mode"); + internal static readonly GUIContent Socket = new GUIContent("Socket", + "Abstract unix socket the on-device server is listening on."); + internal static readonly GUIContent ForwardedPort = new GUIContent("Port", + "Local TCP port adb forwards to that socket."); + internal static readonly GUIContent ServerOnDevice = new GUIContent("Server", + "Where the server jar was pushed on the device."); + internal static readonly GUIContent ServerPid = new GUIContent("Server PID", + "Process id of the server on the device, for adb shell kill or ps."); + internal static readonly GUIContent RebuildJar = new GUIContent("Rebuild Server", + "Run 'gradlew dexJar' on External/UnityLogcatServer, which also copies the jar into the " + + "package, then restart the stream so the device picks the new one up and point the " + + "Logcat window at the server that comes back. Only available in the package's own " + + "repository, where that Gradle project sits next to the package."); + internal static readonly GUIContent KillServer = new GUIContent("Kill Server", + "Kill the server on the device, so the stream fails the way it would if the " + + "server died on its own."); + internal static readonly string NoDevice = + "No device selected. Connect a device, then select it from the device list."; + internal static readonly GUIContent Reconnect = new GUIContent("Reconnect", + "Start the stream on the device again."); + internal static readonly GUIContent ShowServerLogcat = new GUIContent("Show Server Logs", + "Open the Android Logcat window filtered to this server's process."); + } + + AndroidLogcatRuntimeBase m_Runtime; + IAndroidLogcatDevice m_Device; + + /// + /// Where the forwarded port lives, for setups where adb's forward is not on + /// this machine's loopback: which port to forward, and the address to reach it + /// at. Set by the integration tests on build agents whose adb server runs + /// beside the device rather than beside the Editor. All off by default, which + /// is the plain 'adb picks a port on localhost' case. + /// + internal int ForwardLocalPort { get; set; } + internal string TunnelHost { get; set; } + internal int TunnelPort { get; set; } + Action m_OnStopLiveStream; + FailureType m_FailureType; + + Process m_ServerProcess; + readonly StringBuilder m_ServerLog = new StringBuilder(); + readonly StringBuilder m_Errors = new StringBuilder(); + string m_SocketName; + // Where this session's jar lives on the device, unique per session - see + // StartStreaming for why it cannot be a shared path. + string m_ServerDevicePath; + int m_ForwardedPort = -1; + + Thread m_ReaderThread; + /// One per stream, so a reader cannot outlive its own session. + sealed class ReaderSession + { + internal volatile bool Stop; + } + + ReaderSession m_Session; + volatile string m_ReaderError; + volatile bool m_StreamEnded; + + // Guards the connection so that StopStreaming can close it from the main + // thread while the reader thread is blocked in a read on it. + readonly object m_ConnectionLock = new object(); + TcpClient m_Client; + NetworkStream m_Stream; + + // Frame handover, reader thread -> main thread. + readonly object m_FrameLock = new object(); + byte[] m_PendingFrame; + int m_PendingFrameSize; + int m_PendingWidth; + int m_PendingHeight; + int m_PendingDisplayWidth; + int m_PendingDisplayHeight; + long m_ReceivedBytes; + int m_ReceivedFrames; + + // Frame buffers are reused rather than allocated per frame, which at 30 fps was + // a few MB per second of short-lived garbage. + // + // A buffer is owned by exactly one of four places at any moment: this free list, + // the reader thread filling it, the pending slot, or the main thread decoding it. + // It only ever moves between them under m_FrameLock, and the main thread is what + // hands it back, so the reader cannot overwrite a buffer being decoded. Three is + // the most that can be in flight at once - one being filled, one pending, one + // being decoded. + const int kMaxFrameBuffers = 3; + readonly Stack m_FreeFrameBuffers = new Stack(kMaxFrameBuffers); + + volatile bool m_ControlSupported; + // Reported by the server in the stream header, so it is exact rather than + // guessed from the process table, where several app_process entries can exist. + volatile int m_ServerPid; + // Set by the Rebuild jar button, cleared once the Logcat window has been + // pointed at the server that came up. The pid is not known when the stream is + // started - it arrives in the stream header, on the reader thread - so this + // waits for it rather than guessing. + bool m_ShowLogcatWhenServerStarts; + readonly byte[] m_ControlMessage = new byte[kControlMessageSize]; + bool m_TouchDown; + // The modifier keys the device is holding because the user is holding them. + EventModifiers m_HeldModifiers; + // The keys the device is holding because their key-down was forwarded, so that + // their key-up can be forwarded too even when the event that carries it no + // longer qualifies for the path that sent the down. + readonly Dictionary m_HeldKeys = new Dictionary(); + bool m_ControlWriteFailed; + + Texture2D m_Texture; + int m_FrameWidth; + int m_FrameHeight; + // What the frames were scaled down from. Zero until the first one arrives. + int m_DisplayWidth; + int m_DisplayHeight; + double m_Fps; + double m_Mbps; + DateTime m_StatsTime; + long m_StatsBytes; + int m_StatsFrames; + + internal bool IsStreaming => m_ReaderThread != null; + internal string Errors => m_Errors.ToString(); + internal Texture2D Texture => m_Texture; + + /// + /// Whether the server can inject input. False means the device refused to set it + /// up, in which case the view is read only and says so - better than accepting + /// clicks that quietly go nowhere. + /// + internal bool ControlSupported => m_ControlSupported; + + /// + /// Input is always on when the device supports it. There is no toggle: sending an + /// event costs a handful of bytes and nothing at all when idle, so the only + /// argument for one would be avoiding stray input, and a window does not click or + /// type by itself. + /// + bool CanSendInput => IsStreaming && m_ControlSupported; + + /// Frames read off the socket since streaming started. + internal int FramesReceived + { + get + { + lock (m_FrameLock) + return m_ReceivedFrames; + } + } + + internal AndroidLogcatLiveStream(AndroidLogcatRuntimeBase runtime) + { + m_Runtime = runtime; + m_Runtime.Update += Update; + m_Runtime.Closing += Cleanup; + } + + void Cleanup() + { + if (m_Runtime == null) + return; + if (IsStreaming) + Shutdown(Result.Success); + DestroyTexture(); + m_Runtime = null; + } + + internal void StartStreaming(IAndroidLogcatDevice device, + Action onStopLiveStream, + int? maxSize = null, + int? quality = null, + int? maxFps = null, + string displayId = null) + { + if (device == null) + throw new InvalidOperationException("No device selected"); + if (IsStreaming) + throw new InvalidOperationException("Already streaming"); + + m_Errors.Clear(); + m_FailureType = FailureType.None; + lock (m_ServerLog) + m_ServerLog.Clear(); + DestroyTexture(); + + m_Device = device; + m_OnStopLiveStream = onStopLiveStream; + var session = new ReaderSession(); + m_Session = session; + m_ReaderError = null; + m_StreamEnded = false; + m_ControlSupported = false; + m_ServerPid = 0; + m_ControlWriteFailed = false; + m_TouchDown = false; + m_HeldModifiers = EventModifiers.None; + m_HeldKeys.Clear(); + m_FrameWidth = 0; + m_FrameHeight = 0; + m_DisplayWidth = 0; + m_DisplayHeight = 0; + m_Fps = 0; + m_Mbps = 0; + m_StatsTime = DateTime.Now; + m_StatsBytes = 0; + m_StatsFrames = 0; + lock (m_FrameLock) + { + m_PendingFrame = null; + m_PendingFrameSize = 0; + m_ReceivedBytes = 0; + m_ReceivedFrames = 0; + } + + try + { + // One id for the session, used for both the socket and the jar, and + // settled before anything is pushed - the push needs the path. + // + // The socket name has to be unique so that a server left over from a + // previous run cannot own the name we are about to listen on. The jar + // path has to be unique because `adb push` rewrites its destination in + // place rather than replacing it: with a shared name, starting a stream + // while the previous server is still on its way out would truncate the + // file that one is executing from, and a class it had not loaded yet + // would fail to load. + var sessionId = Guid.NewGuid().ToString("N").Substring(0, 8); + m_SocketName = "unity_logcat_server_" + sessionId; + m_ServerDevicePath = $"{kServerDeviceFolder}/{kServerDeviceNamePrefix}-{sessionId}.jar"; + + // Before anything else, because a dark screen produces no frames at all + // and the wait for the first one would just time out. + device.WakeUp(); + + // Before pushing ours, so it cannot sweep away what it is about to push. + RemoveStaleServerJars(device); + + var jarPath = GetServerJarPath(); + if (!File.Exists(jarPath)) + { + m_FailureType = FailureType.JarNotFound; + var error = $"{kServerJarName} is missing from the package, live streaming is unavailable.\n" + + $"Expected it at {jarPath}\n" + + "Build it by running 'gradlew dexJar' in External/UnityLogcatServer."; + AppendError(error); + Shutdown(Result.Failure); + return; + } + + PushServer(device, jarPath); + + var settings = m_Runtime.Settings; + StartServerProcess(device, + maxSize ?? settings.LiveStreamMaxSize, + quality ?? settings.LiveStreamQuality, + maxFps ?? settings.LiveStreamMaxFps, + displayId); + m_ForwardedPort = SetupPortForward(device, m_SocketName); + + // Connecting is retried until the server has created its socket, so it + // happens on the reader thread rather than stalling the main thread. + m_ReaderThread = new Thread(() => ReadFrames(session)) + { + Name = "AndroidLogcatLiveStream", + IsBackground = true + }; + m_ReaderThread.Start(); + } + catch (Exception ex) + { + AndroidLogcatInternalLog.Log(ex.ToString()); + AppendError(ex.Message); + // Nothing is streaming, so unwind whatever did get set up and report + // through the callback instead of throwing into OnGUI. + Shutdown(Result.Failure); + } + } + + internal bool StopStreaming() + { + if (!IsStreaming) + return false; + Shutdown(Result.Success); + return m_Errors.Length == 0; + } + + /// + /// Tears down everything StartStreaming may have set up, in the reverse order, + /// and reports the outcome. Safe to call when only part of the setup happened. + /// + void Shutdown(Result result) + { + var session = m_Session; + m_Session = null; + if (session != null) + session.Stop = true; + // Whatever was waiting for a pid is not getting one now. + m_ShowLogcatWhenServerStarts = false; + + // The connection goes first: closing it is what unblocks a reader thread + // parked in a read, so the join below does not have to wait it out. + CloseConnection(); + + var thread = m_ReaderThread; + m_ReaderThread = null; + if (thread != null && !thread.Join(TimeSpan.FromSeconds(2))) + AndroidLogcatInternalLog.Log("Live stream reader thread did not stop in time"); + + // With the reader gone, and this being the main thread, nothing can still be + // holding a buffer - and once the stream is over they are only memory. A + // frame off a big display is worth a couple of MB. + lock (m_FrameLock) + { + m_PendingFrame = null; + m_PendingFrameSize = 0; + m_FreeFrameBuffers.Clear(); + } + + KillServerProcess(); + RemovePortForward(); + RemoveServerJar(); + + if (result == Result.Failure) + AppendServerLog(); + + m_Device = null; + var callback = m_OnStopLiveStream; + m_OnStopLiveStream = null; + callback?.Invoke(result); + } + + void Update() + { + if (!IsStreaming) + return; + + // The header has landed, so the new server can be named. Done here rather + // than where the pid is parsed, because that is the reader thread and this + // opens an EditorWindow. + if (m_ShowLogcatWhenServerStarts && m_ServerPid > 0) + { + m_ShowLogcatWhenServerStarts = false; + ShowServerLogcat(); + } + + ApplyPendingFrame(); + + var error = m_ReaderError; + if (!string.IsNullOrEmpty(error)) + { + AppendError(error); + Shutdown(Result.Failure); + return; + } + + if (m_StreamEnded) + { + // A reader that ended without recording an error, which today can only + // mean Stop was asked for - the loop has no other way out. + // + // The connection closing on its own does not arrive here: reading hits + // end of stream, which is an exception, so it goes through the branch + // above. That is deliberate. A stream that ends without the user asking + // is worth reporting, and reporting it as a failure is what appends the + // server's own log, which is where the reason lives - the captured + // display went away, the process was killed, and so on. + Shutdown(Result.Success); + } + } + + void ApplyPendingFrame() + { + byte[] frame; + int size; + int width, height; + int displayWidth, displayHeight; + long bytes; + int frames; + + lock (m_FrameLock) + { + frame = m_PendingFrame; + size = m_PendingFrameSize; + m_PendingFrame = null; + m_PendingFrameSize = 0; + width = m_PendingWidth; + height = m_PendingHeight; + displayWidth = m_PendingDisplayWidth; + displayHeight = m_PendingDisplayHeight; + bytes = m_ReceivedBytes; + frames = m_ReceivedFrames; + } + + if (frame != null) + { + if (m_Texture == null) + m_Texture = new Texture2D(2, 2); + // LoadImage resizes the texture to the incoming frame, which is how a + // rotation is absorbed: the server just starts sending a new size. + // + // Decoded through a span rather than the byte[] overload, which would + // take the whole buffer: a reused buffer is usually larger than the + // frame sitting in it. + if (ImageConversion.LoadImage(m_Texture, new ReadOnlySpan(frame, 0, size))) + { + m_FrameWidth = width; + m_FrameHeight = height; + m_DisplayWidth = displayWidth; + m_DisplayHeight = displayHeight; + } + + // Returned whether or not it decoded - a frame this thread could not + // read is still a buffer the reader can fill. + ReturnFrameBuffer(frame); + } + + var now = DateTime.Now; + var elapsed = (now - m_StatsTime).TotalSeconds; + if (elapsed >= 1.0) + { + m_Fps = (frames - m_StatsFrames) / elapsed; + m_Mbps = (bytes - m_StatsBytes) * 8 / elapsed / 1000000.0; + m_StatsTime = now; + m_StatsFrames = frames; + m_StatsBytes = bytes; + } + } + + // ------------------------------------------------------------------ + // Server setup + // ------------------------------------------------------------------ + + void PushServer(IAndroidLogcatDevice device, string jarPath) + { + AndroidLogcatInternalLog.Log($"Pushing {jarPath} to {m_ServerDevicePath}"); + // Pushed on every start: the destination name is new each time, so there is + // never a stale jar to reuse and never one in use to overwrite. + m_Runtime.Tools.ADB.Run(new[] + { + $"-s {device.Id}", + "push", + $"\"{jarPath}\"", + m_ServerDevicePath + }, $"Failed to push {kServerJarName} to the device"); + } + + void StartServerProcess(IAndroidLogcatDevice device, int maxSize, int quality, int maxFps, string displayId) + { + var args = new StringBuilder(); + args.Append($"-s {device.Id} shell CLASSPATH={m_ServerDevicePath} app_process / {kServerMainClass}"); + args.Append($" socket_name={m_SocketName}"); + args.Append($" max_size={maxSize}"); + args.Append($" quality={quality}"); + args.Append($" max_fps={maxFps}"); + args.Append($" connect_timeout_ms={kServerConnectTimeoutMs}"); + if (!string.IsNullOrEmpty(displayId)) + args.Append($" display_id={displayId}"); + if (Unsupported.IsDeveloperMode()) + args.Append(" log_level=debug"); + + AndroidLogcatInternalLog.Log($"{m_Runtime.Tools.ADB.GetADBPath()} {args}"); + + m_ServerProcess = new Process(); + var si = m_ServerProcess.StartInfo; + si.FileName = m_Runtime.Tools.ADB.GetADBPath(); + si.Arguments = args.ToString(); + si.RedirectStandardOutput = true; + si.RedirectStandardError = true; + si.UseShellExecute = false; + si.CreateNoWindow = true; + // Both streams are drained asynchronously. The server logs to stdout and + // stderr for its whole lifetime, and a pipe nobody reads eventually fills + // and blocks the server. + m_ServerProcess.OutputDataReceived += OnServerOutput; + m_ServerProcess.ErrorDataReceived += OnServerOutput; + m_ServerProcess.Start(); + m_ServerProcess.BeginOutputReadLine(); + m_ServerProcess.BeginErrorReadLine(); + } + + void OnServerOutput(object sender, DataReceivedEventArgs e) + { + if (string.IsNullOrEmpty(e.Data)) + return; + lock (m_ServerLog) + m_ServerLog.AppendLine(e.Data); + AndroidLogcatInternalLog.Log(e.Data); + } + + /// + /// Where the forwarded port is listening. adb opens it on whichever host runs + /// the adb *server*, which is this machine unless the environment points + /// somewhere else. Both loopbacks are tried otherwise: which family adb binds + /// is up to adb, and a connect to the other one is refused outright. + /// + static IPAddress[] AdbServerAddresses() + { + // ANDROID_ADB_SERVER_SOCKET is 'tcp::'; the address variable is + // just the host. + var socket = Environment.GetEnvironmentVariable("ANDROID_ADB_SERVER_SOCKET"); + if (!string.IsNullOrEmpty(socket)) + { + var parts = socket.Split(':'); + if (parts.Length >= 3 && parts[0] == "tcp" && parts[1].Length > 0) + return Resolve(parts[1]); + } + + var host = Environment.GetEnvironmentVariable("ANDROID_ADB_SERVER_ADDRESS"); + if (!string.IsNullOrEmpty(host)) + return Resolve(host); + + return new[] { IPAddress.Loopback, IPAddress.IPv6Loopback }; + } + + static IPAddress[] Resolve(string host) + { + if (IPAddress.TryParse(host, out var parsed)) + return new[] { parsed }; + + try + { + return Dns.GetHostAddresses(host); + } + catch (Exception ex) + { + throw new IOException($"Could not resolve the adb server host '{host}': {InnermostMessage(ex)}"); + } + } + + /// + /// What adb thinks it is forwarding, for the failure message. A forward that + /// exists and cannot be reached says something quite different from one that + /// was never created. + /// + string DescribeForwards() + { + try + { + return "adb forward --list:" + Environment.NewLine + + m_Runtime.Tools.ADB.Run(new[] { "forward", "--list" }, "Failed to list adb forwards"); + } + catch (Exception ex) + { + return $"Could not list adb forwards: {InnermostMessage(ex)}"; + } + } + + /// + /// Whether anything on this machine is actually listening on the forwarded + /// port. adb listing a forward only says its server registered one, which is + /// not the same as a socket existing - and that difference is what separates + /// 'adb never bound it' from 'something is refusing us'. + /// + string DescribeLocalListeners() + { + try + { + var listeners = IPGlobalProperties.GetIPGlobalProperties().GetActiveTcpListeners() + .Where(e => e.Port == m_ForwardedPort) + .Select(e => e.ToString()) + .ToArray(); + + return listeners.Length == 0 + ? $"Nothing on this machine is listening on port {m_ForwardedPort}" + : $"Listening on port {m_ForwardedPort}: {string.Join(", ", listeners)}"; + } + catch (Exception ex) + { + return $"Could not list local listeners: {InnermostMessage(ex)}"; + } + } + + /// + /// The message worth reading. Everything reaches adb through reflection, and + /// "Exception has been thrown by the target of an invocation" is what that + /// wrapper says about a failure it is hiding. + /// + static string InnermostMessage(Exception ex) + { + while (ex.InnerException != null) + ex = ex.InnerException; + // Windows socket messages come back padded with nulls, which truncate + // every log line they land in. + return ex.Message.Trim('\0'); + } + + int SetupPortForward(IAndroidLogcatDevice device, string socketName) + { + // tcp:0 lets adb pick a free port and print it, so two Editors streaming + // from two devices cannot collide on a hardcoded one. A caller only asks + // for a specific port when something outside has to know it in advance. + var local = ForwardLocalPort > 0 ? ForwardLocalPort : 0; + var output = m_Runtime.Tools.ADB.Run(new[] + { + $"-s {device.Id}", + "forward", + $"tcp:{local}", + $"localabstract:{socketName}" + }, "Failed to set up an adb port forward for the live stream"); + + // adb only prints the port when it picked one. + var port = local; + if (local == 0 && (!int.TryParse(output.Trim(), out port) || port <= 0)) + throw new Exception($"Expected a port number from 'adb forward tcp:0', got '{output}'"); + + AndroidLogcatInternalLog.Log($"Forwarding tcp:{port} to localabstract:{socketName}"); + return port; + } + + // ------------------------------------------------------------------ + // Reader thread + // ------------------------------------------------------------------ + + void ReadFrames(ReaderSession session) + { + try + { + var stream = Connect(session); + var header = new byte[kFrameHeaderSize]; + + while (!session.Stop) + { + ReadExactly(stream, header, kFrameHeaderSize); + // Bytes 0..7 are the presentation timestamp, unused: frames are + // displayed as they arrive rather than scheduled. + var width = ReadInt32BE(header, 8); + var height = ReadInt32BE(header, 12); + var displayWidth = ReadInt32BE(header, 16); + var displayHeight = ReadInt32BE(header, 20); + var size = ReadInt32BE(header, 24); + + if (size <= 0 || size > kMaxFrameSize) + throw new IOException($"Frame size {size} is out of range, the stream is out of sync"); + + var payload = RentFrameBuffer(size); + ReadExactly(stream, payload, size); + + if (session != m_Session) + break; + + lock (m_FrameLock) + { + // Only the newest frame is kept: if the Editor cannot keep up, + // showing the latest screen matters more than showing every + // frame. The frame being dropped goes back to the free list + // instead of to the GC - the main thread never saw it, so + // nothing else can be holding it. + ReturnFrameBuffer(m_PendingFrame); + + m_PendingFrame = payload; + m_PendingFrameSize = size; + m_PendingWidth = width; + m_PendingHeight = height; + m_PendingDisplayWidth = displayWidth; + m_PendingDisplayHeight = displayHeight; + m_ReceivedBytes += size; + m_ReceivedFrames++; + } + } + } + catch (Exception ex) + { + // A read failing after Stop was requested is just the connection we + // closed ourselves. + if (!session.Stop && session == m_Session) + m_ReaderError = ex.Message; + } + finally + { + if (session == m_Session) + m_StreamEnded = true; + } + } + + /// + /// Connects to the forwarded port and validates the stream header, retrying + /// until the server has created its socket. 'adb forward' succeeds whether or + /// not anything is listening on the device yet, so an early attempt shows up as + /// a connection that is immediately closed rather than as a refused connect. + /// + NetworkStream Connect(ReaderSession session) + { + var deadline = DateTime.Now.AddMilliseconds(kConnectTimeoutMs); + var addresses = string.IsNullOrEmpty(TunnelHost) ? AdbServerAddresses() : Resolve(TunnelHost); + var port = TunnelPort > 0 ? TunnelPort : m_ForwardedPort; + // One entry per address: which one refused and which one was never + // reachable is the whole diagnosis when this times out. + var failures = new Dictionary(); + + while (!session.Stop) + { + foreach (var address in addresses) + { + TcpClient client = null; + try + { + // The socket has to match the address family: an IPv4 socket + // cannot reach a forward adb bound on the IPv6 loopback, and + // fails with 'the socket is not connected' rather than anything + // that names the real problem. + client = new TcpClient(address.AddressFamily) { NoDelay = true }; + lock (m_ConnectionLock) + { + if (session.Stop) + throw new OperationCanceledException(); + // Published before connecting, so that a Stop arriving now can + // close the socket and break us out of the attempt. + m_Client = client; + } + + client.Connect(address, port); + var stream = client.GetStream(); + ValidateStreamHeader(stream); + + lock (m_ConnectionLock) + m_Stream = stream; + AndroidLogcatInternalLog.Log($"Live stream connected on {address}:{port}"); + return stream; + } + catch (ProtocolMismatchException) + { + // Retrying cannot help: we did reach the server and disagree with it. + throw; + } + catch (Exception ex) + { + failures[address] = InnermostMessage(ex); + lock (m_ConnectionLock) + { + m_Client = null; + m_Stream = null; + } + try + { + client?.Close(); + } + catch (Exception) + { + // Nothing useful to do about a failure to close a failed socket. + } + } + } + + if (session.Stop || DateTime.Now >= deadline) + break; + Thread.Sleep(kConnectRetryDelayMs); + } + + if (session.Stop) + throw new OperationCanceledException(); + + var reasons = failures.Select(f => $"{f.Key}:{port} - {f.Value}"); + throw new IOException($"Timed out after {kConnectTimeoutMs} ms connecting to the server on the device" + + $"{Environment.NewLine}{string.Join(Environment.NewLine, reasons)}" + + $"{Environment.NewLine}{DescribeLocalListeners()}" + + $"{Environment.NewLine}{DescribeForwards()}"); + } + + void ValidateStreamHeader(NetworkStream stream) + { + var header = new byte[kStreamHeaderSize]; + ReadExactly(stream, header, kStreamHeaderSize); + + var magic = (uint)ReadInt32BE(header, 0); + if (magic != kProtocolMagic) + throw new ProtocolMismatchException($"Expected stream magic 0x{kProtocolMagic:X8} but got 0x{magic:X8}, this is not the live stream server"); + + var version = ReadInt32BE(header, 4); + if (version != kProtocolVersion) + { + throw new ProtocolMismatchException( + $"The server on the device speaks protocol version {version}, this Editor expects {kProtocolVersion}.\n" + + $"Rebuild {kServerJarName} with 'gradlew dexJar' in External/UnityLogcatServer."); + } + + var codec = ReadInt32BE(header, 8); + if (codec != kCodecMjpeg) + throw new ProtocolMismatchException($"The server is sending codec {codec}, which this Editor cannot decode"); + + var flags = ReadInt32BE(header, 12); + m_ControlSupported = (flags & kFlagControlSupported) != 0; + if (!m_ControlSupported) + AndroidLogcatInternalLog.Log("The server cannot inject input, the live stream will be view only"); + + m_ServerPid = ReadInt32BE(header, 16); + } + + static void ReadExactly(Stream stream, byte[] buffer, int count) + { + var offset = 0; + while (offset < count) + { + var read = stream.Read(buffer, offset, count - offset); + if (read <= 0) + throw new EndOfStreamException("The device closed the live stream connection"); + offset += read; + } + } + + static void WriteUInt16BE(byte[] buffer, int offset, int value) + { + buffer[offset] = (byte)(value >> 8); + buffer[offset + 1] = (byte)value; + } + + static int ReadInt32BE(byte[] buffer, int offset) + { + return (buffer[offset] << 24) + | (buffer[offset + 1] << 16) + | (buffer[offset + 2] << 8) + | buffer[offset + 3]; + } + + /// + /// Reaching a server we cannot talk to, as opposed to not reaching one yet. + /// Retrying a connect makes sense for the latter and never for the former. + /// + class ProtocolMismatchException : Exception + { + public ProtocolMismatchException(string message) : base(message) + { + } + } + + // ------------------------------------------------------------------ + // Teardown + // ------------------------------------------------------------------ + + void CloseConnection() + { + lock (m_ConnectionLock) + { + try + { + m_Stream?.Close(); + m_Client?.Close(); + } + catch (Exception ex) + { + AndroidLogcatInternalLog.Log($"Failed to close the live stream connection: {ex.Message}"); + } + m_Stream = null; + m_Client = null; + } + } + + /// + /// Kills the server on the device, which is the only way to see from the Editor + /// what a server that dies mid-stream looks like. + /// + void KillServerOnDevice() + { + RunAdbQuietly(new[] + { + $"-s {m_Device.Id}", + "shell", + "kill", + m_ServerPid.ToString() + }, $"Failed to kill the live stream server (pid {m_ServerPid})"); + } + + void KillServerProcess() + { + var process = m_ServerProcess; + m_ServerProcess = null; + if (process == null) + return; + + try + { + // Closing the connection makes the server exit by itself, so give it a + // moment before killing it: a clean exit releases the mirrored display + // on the device instead of leaving it to the kernel. + if (!process.WaitForExit(1000)) + { + AndroidLogcatInternalLog.Log("Live stream server did not exit on its own, killing it"); + process.Kill(); + process.WaitForExit(); + } + AndroidLogcatInternalLog.Log($"Live stream server exited with code {process.ExitCode}"); + } + catch (Exception ex) + { + AndroidLogcatInternalLog.Log($"Failed to stop the live stream server: {ex.Message}"); + } + finally + { + process.Close(); + } + } + + /// + /// Deletes jars left behind by sessions that never got to clean up after + /// themselves - an Editor killed mid-stream - and the fixed name that versions + /// before the per-session path used. + /// + /// Safe even if a server is still running from one of them: unlinking a jar does + /// not disturb a process already executing it, because the runtime keeps the + /// file it opened. That was verified on device rather than assumed, on both + /// Android 16 and Android 8.1. + /// + /// + void RemoveStaleServerJars(IAndroidLogcatDevice device) + { + DeleteOnDevice(device, $"{kServerDeviceFolder}/{kServerDeviceNamePrefix}*.jar"); + } + + /// + /// Deletes this session's jar from the device. Unlinking it is safe even if the + /// server somehow outlived us - the file stays alive for whoever has it open - + /// and skipping it would leave 17 KB behind on the device per stream. + /// + void RemoveServerJar() + { + var path = m_ServerDevicePath; + m_ServerDevicePath = null; + DeleteOnDevice(m_Device, path); + } + + /// + /// Deletes files on the device, one path or a glob, and never fails: tidying up + /// is not worth losing a stream over, and what is left behind if it does fail is + /// a small file in a temporary folder. + /// + void DeleteOnDevice(IAndroidLogcatDevice device, string target) + { + if (device == null || string.IsNullOrEmpty(target)) + return; + + RunAdbQuietly(new[] + { + $"-s {device.Id}", + "shell", + // Quoted so the target reaches the device's shell whole, glob and all, + // rather than anything on this side of adb taking an interest in it. + // rm -f is silent when nothing matches. + $"\"rm -f {target}\"" + }, $"Failed to delete {target} from the device"); + } + + /// + /// Runs an adb command and never throws. Everything that goes through here is + /// tidying up after a stream, where what is left behind if it fails is a file in + /// a temporary folder or a port forward that goes away with the adb server. + /// + void RunAdbQuietly(string[] args, string failureMessage) + { + try + { + m_Runtime.Tools.ADB.Run(args, failureMessage); + } + catch (Exception ex) + { + AndroidLogcatInternalLog.Log($"{failureMessage}: {InnermostMessage(ex)}"); + } + } + + void RemovePortForward() + { + if (m_ForwardedPort <= 0) + return; + + var port = m_ForwardedPort; + var device = m_Device; + m_ForwardedPort = -1; + if (device == null) + return; + + RunAdbQuietly(new[] + { + $"-s {device.Id}", + "forward", + "--remove", + $"tcp:{port}" + }, $"Failed to remove the adb port forward for tcp:{port}"); + } + + /// + /// A buffer at least bytes long, reused if one that big + /// is free. Called only from the reader thread. + /// + byte[] RentFrameBuffer(int size) + { + lock (m_FrameLock) + { + while (m_FreeFrameBuffers.Count > 0) + { + var buffer = m_FreeFrameBuffers.Pop(); + if (buffer.Length >= size) + return buffer; + // Too small, because frames have grown - a rotation, or simply a + // busier screen. Dropped, and the rounding up below replaces it. + } + } + + // Rounded up so that frames creeping up in size do not reallocate every + // time: JPEG sizes vary frame to frame even at a fixed resolution. + return new byte[Mathf.NextPowerOfTwo(size)]; + } + + /// + /// Gives a buffer back, from either thread. Null is accepted, so returning + /// whatever happened to be in the pending slot needs no check at the call site. + /// + void ReturnFrameBuffer(byte[] buffer) + { + if (buffer == null) + return; + + lock (m_FrameLock) + { + // Over the cap only if a buffer has leaked somewhere, in which case the + // extra one is better dropped than kept forever. + if (m_FreeFrameBuffers.Count < kMaxFrameBuffers) + m_FreeFrameBuffers.Push(buffer); + } + } + + void DestroyTexture() + { + if (m_Texture == null) + return; + UnityEngine.Object.DestroyImmediate(m_Texture); + m_Texture = null; + } + + void AppendError(string message) + { + if (string.IsNullOrEmpty(message)) + return; + if (m_Errors.Length > 0) + m_Errors.AppendLine(); + m_Errors.AppendLine(message); + } + + void AppendServerLog() + { + string log; + lock (m_ServerLog) + log = m_ServerLog.ToString(); + if (string.IsNullOrEmpty(log)) + return; + m_Errors.AppendLine(); + m_Errors.AppendLine("Server output:"); + m_Errors.Append(log); + } + + // ------------------------------------------------------------------ + // GUI + // ------------------------------------------------------------------ + + /// + /// The device the window is pointed at, which is not necessarily + /// : a stream that failed to start has already been shut + /// down, and shutting down clears that. Only the error state's retry uses it. + /// + /// + /// For what changes outside the frames arriving - zooming a stopped stream. + /// + internal void DoGUI(Rect rc, IAndroidLogcatDevice selectedDevice, Action repaint) + { + // Allocated on every pass, before any early return: skipping it on some + // frames would shift control ids between the Layout and Repaint passes and + // trip "GUI id mismatch" warnings. + var controlId = GUIUtility.GetControlID(FocusType.Keyboard); + + if (m_Errors.Length > 0) + DoErrorsGUI(rc, selectedDevice); + // Before the texture, not after it: a device that goes away stops the + // stream but leaves the last frame behind, and a still image of a device + // that is no longer there says nothing about why it stopped updating. + else if (selectedDevice == null) + EditorGUI.HelpBox(rc, Styles.NoDevice, MessageType.Info); + else if (m_Texture == null) + DoStatusGUI(rc, selectedDevice); + else + DoStreamGUI(rc, controlId, repaint); + } + + /// Why there is no image yet, and the one thing to do about it. + void DoStatusGUI(Rect rc, IAndroidLogcatDevice selectedDevice) + { + if (IsStreaming) + { + EditorGUI.HelpBox(rc, "Starting the stream on the device...", MessageType.Info); + return; + } + + var message = new GUIContent("The live stream is not running. Select Reconnect to start it again."); + var height = EditorGUIUtility.singleLineHeight; + var messageRect = new Rect(rc.x, rc.y, rc.width, + Mathf.Min(Mathf.Max(0, rc.height - height - kNavigationSpacing), + EditorStyles.helpBox.CalcHeight(message, rc.width))); + + EditorGUI.HelpBox(messageRect, message.text, MessageType.Info); + + var buttonRect = new Rect(rc.x, messageRect.yMax + kNavigationSpacing, + Mathf.Min(kReconnectButtonWidth, rc.width), height); + if (GUI.Button(buttonRect, Styles.Reconnect, EditorStyles.miniButton)) + RestartStreaming(selectedDevice); + } + + /// + /// What went wrong, where the image would be, and what can be done about it. + /// + void DoErrorsGUI(Rect rc, IAndroidLogcatDevice selectedDevice) + { + var message = new GUIContent(m_Errors.ToString(), + EditorGUIUtility.IconContent("console.erroricon").image); + + var buttonHeight = EditorGUIUtility.singleLineHeight; + var messageRect = new Rect(rc.x, rc.y, rc.width, + Mathf.Min(Mathf.Max(0, rc.height - buttonHeight - kNavigationSpacing), + EditorStyles.helpBox.CalcHeight(message, rc.width))); + + GUI.Label(messageRect, message, EditorStyles.helpBox); + + DoErrorButtonsGUI(rc, messageRect.yMax + kNavigationSpacing, buttonHeight, selectedDevice); + } + + void DoErrorButtonsGUI(Rect rc, float y, float height, IAndroidLogcatDevice selectedDevice) + { + // The jar is a build output and is not committed, so a fresh clone has none + // until Gradle has run. Offering to run it here is the whole of the fix, so + // the button is only worth drawing when that project is actually next to + // the package - GetServerGradleProjectPath says so. + var gradleProject = m_FailureType == FailureType.JarNotFound + ? GetServerGradleProjectPath() + : null; + + var x = rc.x; + if (gradleProject != null) + { + var buttonRect = new Rect(x, y, Mathf.Min(kBuildJarButtonWidth, rc.width), height); + if (GUI.Button(buttonRect, new GUIContent("Build Server Jar", + $"Runs 'gradlew dexJar' in {gradleProject}, then starts the stream again."))) + { + RebuildServerJar(gradleProject); + // Whatever the window is pointed at now. Building is worth doing + // even with no device selected; only the retry needs one. + RestartStreaming(selectedDevice); + } + x = buttonRect.xMax + kNavigationSpacing; + } + + // A stream that failed leaves nothing in the view to act on, and + // reselecting the row to start another one is not something the window + // says anywhere. + EditorGUI.BeginDisabledGroup(selectedDevice == null); + var reconnectRect = new Rect(x, y, + Mathf.Min(kReconnectButtonWidth, Mathf.Max(0, rc.xMax - x)), height); + if (GUI.Button(reconnectRect, Styles.Reconnect)) + RestartStreaming(selectedDevice); + EditorGUI.EndDisabledGroup(); + } + + /// The mirrored screen, with the stats column beside it. + void DoStreamGUI(Rect rc, int controlId, Action repaint) + { + // The info column is reserved before the image is fitted, so that the image + // is never drawn underneath it. + var statsWidth = IsStreaming ? AndroidLogcatStatsColumn.WidthFor(rc) : 0; + var imageArea = new Rect(rc.x, rc.y, Mathf.Max(0, rc.width - statsWidth), rc.height); + + var aspect = (float)m_Texture.width / m_Texture.height; + + var imageBox = m_Viewer.DoGUI(imageArea, aspect, videoRect => + { + HandleTouchInput(controlId, videoRect); + GUI.DrawTexture(videoRect, m_Texture); + }, repaint); + + HandleKeyboardInput(controlId); + + if (statsWidth > 0) + DoStatsGUI(AndroidLogcatStatsColumn.RectBeside(rc, imageBox)); + } + + void DoStatsGUI(Rect rc) + { + const float kLabelWidth = AndroidLogcatStatsColumn.kLabelWidth; + var y = rc.y; + + // A row reading 0x0 says less than no row at all. + if (m_DisplayWidth > 0 && m_DisplayHeight > 0) + AndroidLogcatStatsColumn.Row(rc, kLabelWidth, ref y, Styles.DisplaySize, $"{m_DisplayWidth}x{m_DisplayHeight}"); + AndroidLogcatStatsColumn.Row(rc, kLabelWidth, ref y, Styles.StreamSize, $"{m_FrameWidth}x{m_FrameHeight}"); + AndroidLogcatStatsColumn.Row(rc, kLabelWidth, ref y, Styles.FrameRate, $"{m_Fps:0.0} fps"); + AndroidLogcatStatsColumn.Row(rc, kLabelWidth, ref y, Styles.Bandwidth, $"{m_Mbps:0.00} Mbps"); + // Listed whether or not it works: without the row there is nothing in the + // window to say the view is interactive at all. One row rather than separate + // Touch and Keyboard ones because the server reports a single capability + // covering both, so the two could never disagree. The column is too narrow + // for how to use them, so that lives in the tooltip. + AndroidLogcatStatsColumn.Row(rc, kLabelWidth, ref y, Styles.Input, + m_ControlSupported ? "Supported" : "Unsupported"); + + y += kNavigationSpacing; + DoNavigationGUI(rc, ref y); + DoRotationGUI(rc, ref y); + DoDebuggingGUI(rc, kLabelWidth, ref y); + } + + /// + /// Android's Back / Home / Overview buttons. + /// + /// On a device with the three-button navigation bar these are also just tappable + /// in the mirrored image, but on one using gesture navigation there is no bar to + /// tap - so without these there is no way to leave an app from the live view. + /// + /// + void DoNavigationGUI(Rect rc, ref float y) + { + var height = EditorGUIUtility.singleLineHeight; + if (y + height * 2 > rc.yMax) + return; + + GUI.Label(new Rect(rc.x, y, rc.width, height), Styles.NavigationKeys, EditorStyles.miniBoldLabel); + y += height; + + EditorGUI.BeginDisabledGroup(!CanSendInput); + + // Fixed width, rather than a third of the column each: these hold a single + // glyph, so stretching them to fill the column just looks wrong. Narrowed + // only if the column itself cannot fit three of them. Joined into one group, + // as the same row is in the Inputs window. + var width = Mathf.Floor(ButtonRowWidth(rc) / 3); + if (GUI.Button(new Rect(rc.x, y, width, height), Styles.Back, EditorStyles.miniButtonLeft)) + SendKeyPress(AndroidKeyCode.BACK); + if (GUI.Button(new Rect(rc.x + width, y, width, height), Styles.Home, EditorStyles.miniButtonMid)) + SendKeyPress(AndroidKeyCode.HOME); + if (GUI.Button(new Rect(rc.x + width * 2, y, width, height), Styles.Recents, EditorStyles.miniButtonRight)) + SendKeyPress(AndroidKeyCode.APP_SWITCH); + + EditorGUI.EndDisabledGroup(); + y += height; + } + + /// + /// Rotates the device the stream is coming from. Not an input event like the + /// navigation row above it - this goes through adb settings, so it works on a + /// device whose server cannot inject input, and it outlives the stream. + /// + void DoRotationGUI(Rect rc, ref float y) + { + var height = EditorGUIUtility.singleLineHeight; + y += kNavigationSpacing; + if (y + height * 2 > rc.yMax) + return; + + GUI.Label(new Rect(rc.x, y, rc.width, height), Styles.DeviceRotation, EditorStyles.miniBoldLabel); + y += height; + + EditorGUI.BeginDisabledGroup(m_Device == null); + + var rotations = Styles.Rotations; + var width = Mathf.Floor(ButtonRowWidth(rc) / rotations.Length); + for (var i = 0; i < rotations.Length; i++) + { + var style = i == 0 ? EditorStyles.miniButtonLeft + : i == rotations.Length - 1 ? EditorStyles.miniButtonRight + : EditorStyles.miniButtonMid; + // Auto is first and is -1, so the index is the rotation shifted by one. + if (GUI.Button(new Rect(rc.x + width * i, y, width, height), rotations[i], style)) + SetRotation((AndroidDeviceRotation)(i - 1)); + } + + EditorGUI.EndDisabledGroup(); + y += height; + } + + /// + /// How much of the column the navigation and rotation rows take, so the two + /// line up as one block of controls rather than two of different widths. + /// + static float ButtonRowWidth(Rect rc) + { + return Mathf.Min(kNavigationButtonWidth, Mathf.Floor(rc.width / 3)) * 3; + } + + void SetRotation(AndroidDeviceRotation rotation) + { + try + { + m_Device.SetRotation(rotation); + } + catch (Exception ex) + { + AndroidLogcatInternalLog.Log($"Failed to set rotation to {rotation}: {InnermostMessage(ex)}"); + } + } + + // ------------------------------------------------------------------ + // Touch forwarding + // ------------------------------------------------------------------ + + void HandleTouchInput(int controlId, Rect videoRect) + { + var e = Event.current; + + if (!CanSendInput) + { + // Control switched off, or the stream dropped, in the middle of a drag. + // The device still believes a finger is down, so let go of it - which + // deliberately bypasses the CanSendTouch gate that just failed. + if (m_TouchDown) + { + SendTouchAt(TouchAction.Cancel, videoRect, e.mousePosition); + ReleaseTouch(controlId); + } + return; + } + + switch (e.GetTypeForControl(controlId)) + { + case EventType.MouseDown: + if (e.button != 0 || !videoRect.Contains(e.mousePosition)) + break; + // Taking the hot control is what routes the rest of the drag here, + // including the part that happens outside the rect. + GUIUtility.hotControl = controlId; + // Also takes keyboard focus, so a click is all it takes before typing. + GUIUtility.keyboardControl = controlId; + m_TouchDown = true; + SendTouchAt(TouchAction.Down, videoRect, e.mousePosition); + e.Use(); + break; + + case EventType.MouseDrag: + if (!m_TouchDown) + break; + SendTouchAt(TouchAction.Move, videoRect, e.mousePosition); + e.Use(); + break; + + case EventType.MouseUp: + if (!m_TouchDown) + break; + SendTouchAt(TouchAction.Up, videoRect, e.mousePosition); + ReleaseTouch(controlId); + e.Use(); + break; + + case EventType.ScrollWheel: + // No focus or hot control needed: a wheel acts on whatever the + // pointer is over, on the device as much as in the Editor. + if (!videoRect.Contains(e.mousePosition)) + break; + SendScrollAt(videoRect, e.mousePosition, e.delta); + e.Use(); + break; + } + + // Losing the mouse mid-drag would otherwise leave the finger down for good. + if (m_TouchDown && e.type == EventType.MouseLeaveWindow) + { + SendTouchAt(TouchAction.Cancel, videoRect, e.mousePosition); + ReleaseTouch(controlId); + } + } + + void ReleaseTouch(int controlId) + { + m_TouchDown = false; + if (GUIUtility.hotControl == controlId) + GUIUtility.hotControl = 0; + } + + /// + /// Sends a touch at a position in normalized display coordinates, (0,0) being the + /// top left of the device screen. Does nothing unless the stream is up, the server + /// supports injection and control is enabled. + /// + internal void SendTouch(TouchAction action, float normalizedX, float normalizedY) + { + if (!CanSendInput) + return; + SendTouchMessage(action, normalizedX, normalizedY); + } + + /// + /// Sends a named key, e.g. . For typed + /// characters use instead, which handles layouts. + /// + internal void SendKey(KeyAction action, AndroidKeyCode keyCode, int metaState = 0) + { + if (!CanSendInput) + return; + SendKeyMessage(action, keyCode, metaState); + } + + /// + /// Presses and releases a key, for callers that have no press and release of + /// their own to mirror - a toolbar button, say. + /// + internal void SendKeyPress(AndroidKeyCode keyCode, int metaState = 0) + { + SendKey(KeyAction.Down, keyCode, metaState); + SendKey(KeyAction.Up, keyCode, metaState); + } + + /// + /// Sends a scroll at a position in normalized display coordinates. Magnitudes are + /// in wheel notches: positive vertical scrolls away from the user, positive + /// horizontal to the right. + /// + internal void SendScroll(float normalizedX, float normalizedY, + float horizontalNotches, float verticalNotches) + { + if (!CanSendInput) + return; + SendScrollMessage(normalizedX, normalizedY, horizontalNotches, verticalNotches); + } + + /// Types text on the device. + internal void SendText(string text) + { + if (!CanSendInput || string.IsNullOrEmpty(text)) + return; + SendTextMessage(text); + } + + void SendTouchAt(TouchAction action, Rect videoRect, Vector2 mousePosition) + { + var position = PositionOnScreen(videoRect, mousePosition); + SendTouchMessage(action, position.x, position.y); + } + + void SendScrollAt(Rect videoRect, Vector2 mousePosition, Vector2 delta) + { + var position = PositionOnScreen(videoRect, mousePosition); + + // Unity's scroll delta grows downward, where Android's VSCROLL is notches + // away from the user, so the vertical sign flips. Horizontal is passed + // through: both count rightward as positive. + SendScrollMessage(position.x, position.y, + delta.x / kUnityScrollLinesPerNotch, + -delta.y / kUnityScrollLinesPerNotch); + } + + /// + /// Where a mouse position falls on the device screen, 0..1. Clamped, not + /// rejected: a swipe that overshoots the edge of the view should still read as a + /// swipe to the edge of the screen. GUI y grows downward and so does the device + /// y, so there is nothing to flip. + /// + static Vector2 PositionOnScreen(Rect videoRect, Vector2 mousePosition) + { + return new Vector2( + Mathf.Clamp01((mousePosition.x - videoRect.x) / videoRect.width), + Mathf.Clamp01((mousePosition.y - videoRect.y) / videoRect.height)); + } + + void SendTouchMessage(TouchAction action, float x, float y) + { + var message = m_ControlMessage; + message[0] = (byte)ControlMessage.Touch; + message[1] = (byte)action; + message[2] = 0; // pointer id - a mouse is a single finger + WriteUInt16BE(message, 3, ToNormalized(x)); + WriteUInt16BE(message, 5, ToNormalized(y)); + // Full pressure. The server drops it to 0 for an Up by itself. + WriteUInt16BE(message, 7, (int)kNormalizedMax); + + SendControlMessage(message, 9, "touch"); + } + + void SendScrollMessage(float x, float y, float hScroll, float vScroll) + { + var h = ToScrollFixedPoint(hScroll); + var v = ToScrollFixedPoint(vScroll); + // Rounded away to nothing - a trackpad twitch, or a delta of zero on an axis + // the mouse does not have. The server would ignore it anyway. + if (h == 0 && v == 0) + return; + + var message = m_ControlMessage; + message[0] = (byte)ControlMessage.Scroll; + WriteUInt16BE(message, 1, ToNormalized(x)); + WriteUInt16BE(message, 3, ToNormalized(y)); + WriteUInt16BE(message, 5, h); + WriteUInt16BE(message, 7, v); + + SendControlMessage(message, 9, "scroll"); + } + + /// A position, 0..1, as the protocol carries it. + static int ToNormalized(float value) + { + return (int)Mathf.Round(value * kNormalizedMax); + } + + static short ToScrollFixedPoint(float notches) + { + return (short)Mathf.Clamp(Mathf.Round(notches * kScrollScale), + short.MinValue, short.MaxValue); + } + + void SendKeyMessage(KeyAction action, AndroidKeyCode keyCode, int metaState) + { + var message = m_ControlMessage; + message[0] = (byte)ControlMessage.Key; + message[1] = (byte)action; + WriteInt32BE(message, 2, (int)keyCode); + WriteInt32BE(message, 6, metaState); + + SendControlMessage(message, 10, "key"); + } + + void SendTextMessage(string text) + { + var bytes = Encoding.UTF8.GetBytes(text); + if (bytes.Length > kMaxTextBytes) + { + AndroidLogcatInternalLog.Log($"Not sending {bytes.Length} bytes of text, the limit is {kMaxTextBytes}"); + return; + } + + // Length prefixed, so the server stays in sync even on a message it decides + // to ignore. Allocated per message rather than reusing a buffer: this only + // happens on a keystroke or a paste. + var message = new byte[3 + bytes.Length]; + message[0] = (byte)ControlMessage.Text; + WriteUInt16BE(message, 1, bytes.Length); + Array.Copy(bytes, 0, message, 3, bytes.Length); + + SendControlMessage(message, message.Length, "text"); + } + + void SendControlMessage(byte[] message, int length, string what) + { + NetworkStream stream; + lock (m_ConnectionLock) + stream = m_Stream; + if (stream == null) + return; + + try + { + stream.Write(message, 0, length); + stream.Flush(); + } + catch (Exception ex) + { + // The reader thread watches the same connection and will report the + // failure properly, so this only needs to avoid throwing out of OnGUI - + // and to not log the same thing once per mouse move. + if (!m_ControlWriteFailed) + { + m_ControlWriteFailed = true; + AndroidLogcatInternalLog.Log($"Failed to send a {what} event: {ex.Message}"); + } + } + } + + static void WriteInt32BE(byte[] buffer, int offset, int value) + { + buffer[offset] = (byte)(value >> 24); + buffer[offset + 1] = (byte)(value >> 16); + buffer[offset + 2] = (byte)(value >> 8); + buffer[offset + 3] = (byte)value; + } + + // ------------------------------------------------------------------ + // Keyboard forwarding + // ------------------------------------------------------------------ + + void HandleKeyboardInput(int controlId) + { + if (!CanSendInput || GUIUtility.keyboardControl != controlId) + { + // Focus moved away, or the stream went down, with something held: let + // go of it rather than leaving the device holding shift or a key whose + // release we will never see. + SyncModifiers(EventModifiers.None); + ReleaseHeldKeys(); + return; + } + + var e = Event.current; + if (e.type != EventType.KeyDown && e.type != EventType.KeyUp) + return; + + // Before whatever this event turns into: the device holds a modifier for as + // long as the user does. + SyncModifiers(e.modifiers); + + // A key the device is holding is released on its own key-up, whatever the + // modifiers say by then. The user can let go of Ctrl before the C in Ctrl+C, + // and the paths below would no longer recognise that event. + if (e.type == EventType.KeyUp && m_HeldKeys.TryGetValue(e.keyCode, out var heldKeyCode)) + { + m_HeldKeys.Remove(e.keyCode); + SendKeyMessage(KeyAction.Up, heldKeyCode, MetaState(e.modifiers)); + e.Use(); + return; + } + + // Select all, copy and paste act on the device: they are text editing where + // the text is, and they do nothing in this window otherwise. The device's + // own clipboard is what is copied to and pasted from - nothing is exchanged + // with the Editor's clipboard. + if (TryMapEditingShortcut(e, out var editingKeyCode)) + { + // Forced to Ctrl even when the user pressed Cmd: Android has no Command + // modifier, and META_CTRL_ON is what a text field acts on. + SendKeyMessage(e.type == EventType.KeyDown ? KeyAction.Down : KeyAction.Up, + editingKeyCode, MetaState(e.modifiers) | kMetaCtrlOn); + if (e.type == EventType.KeyDown) + m_HeldKeys[e.keyCode] = editingKeyCode; + e.Use(); + return; + } + + // AltGr is reported as Ctrl+Alt on Windows, so a chord carrying a + // printable character is someone typing @ or a currency sign, not a shortcut. + if (e.type == EventType.KeyDown && IsPrintable(e.character) + && (e.modifiers & EventModifiers.Control) != 0 + && (e.modifiers & EventModifiers.Alt) != 0) + { + // The character already says what the layout produced, so the device + // must not be holding Alt when it arrives. + SyncModifiers(e.modifiers & EventModifiers.Shift); + SendTextMessage(e.character.ToString()); + e.Use(); + return; + } + + // Every other Editor shortcut keeps working: Ctrl/Cmd combinations are not + // forwarded, so Ctrl+S still saves rather than going to the device. + if ((e.modifiers & (EventModifiers.Control | EventModifiers.Command)) != 0) + return; + + if (TryMapKeyCode(e.keyCode, out var androidKeyCode)) + { + SendKeyMessage(e.type == EventType.KeyDown ? KeyAction.Down : KeyAction.Up, + androidKeyCode, MetaState(e.modifiers)); + if (e.type == EventType.KeyDown) + m_HeldKeys[e.keyCode] = androidKeyCode; + e.Use(); + return; + } + + // Anything printable goes as text rather than as a keycode. Unity reports a + // printable key twice - once with a keyCode and once with a character - and + // only the character knows about the keyboard layout, so letting the device + // work out the keystrokes from the character is what makes punctuation and + // non-US layouts come out right. + if (e.type == EventType.KeyDown && IsPrintable(e.character)) + { + SendTextMessage(e.character.ToString()); + e.Use(); + } + } + + static bool IsPrintable(char c) + { + return c != '\0' && !char.IsControl(c); + } + + /// + /// Releases every key the device is still holding. Used when the window stops + /// being the one the keyboard talks to, where the key-up that would have + /// released them is never delivered here. + /// + void ReleaseHeldKeys() + { + if (m_HeldKeys.Count == 0) + return; + + foreach (var keyCode in m_HeldKeys.Values) + SendKeyMessage(KeyAction.Up, keyCode, 0); + + m_HeldKeys.Clear(); + } + + /// + /// Presses and releases modifier keys on the device so that what it holds + /// matches what the user holds. A text field extends a selection while shift is + /// down and not merely named in a key's metaState - checked on a device - and + /// the modifier keys are forwarded as keys in their own right. + /// + /// Ctrl and Cmd are deliberately not among them: those chords stay with the + /// Editor apart from the three that are mapped, and pressing Ctrl on the device + /// every time someone saves a scene would be its own kind of surprise. + /// + /// + void SyncModifiers(EventModifiers modifiers) + { + var wanted = modifiers & (EventModifiers.Shift | EventModifiers.Alt); + var changed = wanted ^ m_HeldModifiers; + if (changed == EventModifiers.None) + return; + + m_HeldModifiers = wanted; + var meta = MetaState(wanted); + + if ((changed & EventModifiers.Shift) != 0) + { + SendKeyMessage((wanted & EventModifiers.Shift) != 0 ? KeyAction.Down : KeyAction.Up, + AndroidKeyCode.SHIFT_LEFT, meta); + } + + if ((changed & EventModifiers.Alt) != 0) + { + SendKeyMessage((wanted & EventModifiers.Alt) != 0 ? KeyAction.Down : KeyAction.Up, + AndroidKeyCode.ALT_LEFT, meta); + } + } + + /// + /// Named keys that have no character to type. Everything else - letters, digits, + /// punctuation - is left to the text path. + /// + static bool TryMapKeyCode(KeyCode keyCode, out AndroidKeyCode androidKeyCode) + { + switch (keyCode) + { + // Escape is the device's BACK rather than Android's ESCAPE: on a phone + // that is what "go back" means, and it is the reason to press it. + case KeyCode.Escape: androidKeyCode = AndroidKeyCode.BACK; return true; + case KeyCode.Return: + case KeyCode.KeypadEnter: androidKeyCode = AndroidKeyCode.ENTER; return true; + case KeyCode.Backspace: androidKeyCode = AndroidKeyCode.DEL; return true; + case KeyCode.Delete: androidKeyCode = AndroidKeyCode.FORWARD_DEL; return true; + case KeyCode.Tab: androidKeyCode = AndroidKeyCode.TAB; return true; + case KeyCode.UpArrow: androidKeyCode = AndroidKeyCode.DPAD_UP; return true; + case KeyCode.DownArrow: androidKeyCode = AndroidKeyCode.DPAD_DOWN; return true; + case KeyCode.LeftArrow: androidKeyCode = AndroidKeyCode.DPAD_LEFT; return true; + case KeyCode.RightArrow: androidKeyCode = AndroidKeyCode.DPAD_RIGHT; return true; + case KeyCode.Home: androidKeyCode = AndroidKeyCode.MOVE_HOME; return true; + case KeyCode.End: androidKeyCode = AndroidKeyCode.MOVE_END; return true; + case KeyCode.PageUp: androidKeyCode = AndroidKeyCode.PAGE_UP; return true; + case KeyCode.PageDown: androidKeyCode = AndroidKeyCode.PAGE_DOWN; return true; + default: androidKeyCode = default; return false; + } + } + + /// + /// The Ctrl/Cmd chords that are forwarded to the device rather than left to the + /// Editor: select all, copy and paste. Only the bare chord, so Ctrl+Shift+A and + /// anything with Alt still belong to the Editor. + /// + internal static bool TryMapEditingShortcut(Event e, out AndroidKeyCode androidKeyCode) + { + androidKeyCode = default; + + if ((e.modifiers & (EventModifiers.Control | EventModifiers.Command)) == 0) + return false; + if ((e.modifiers & (EventModifiers.Shift | EventModifiers.Alt)) != 0) + return false; + + switch (e.keyCode) + { + case KeyCode.A: androidKeyCode = AndroidKeyCode.A; return true; + case KeyCode.C: androidKeyCode = AndroidKeyCode.C; return true; + case KeyCode.V: androidKeyCode = AndroidKeyCode.V; return true; + default: return false; + } + } + + static int MetaState(EventModifiers modifiers) + { + var meta = 0; + if ((modifiers & EventModifiers.Shift) != 0) + meta |= kMetaShiftOn; + if ((modifiers & EventModifiers.Alt) != 0) + meta |= kMetaAltOn; + if ((modifiers & EventModifiers.Control) != 0) + meta |= kMetaCtrlOn; + return meta; + } + + /// + /// Opens the Android Logcat window showing only this server's process, the + /// equivalent of adb logcat --pid=<server pid>. Tag filtering is left + /// as the user set it. + /// + void ShowServerLogcat() + { + var window = AndroidLogcatConsoleWindow.ShowNewOrExisting(); + if (window == null) + return; + + // Logcat follows the runtime-wide device selection, so filtering by a process + // id means nothing without selecting the device that process is on first. + if (m_Device != null) + m_Runtime.DeviceQuery.SelectDevice(m_Device); + + window.FilterByProcessId(m_ServerPid); + } + + /// + /// Extra detail for diagnosing the stream, below the navigation buttons. Touch + /// support is not repeated here - the rows above already report it. + /// + void DoDebuggingGUI(Rect rc, float labelWidth, ref float y) + { + if (!Unsupported.IsDeveloperMode()) + return; + + var height = EditorGUIUtility.singleLineHeight; + y += kNavigationSpacing; + if (y + height > rc.yMax) + return; + + GUI.Label(new Rect(rc.x, y, rc.width, height), Styles.DeveloperMode, EditorStyles.miniBoldLabel); + y += height; + + AndroidLogcatStatsColumn.Row(rc, labelWidth, ref y, Styles.Socket, + string.IsNullOrEmpty(m_SocketName) ? "-" : m_SocketName, m_SocketName); + AndroidLogcatStatsColumn.Row(rc, labelWidth, ref y, Styles.ForwardedPort, + m_ForwardedPort > 0 ? m_ForwardedPort.ToString() : "-"); + AndroidLogcatStatsColumn.Row(rc, labelWidth, ref y, Styles.ServerOnDevice, + string.IsNullOrEmpty(m_ServerDevicePath) ? "-" : m_ServerDevicePath, m_ServerDevicePath); + AndroidLogcatStatsColumn.Row(rc, labelWidth, ref y, Styles.ServerPid, + m_ServerPid > 0 ? m_ServerPid.ToString() : "-"); + + if (y + height > rc.yMax) + return; + + // Everything the server logs goes to logcat as well, so there is no button + // for the copy the Editor captures from the adb shell - that copy is kept + // only because a server that dies before it has a pid leaves nothing for + // the Logcat window to filter on, and it ends up in Errors instead. + var buttonWidth = Mathf.Min(kDebugButtonWidth, rc.width); + + EditorGUI.BeginDisabledGroup(m_ServerPid <= 0 || m_Device == null); + if (GUI.Button(new Rect(rc.x, y, buttonWidth, height), + Styles.ShowServerLogcat, EditorStyles.miniButton)) + { + ShowServerLogcat(); + } + EditorGUI.EndDisabledGroup(); + + y += height; + + if (y + height > rc.yMax) + return; + + EditorGUI.BeginDisabledGroup(m_ServerPid <= 0 || m_Device == null); + if (GUI.Button(new Rect(rc.x, y, buttonWidth, height), + Styles.KillServer, EditorStyles.miniButton)) + { + KillServerOnDevice(); + } + EditorGUI.EndDisabledGroup(); + + y += height; + + if (y + height > rc.yMax) + return; + + var gradleProject = GetServerGradleProjectPath(); + EditorGUI.BeginDisabledGroup(gradleProject == null); + if (GUI.Button(new Rect(rc.x, y, buttonWidth, height), + Styles.RebuildJar, EditorStyles.miniButton)) + { + RebuildServerJar(gradleProject); + } + EditorGUI.EndDisabledGroup(); + + y += height; + } + + /// + /// The Gradle project that builds the server, which lives beside the package in + /// its own repository - <repo>/External/UnityLogcatServer - and not + /// at all in a package installed from a registry. Null when it is not there. + /// + static string GetServerGradleProjectPath() + { + var path = AndroidLogcatUtilities.ResolvePath("..", "External", "UnityLogcatServer"); + return path != null && File.Exists(Path.Combine(path, "build.gradle")) ? path : null; + } + + /// + /// Builds the server jar and, if a stream is up, restarts it so the device runs + /// the new one and the Logcat window follows it. Developer-mode only: it is the + /// edit-build-run loop for the server, which is otherwise a trip to a terminal. + /// + void RebuildServerJar(string gradleProject) + { + if (gradleProject == null) + return; + + if (!AndroidLogcatUtilities.RunGradle(gradleProject, "dexJar")) + return; + + var wasStreaming = IsStreaming; + UnityEngine.Debug.Log("Live stream server jar rebuilt" + + (wasStreaming ? ", restarting the stream" : "")); + + if (!wasStreaming) + return; + + RestartStreaming(m_Device); + // Armed after the restart, so that the state reset inside StartStreaming + // does not clear it, and only if that restart actually took: a stream that + // failed to start has no server to show, and Shutdown disarms this anyway. + m_ShowLogcatWhenServerStarts = IsStreaming; + } + + /// + /// Stops and starts the stream against the same device, keeping the caller's + /// completion callback. Does nothing when no stream is running. + /// + internal void RestartStreaming(IAndroidLogcatDevice device) + { + var onStopped = m_OnStopLiveStream; + StopStreaming(); + if (device != null) + StartStreaming(device, onStopped); + } + } +} diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatLiveStream.cs.meta b/com.unity.mobile.android-logcat/Editor/AndroidLogcatLiveStream.cs.meta new file mode 100644 index 00000000..4b2000c1 --- /dev/null +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatLiveStream.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c87b2c434792aae49951aacc50ba4e9b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatRuntime.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatRuntime.cs index 6fe9da39..3730e4fc 100644 --- a/com.unity.mobile.android-logcat/Editor/AndroidLogcatRuntime.cs +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatRuntime.cs @@ -13,8 +13,10 @@ internal abstract class AndroidLogcatRuntimeBase protected AndroidTools m_Tools; protected AndroidLogcatDeviceQueryBase m_DeviceQuery; protected AndroidLogcatCaptureScreenshot m_CaptureScreenshot; + protected AndroidLogcatCaptureScreenshot m_LayoutCaptureScreenshot; protected AndroidLogcatCaptureVideo m_CaptureVideo; protected AndroidLogcatQueryLayout m_QueryLayout; + protected AndroidLogcatLiveStream m_LiveSream; protected bool m_Initialized; protected abstract string UserSettingsPath { get; } @@ -60,6 +62,21 @@ public AndroidLogcatCaptureScreenshot CaptureScreenshot get { ValidateIsInitialized(); return m_CaptureScreenshot; } } + /// + /// The Layout Viewer's own capture, separate from + /// so that neither window's captures show up in - or replace what is on screen + /// in - the other. + /// + public AndroidLogcatCaptureScreenshot LayoutCaptureScreenshot + { + get { ValidateIsInitialized(); return m_LayoutCaptureScreenshot; } + } + + public AndroidLogcatLiveStream LiveStream + { + get { ValidateIsInitialized(); return m_LiveSream; } + } + public AndroidLogcatQueryLayout QueryLayout { get { ValidateIsInitialized(); return m_QueryLayout; } @@ -70,8 +87,9 @@ public AndroidLogcatQueryLayout QueryLayout protected abstract AndroidLogcatSettings LoadEditorSettings(); protected abstract AndroidTools CreateAndroidTools(); protected abstract AndroidLogcatCaptureVideo CreateScreenRecorder(); - protected abstract AndroidLogcatCaptureScreenshot CreateScreenCapture(); + protected abstract AndroidLogcatCaptureScreenshot CreateScreenCapture(string directory, bool keepHistory); protected abstract AndroidLogcatQueryLayout CreateQueryLayout(); + protected abstract AndroidLogcatLiveStream CreateLiveStream(); protected abstract void SaveEditorSettings(AndroidLogcatSettings settings); public virtual void Initialize() @@ -92,8 +110,10 @@ public virtual void Initialize() m_Tools = CreateAndroidTools(); m_DeviceQuery = CreateDeviceQuery(); m_CaptureVideo = CreateScreenRecorder(); - m_CaptureScreenshot = CreateScreenCapture(); + m_CaptureScreenshot = CreateScreenCapture(AndroidLogcatUtilities.GetScreenshotsDirectory(), true); + m_LayoutCaptureScreenshot = CreateScreenCapture(AndroidLogcatUtilities.GetLayoutViewerDirectory(), false); m_QueryLayout = CreateQueryLayout(); + m_LiveSream = CreateLiveStream(); m_Initialized = true; } @@ -170,9 +190,9 @@ protected override AndroidLogcatCaptureVideo CreateScreenRecorder() return new AndroidLogcatCaptureVideo(this); } - protected override AndroidLogcatCaptureScreenshot CreateScreenCapture() + protected override AndroidLogcatCaptureScreenshot CreateScreenCapture(string directory, bool keepHistory) { - return new AndroidLogcatCaptureScreenshot(this); + return new AndroidLogcatCaptureScreenshot(this, directory, keepHistory); } protected override AndroidLogcatQueryLayout CreateQueryLayout() @@ -180,6 +200,11 @@ protected override AndroidLogcatQueryLayout CreateQueryLayout() return new AndroidLogcatQueryLayout(this); } + protected override AndroidLogcatLiveStream CreateLiveStream() + { + return new AndroidLogcatLiveStream(this); + } + protected override AndroidLogcatSettings LoadEditorSettings() { return AndroidLogcatSettings.Load(); diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatScreenCaptureWindow.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatScreenCaptureWindow.cs index d011891a..98cd2360 100644 --- a/com.unity.mobile.android-logcat/Editor/AndroidLogcatScreenCaptureWindow.cs +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatScreenCaptureWindow.cs @@ -4,6 +4,7 @@ using UnityEditor; using System.Collections.Generic; using UnityEditor.IMGUI.Controls; +using UnityEditor.ShortcutManagement; namespace Unity.Android.Logcat { @@ -19,7 +20,8 @@ class Styles public static GUIContent ShowInfo = new GUIContent("Show Info", "Display video information."); public static GUIContent Open = new GUIContent("Open", "Open captured screenshot or video."); public static GUIContent SaveAs = new GUIContent("Save As", "Save captured screenshot or video."); - public static GUIContent CaptureScreenshot = new GUIContent("Capture", "Capture screenshot from the android device."); + public static GUIContent CaptureScreenshot = new GUIContent("Capture", + "Capture screenshot from the android device. Shortcut: Ctrl+Shift+S, Cmd+Shift+S on macOS."); public static GUIContent CaptureVideo = new GUIContent("Capture", "Record the video from the android device, click Stop afterwards to stop the recording."); public static GUIContent StopVideo = new GUIContent("Stop", "Stop the recording."); } @@ -31,14 +33,17 @@ internal enum Mode private AndroidLogcatRuntimeBase m_Runtime; private const int kButtonAreaHeight = 30; - private const int kBottomAreaHeight = 8; + private AndroidLogcatCaptureScreenshot m_CaptureScreenshot; private AndroidLogcatCaptureVideo m_CaptureVideo; private AndroidLogcatVideoPlayer m_VideoPlayer; + private AndroidLogcatLiveStream m_LiveStream; private AndroidLogcatDeviceSelection m_DeviceSelection; private IAndroidLogcatDevice m_LastDeviceUsedForAssets; + private AndroidLogcatScreenshotList m_ScreenshotList; + private bool IsCapturing { get @@ -46,7 +51,7 @@ private bool IsCapturing var mode = m_Runtime.UserSettings.CaptureSettings.Mode; switch (mode) { - case Mode.Screenshot: return m_CaptureScreenshot.IsCapturing; + case Mode.Screenshot: return m_CaptureScreenshot.IsCapturing || m_LiveStream.IsStreaming; case Mode.Video: return m_CaptureVideo.IsRecording; default: throw new NotImplementedException(mode.ToString()); @@ -61,7 +66,9 @@ private string TemporaryPath var mode = m_Runtime.UserSettings.CaptureSettings.Mode; switch (mode) { - case Mode.Screenshot: return m_CaptureScreenshot.GetImagePath(m_DeviceSelection.SelectedDevice); + // A live stream leaves no file behind, so there is nothing to open or + // save while its row is selected. + case Mode.Screenshot: return m_ScreenshotList.LiveSelected ? string.Empty : m_CaptureScreenshot.SelectedImagePath; case Mode.Video: return m_CaptureVideo.GetVideoPath(m_DeviceSelection.SelectedDevice); default: throw new NotImplementedException(mode.ToString()); @@ -69,14 +76,11 @@ private string TemporaryPath } } - private string ExtensionForDialog - { - get - { - return Path.GetExtension(TemporaryPath).Substring(1); - } - } - + // Alongside the Logcat window's own entry, and reachable without opening that + // window first - the Screen Capture window is useful on its own. A device with + // no Android support installed gets the same message here as anywhere else, from + // OnGUI, rather than the item being hidden. + [MenuItem("Window/Analysis/Android Screen Capture")] public static void ShowWindow() { GetWindow("Device Screen Capture"); @@ -92,7 +96,15 @@ private void OnEnable() m_Runtime.Closing += OnDisable; m_CaptureScreenshot = m_Runtime.CaptureScreenshot; m_CaptureVideo = m_Runtime.CaptureVideo; + m_LiveStream = m_Runtime.LiveStream; m_VideoPlayer = new AndroidLogcatVideoPlayer(); + m_ScreenshotList = new AndroidLogcatScreenshotList(m_Runtime, Repaint); + + // Settings saved while the removed LiveStream mode was selected still hold + // its value, which is now out of range and would throw in the switches above. + var captureSettings = m_Runtime.UserSettings.CaptureSettings; + if (!Enum.IsDefined(typeof(Mode), captureSettings.Mode)) + captureSettings.Mode = Mode.Screenshot; m_Runtime.DeviceQuery.UpdateConnectedDevicesList(true); } @@ -104,11 +116,20 @@ private void ReloadCaptureAssetsIfNeeded(IAndroidLogcatDevice device) m_LastDeviceUsedForAssets = device; m_VideoPlayer.Play(m_CaptureVideo.GetVideoPath(device)); - m_Runtime.CaptureScreenshot.LoadImage(m_Runtime.CaptureScreenshot.GetImagePath(device)); + + // The screenshots are not tied to a device, so losing one keeps the view. + if (string.IsNullOrEmpty(m_Runtime.CaptureScreenshot.SelectedImagePath)) + m_Runtime.CaptureScreenshot.LoadImage(m_Runtime.CaptureScreenshot.GetLatestImagePath(device)); + + m_ScreenshotList.OnDeviceChanged(m_DeviceSelection.SelectedDevice); } private void OnDisable() { + // The live stream is owned by the runtime, so it would otherwise keep + // mirroring the device after the window that was showing it is gone. + m_ScreenshotList?.Deselect(); + if (m_VideoPlayer != null) { m_VideoPlayer.Dispose(); @@ -129,8 +150,48 @@ private void QueueScreenCapture() m_CaptureScreenshot.QueueScreenCapture(m_DeviceSelection.SelectedDevice, OnScreenshotCompleted); } + /// + /// Ctrl+Shift+S, and Cmd+Shift+S on macOS - + /// is whichever of the two the platform uses. + /// + /// Scoped to this window rather than registered globally: the Editor's own + /// File > Save As sits on the same chord, and a window scoped shortcut takes + /// precedence over a global one only while its window has focus. It shows up in + /// Edit > Shortcuts under "Android Logcat", so it can be rebound there. + /// + /// + [Shortcut("Android Logcat/Capture Screenshot", typeof(AndroidLogcatScreenCaptureWindow), + KeyCode.S, ShortcutModifiers.Action | ShortcutModifiers.Shift)] + static void CaptureScreenshotShortcut(ShortcutArguments args) + { + var window = args.context as AndroidLogcatScreenCaptureWindow; + if (window != null) + window.CaptureScreenshotFromShortcut(); + } + + void CaptureScreenshotFromShortcut() + { + // The same conditions the Capture button draws itself with: it is disabled + // without a device and while a capture is in flight, and in Video mode it + // records video instead, which this shortcut is not for. + if (m_Runtime == null || m_DeviceSelection == null) + return; + if (m_Runtime.UserSettings.CaptureSettings.Mode != Mode.Screenshot) + return; + if (m_DeviceSelection.SelectedDevice == null || m_CaptureScreenshot.IsCapturing) + return; + + QueueScreenCapture(); + } + void OnScreenshotCompleted() { + // The image lands on disk while the capture is still running, and its + // details file only when the capture is integrated here. Selecting the row + // in between loads one without the other, and the preview would keep that + // for as long as the selection does not change. + m_ScreenshotList?.InvalidatePreview(); + var texture = m_CaptureScreenshot.ImageTexture; if (texture != null) maxSize = new Vector2(Math.Max(texture.width, position.width), texture.height + kButtonAreaHeight); @@ -145,7 +206,36 @@ void OnVideoCompleted(AndroidLogcatCaptureVideo.Result result, string videoPath) void DoModeGUI() { - m_Runtime.UserSettings.CaptureSettings.Mode = (Mode)EditorGUILayout.EnumPopup(m_Runtime.UserSettings.CaptureSettings.Mode, AndroidLogcatStyles.toolbarPopup); + var settings = m_Runtime.UserSettings.CaptureSettings; + var mode = (Mode)EditorGUILayout.EnumPopup(settings.Mode, AndroidLogcatStyles.toolbarPopup); + if (mode == settings.Mode) + return; + + settings.Mode = mode; + + // The list, and with it the Live row, is only drawn in Screenshot mode. + // Leaving that mode has to stop the stream, or the server carries on + // mirroring the device's display for a window that no longer shows it - + // and Video mode would happily start a recording alongside it. + if (mode != Mode.Screenshot) + m_ScreenshotList.Deselect(); + } + + /// + /// The screenshots folder is an ordinary directory that the user can add to, + /// delete from or overwrite behind the Editor's back. Nothing inside the Editor + /// can notice that, so the listing and the loaded image are both dropped when + /// this window comes back to the front - the moment someone is most likely to + /// have just been doing exactly that in a file browser. + /// + void OnFocus() + { + if (!AndroidBridge.AndroidExtensionsInstalled || m_Runtime == null) + return; + + m_CaptureScreenshot.InvalidateScreenshots(); + m_ScreenshotList?.InvalidatePreview(); + Repaint(); } void OnGUI() @@ -161,11 +251,8 @@ void OnGUI() DoToolbarGUI(); - GUILayout.Space(10); - if (m_DeviceSelection.SelectedDevice == null) - EditorGUILayout.HelpBox("No valid device selected.", MessageType.Info); - else - DoPreviewGUI(); + GUILayout.Space(5); + DoPreviewGUI(); EditorGUILayout.EndVertical(); } @@ -249,18 +336,7 @@ private void DoOpenGUI() { EditorGUI.BeginDisabledGroup(!File.Exists(TemporaryPath)); if (GUILayout.Button(Styles.Open, AndroidLogcatStyles.toolbarButton)) - { - switch (Application.platform) - { - case RuntimePlatform.OSXEditor: - System.Diagnostics.Process.Start("open", TemporaryPath); - break; - default: - Application.OpenURL(TemporaryPath); - break; - } - } - + AndroidLogcatUtilities.OpenFile(TemporaryPath); EditorGUI.EndDisabledGroup(); } @@ -269,40 +345,69 @@ private void DoSaveAsGUI() EditorGUI.BeginDisabledGroup(!File.Exists(TemporaryPath)); if (GUILayout.Button(Styles.SaveAs, AndroidLogcatStyles.toolbarButton)) { - var mode = m_Runtime.UserSettings.CaptureSettings.Mode; - var path = EditorUtility.SaveFilePanel( - "Save Screen Capture", - m_Runtime.UserSettings.CaptureSettings.GetLastSaveLocation(mode), - Path.GetFileName(TemporaryPath), - ExtensionForDialog); - if (!string.IsNullOrEmpty(path)) - { - try - { - m_Runtime.UserSettings.CaptureSettings.SetLastSaveLocation(mode, Path.GetFullPath(Path.GetDirectoryName(path))); - File.Copy(TemporaryPath, path, true); - } - catch (Exception ex) - { - UnityEngine.Debug.LogErrorFormat("Failed to save to '{0}' as '{1}'.", path, ex.Message); - } - } + var settings = m_Runtime.UserSettings.CaptureSettings; + settings.SaveFileAs(settings.Mode, TemporaryPath, "Save Screen Capture"); } EditorGUI.EndDisabledGroup(); } + /// + /// The list of saved screenshots on the left, the selected one on the right, a + /// draggable splitter between them. + /// + private void DoScreenshotGUI(Rect rc) + { + // Drawn with or without a device: these are files on this machine, and + // they outlive the device they came from. What needs a device - Capture, + // the live view - disables itself. + // The list draws itself and the splitter, and hands back what is left. + var imageRect = m_ScreenshotList.DoGUI(rc, m_DeviceSelection.SelectedDevice); + + if (m_ScreenshotList.LiveSelected) + { + // The developer-mode details are drawn by DoGUI, in the info column. + m_LiveStream.DoGUI(imageRect, m_DeviceSelection.SelectedDevice, Repaint); + // Frames arrive on the runtime's update, not on GUI events, so the window + // has to keep repainting to show them. + if (m_LiveStream.IsStreaming) + Repaint(); + } + // The list draws the image, not AndroidLogcatCaptureScreenshot: its texture + // is the last capture rather than the selected row. + else if (!m_ScreenshotList.DoPreviewGUI(imageRect)) + { + var message = m_DeviceSelection.SelectedDevice == null + ? "No screenshot to show. Select one from the list." + : "No screenshot to show. Select Capture to take one."; + EditorGUI.HelpBox(imageRect, message, MessageType.Info); + } + } + private void DoPreviewGUI() { switch (m_Runtime.UserSettings.CaptureSettings.Mode) { case Mode.Screenshot: { - var rc = new Rect(0, kButtonAreaHeight * 2, position.width, position.height - kButtonAreaHeight - kBottomAreaHeight); - if (!m_CaptureScreenshot.DoGUI(rc)) - EditorGUILayout.HelpBox("No screenshot to show, click Capture button.", MessageType.Info); + // Claimed from the layout rather than offset by a hardcoded + // toolbar height, which left a gap when the two disagreed. + var rc = GUILayoutUtility.GetRect(0, 0, + GUILayout.ExpandWidth(true), GUILayout.ExpandHeight(true)); + DoScreenshotGUI(rc); } break; case Mode.Video: + // Unlike the saved screenshots, a recording belongs to the device it + // was taken from and is kept per device, so there is nothing to show + // while none is selected. + if (m_DeviceSelection.SelectedDevice == null) + { + EditorGUILayout.HelpBox( + "No device selected. Connect a device, then select it from the device list.", + MessageType.Info); + break; + } + if (Unsupported.IsDeveloperMode()) m_CaptureVideo.DoDebuggingGUI(); DoVideoSettingsGUI(); @@ -323,6 +428,8 @@ private void DoPreviewGUI() Repaint(); } break; + default: + break; } } diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatScreenshotInfo.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatScreenshotInfo.cs new file mode 100644 index 00000000..2fd7290d --- /dev/null +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatScreenshotInfo.cs @@ -0,0 +1,219 @@ +using System; +using System.IO; +using UnityEngine; + +namespace Unity.Android.Logcat +{ + /// + /// What a screenshot cannot say about itself: the device it came from, and when. + /// Written beside the image as <name>.json when it is captured, and + /// moved and deleted with it. A screenshot that has none - captured before this + /// existed, or dropped into the folder by hand - shows Undefined instead. + /// + [Serializable] + internal class AndroidLogcatScreenshotInfo + { + internal const string kExtension = ".json"; + + // Bumped if the fields below stop meaning what they mean now, so a reader can + // tell an old file from an unreadable one. + internal const int kVersion = 1; + + public int version = kVersion; + public string capturedAt; + public string deviceId; + public string deviceName; + public string manufacturer; + public string model; + public string osVersion; + public int apiLevel; + public string abi; + public int displayWidth; + public int displayHeight; + + internal static string PathFor(string imagePath) + { + return string.IsNullOrEmpty(imagePath) ? null : Path.ChangeExtension(imagePath, kExtension); + } + + /// + /// Reads the device. Talks to adb for the display size, so this belongs on the + /// thread the capture itself runs on, not on the GUI's. + /// + internal static AndroidLogcatScreenshotInfo Create(IAndroidLogcatDevice device) + { + if (device == null) + return null; + + var displaySize = Vector2.zero; + try + { + device.QueryDisplaySize(out var physical, out var overriden); + displaySize = overriden ?? physical; + } + catch (Exception ex) + { + AndroidLogcatInternalLog.Log($"Failed to query display size: {ex.Message}"); + } + + var manufacturer = device.Manufacturer ?? string.Empty; + var model = device.Model ?? string.Empty; + + return new AndroidLogcatScreenshotInfo() + { + capturedAt = DateTime.Now.ToString("o"), + deviceId = device.Id, + deviceName = $"{manufacturer} {model}".Trim(), + manufacturer = manufacturer, + model = model, + osVersion = device.OSVersion?.ToString(), + apiLevel = device.APILevel, + abi = device.ABI, + displayWidth = (int)displaySize.x, + displayHeight = (int)displaySize.y + }; + } + + static bool IsDetails(AndroidLogcatScreenshotInfo info) + { + return info != null && info.version > 0 && !string.IsNullOrEmpty(info.capturedAt); + } + + static bool IsFree(string path) + { + if (!File.Exists(path)) + return true; + + try + { + return IsDetails(JsonUtility.FromJson(File.ReadAllText(path))); + } + catch (Exception) + { + // Not readable as ours, so certainly not ours. + return false; + } + } + + /// + /// Whether details could be written beside , for a + /// caller that has to know before it moves the image there. + /// + internal static bool CanWriteBeside(string imagePath) + { + var path = PathFor(imagePath); + return path == null || IsFree(path); + } + + static bool MayReplace(string path, string what) + { + if (IsFree(path)) + return true; + + UnityEngine.Debug.LogWarning($"The screenshot details were not {what}: '{path}' already " + + "exists and was not written by Android Logcat. Rename or remove that file if Android " + + "Logcat should manage it."); + return false; + } + + internal void Save(string imagePath) + { + var path = PathFor(imagePath); + if (!MayReplace(path, "saved")) + return; + + try + { + File.WriteAllText(path, JsonUtility.ToJson(this, true)); + } + catch (Exception ex) + { + // A screenshot without its details is still a screenshot, so this is + // logged rather than failing the capture. + UnityEngine.Debug.LogWarning($"Failed to write '{path}'.\n{ex.Message}"); + } + } + + /// Returns null when there is no file, or it cannot be read. + internal static AndroidLogcatScreenshotInfo Load(string imagePath) + { + var path = PathFor(imagePath); + if (string.IsNullOrEmpty(path) || !File.Exists(path)) + return null; + + try + { + var info = JsonUtility.FromJson(File.ReadAllText(path)); + // A file of someone else's that shares the name is not details, and + // its defaults would be shown as a device of empty strings and zeroes. + return IsDetails(info) ? info : null; + } + catch (Exception ex) + { + AndroidLogcatInternalLog.Log($"Failed to read '{path}': {ex.Message}"); + return null; + } + } + + internal static void Move(string fromImagePath, string toImagePath) + { + if (!Both(fromImagePath, toImagePath, out var from, out var to)) + return; + if (!MayReplace(to, "moved")) + return; + + try + { + File.Delete(to); + File.Move(from, to); + } + catch (Exception ex) + { + UnityEngine.Debug.LogWarning($"Failed to move '{from}' to '{to}'.\n{ex.Message}"); + } + } + + /// Takes the details along to a copy of the image saved elsewhere. + internal static void CopyBeside(string fromImagePath, string toImagePath) + { + if (!Both(fromImagePath, toImagePath, out var from, out var to)) + return; + if (!MayReplace(to, "copied")) + return; + + try + { + File.Copy(from, to, true); + } + catch (Exception ex) + { + UnityEngine.Debug.LogWarning($"Failed to copy '{from}' to '{to}'.\n{ex.Message}"); + } + } + + static bool Both(string fromImagePath, string toImagePath, out string from, out string to) + { + from = PathFor(fromImagePath); + to = PathFor(toImagePath); + return from != null && to != null && from != to && File.Exists(from); + } + + internal static void Delete(string imagePath) + { + var path = PathFor(imagePath); + if (path == null || !File.Exists(path)) + return; + if (!MayReplace(path, "removed")) + return; + + try + { + File.Delete(path); + } + catch (Exception ex) + { + UnityEngine.Debug.LogWarning($"Failed to delete '{path}'.\n{ex.Message}"); + } + } + } +} diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatScreenshotInfo.cs.meta b/com.unity.mobile.android-logcat/Editor/AndroidLogcatScreenshotInfo.cs.meta new file mode 100644 index 00000000..0d77b111 --- /dev/null +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatScreenshotInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: eb45dfea200f4f63a69a5427a68b6317 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatScreenshotList.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatScreenshotList.cs new file mode 100644 index 00000000..b339800a --- /dev/null +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatScreenshotList.cs @@ -0,0 +1,777 @@ +using System; +using System.Collections.Generic; +using System.IO; +using UnityEditor; +using UnityEngine; + +namespace Unity.Android.Logcat +{ + /// + /// The list of saved screenshots, with the live stream as its first row, and the + /// splitter that separates it from whatever is being shown on the right. + /// + /// One per window rather than one per runtime: the scroll position, the splitter + /// width and which row is selected are all view state, while + /// is runtime-wide. Files, numbering + /// and the image itself stay there; this only decides what to look at. + /// + /// + internal class AndroidLogcatScreenshotList + { + static class Styles + { + internal static readonly GUIContent LiveRow = new GUIContent("Live", + "Show the device screen live. Streaming stops when another row is selected. " + + "Right click to reconnect."); + internal static readonly GUIContent Delete = new GUIContent("×", + "Delete this screenshot from disk"); + + internal static readonly GUIContent Device = new GUIContent("Device", + "The device the screenshot was captured from, as its details file records it."); + internal static readonly GUIContent OS = new GUIContent("OS", + "The Android version the device was running."); + internal static readonly GUIContent DisplaySize = new GUIContent("Display Size", + "The device's display resolution at the time, which is not the image size when the display was rotated or its size overridden."); + internal static readonly GUIContent ImageSize = new GUIContent("Image Size", + "Size of the image in pixels, which is the resolution of the display it was captured from."); + internal static readonly GUIContent FileSize = new GUIContent("File Size", + "Size of the file on disk."); + internal static readonly GUIContent Captured = new GUIContent("Captured", + "When the file was last written."); + + // The selected row draws on a coloured background, where the default label + // colour is hard to read. + static GUIStyle s_SelectedRow; + internal static GUIStyle SelectedRow + { + get + { + if (s_SelectedRow == null) + { + s_SelectedRow = new GUIStyle(EditorStyles.label); + s_SelectedRow.normal.textColor = Color.white; + } + return s_SelectedRow; + } + } + } + + internal const float kDefaultWidth = 220; + const float kMinWidth = 150; + const float kMaxWidth = 400; + const float kSplitterWidth = 5; + const float kMinPreviewWidth = 100; + const float kScrollbarWidth = 16; + const float kDeleteButtonWidth = 18; + const float kDeleteButtonMargin = 2; + // Air between the Live row and the saved screenshots, with the line in it. + const float kGroupGap = 5; + + readonly AndroidLogcatRuntimeBase m_Runtime; + readonly AndroidLogcatCaptureScreenshot m_CaptureScreenshot; + readonly AndroidLogcatLiveStream m_LiveStream; + readonly Action m_Repaint; + + const string kRenameControlName = "ScreenshotRenameField"; + const string kUndefined = "Undefined"; + + readonly Splitter m_Splitter = new Splitter(Splitter.SplitterType.Horizontal, kMinWidth, kMaxWidth); + Vector2 m_Scroll; + bool m_LiveSelected; + // Whether the one-off "what should this window open on" decision has been made. + bool m_InitialSelectionDone; + + // Which row is being renamed, and the text so far. The field is focused once, + // the frame after it first appears. + string m_RenamingPath; + string m_RenameText; + bool m_RenameNeedsFocus; + // The list's own control id, remembered so that focus can go back to it once a + // rename ends - otherwise the keys would need another click to work again. + int m_ListControlId; + + // The selected screenshot, drawn by this window and nothing else. It is + // deliberately not AndroidLogcatCaptureScreenshot's texture: that one is the + // last capture, and swapping it for a screenshot picked out of this list would + // lose it. Only the selected path is shared. + Texture2D m_PreviewTexture; + string m_PreviewPath; + PreviewDetails m_PreviewDetails; + + // Zoom and pan for the preview. Its own, separate from the live view's: they + // show different things, and a zoom set on one is rarely the one wanted on the + // other. Kept across screenshots, though - screenshots from the same device are + // the same size, so comparing two of them at the same zoom is the point. + readonly AndroidLogcatImageViewer m_Viewer = new AndroidLogcatImageViewer(); + + /// + /// Whether the Live row is the selected one, so the caller knows to show the + /// stream rather than an image, and that there is no file to open or save. + /// + internal bool LiveSelected => m_LiveSelected; + + internal AndroidLogcatScreenshotList(AndroidLogcatRuntimeBase runtime, Action repaint) + { + m_Runtime = runtime; + m_CaptureScreenshot = runtime.CaptureScreenshot; + m_LiveStream = runtime.LiveStream; + m_Repaint = repaint; + + // Settings saved before the width existed deserialize it as 0, which would + // collapse the list to nothing. + var settings = m_Runtime.UserSettings.CaptureSettings; + if (settings.ScreenshotListWidth < kMinWidth) + settings.ScreenshotListWidth = kDefaultWidth; + } + + /// + /// Stops the stream and drops the selection, for a window that is going away or + /// has switched to a mode that does not show this list. The initial selection is + /// forgotten with it, so coming back decides what to open on again. + /// + internal void Deselect() + { + if (m_LiveSelected) + m_LiveStream.StopStreaming(); + m_LiveSelected = false; + m_InitialSelectionDone = false; + DestroyPreview(); + } + + /// + /// Forgets the loaded preview, so the next pass reads it from disk again even + /// though the selected path has not changed. The file behind that path can have + /// been replaced while the Editor was not looking. + /// + internal void InvalidatePreview() + { + DestroyPreview(); + } + + /// + /// Draws the selected screenshot, or the last capture's error if there is one. + /// Returns false when there is nothing to show, so the caller can say so. + /// + internal bool DoPreviewGUI(Rect rc) + { + var error = m_CaptureScreenshot.Error; + if (!string.IsNullOrEmpty(error)) + { + EditorGUI.HelpBox(rc, error, MessageType.Error); + return true; + } + + if (m_PreviewTexture == null) + return false; + + // The same column the live view draws, so the two modes look alike. Taken + // out of the area before the image is fitted, or the image would be drawn + // underneath it, and sized to its text, or a device name is cut in half. + var statsWidth = AndroidLogcatStatsColumn.WidthFor(rc, m_PreviewDetails.Values); + var imageArea = new Rect(rc.x, rc.y, Mathf.Max(0, rc.width - statsWidth), rc.height); + + var imageBox = m_Viewer.DoGUI(imageArea, + (float)m_PreviewTexture.width / m_PreviewTexture.height, + imageRect => GUI.DrawTexture(imageRect, m_PreviewTexture), m_Repaint); + + DoStatsGUI(AndroidLogcatStatsColumn.RectBeside(rc, imageBox)); + return true; + } + + void DoStatsGUI(Rect rc) + { + const float kLabelWidth = AndroidLogcatStatsColumn.kLabelWidth; + var y = rc.y; + var details = m_PreviewDetails; + + AndroidLogcatStatsColumn.Row(rc, kLabelWidth, ref y, Styles.Device, details.Device, details.DeviceId); + AndroidLogcatStatsColumn.Row(rc, kLabelWidth, ref y, Styles.OS, details.OS); + AndroidLogcatStatsColumn.Row(rc, kLabelWidth, ref y, Styles.DisplaySize, details.DisplaySize); + AndroidLogcatStatsColumn.Row(rc, kLabelWidth, ref y, Styles.ImageSize, details.ImageSize); + AndroidLogcatStatsColumn.Row(rc, kLabelWidth, ref y, Styles.FileSize, details.FileSize); + AndroidLogcatStatsColumn.Row(rc, kLabelWidth, ref y, Styles.Captured, details.Captured, details.CapturedInFull); + } + + /// + /// What the details column says about the selected screenshot, worked out when + /// it is loaded: the column is measured against these before the image is + /// fitted, and none of it changes while the same screenshot is shown. + /// + class PreviewDetails + { + internal string Device { get; } + internal string DeviceId { get; } + internal string OS { get; } + internal string DisplaySize { get; } + internal string ImageSize { get; } + internal string FileSize { get; } + internal string Captured { get; } + internal string CapturedInFull { get; } + internal string[] Values { get; } + + internal PreviewDetails(Texture2D texture, FileInfo file, AndroidLogcatScreenshotInfo info) + { + Device = info == null || string.IsNullOrEmpty(info.deviceName) ? kUndefined : info.deviceName; + DeviceId = info?.deviceId; + OS = info == null ? kUndefined : OperatingSystem(info); + DisplaySize = info == null || info.displayWidth <= 0 + ? kUndefined + : $"{info.displayWidth}x{info.displayHeight}"; + ImageSize = $"{texture.width}x{texture.height}"; + FileSize = EditorUtility.FormatBytes(file.Length); + Captured = file.LastWriteTime.ToString("g"); + CapturedInFull = file.LastWriteTime.ToString("F"); + Values = new[] { Device, OS, DisplaySize, ImageSize, FileSize, Captured }; + } + } + + static string OperatingSystem(AndroidLogcatScreenshotInfo info) + { + if (string.IsNullOrEmpty(info.osVersion)) + return info.apiLevel > 0 ? $"API {info.apiLevel}" : kUndefined; + return info.apiLevel > 0 ? $"Android {info.osVersion} (API {info.apiLevel})" : $"Android {info.osVersion}"; + } + + /// + /// Loads whatever the selection points at, if it is not already loaded. Called + /// every pass rather than from each place that can change the selection - a + /// capture landing, a delete, a rename - so there is one path to get wrong + /// instead of four. + /// + void SyncPreview() + { + var path = m_CaptureScreenshot.SelectedImagePath; + if (path == m_PreviewPath) + return; + + DestroyPreview(); + m_PreviewPath = path; + + if (string.IsNullOrEmpty(path) || !File.Exists(path)) + return; + + var texture = new Texture2D(2, 2); + if (texture.LoadImage(File.ReadAllBytes(path))) + { + m_PreviewTexture = texture; + m_PreviewDetails = new PreviewDetails(texture, new FileInfo(path), + AndroidLogcatScreenshotInfo.Load(path)); + } + else + { + UnityEngine.Object.DestroyImmediate(texture); + } + } + + void DestroyPreview() + { + if (m_PreviewTexture != null) + UnityEngine.Object.DestroyImmediate(m_PreviewTexture); + m_PreviewTexture = null; + m_PreviewPath = null; + m_PreviewDetails = null; + } + + /// + /// A stream belongs to the device it was started on, so it has to be restarted + /// against a new one. + /// + internal void OnDeviceChanged(IAndroidLogcatDevice device) + { + if (m_LiveSelected) + m_LiveStream.RestartStreaming(device); + } + + /// + /// Draws the list and the splitter, and returns what is left for the caller to + /// draw the image or the stream into. + /// + internal Rect DoGUI(Rect rc, IAndroidLogcatDevice device) + { + var settings = m_Runtime.UserSettings.CaptureSettings; + var width = Mathf.Min(settings.ScreenshotListWidth, + Mathf.Max(0, rc.width - kSplitterWidth - kMinPreviewWidth)); + + var listRect = new Rect(rc.x, rc.y, width, rc.height); + var splitterRect = new Rect(listRect.xMax, rc.y, kSplitterWidth, rc.height); + + DoListGUI(listRect, device); + + if (m_Splitter.DoGUI(splitterRect, ref width)) + { + settings.ScreenshotListWidth = width; + m_Repaint(); + } + + return new Rect(splitterRect.xMax, rc.y, Mathf.Max(0, rc.xMax - splitterRect.xMax), rc.height); + } + + void DoListGUI(Rect rc, IAndroidLogcatDevice device) + { + // Allocated on every pass, before any early return, so control ids do not + // shift between the Layout and Repaint passes. + var controlId = GUIUtility.GetControlID(FocusType.Keyboard); + m_ListControlId = controlId; + + GUI.Box(rc, GUIContent.none, EditorStyles.helpBox); + + // Every device, not just the selected one: a screenshot is worth looking at + // whichever device it came from, and the file name says which that was. + var screenshots = m_CaptureScreenshot.GetScreenshots(); + + SyncPreview(); + + // Row 0 is the live stream, the rest are saved screenshots. + var rowCount = screenshots.Count + 1; + var selectedRow = m_LiveSelected ? 0 : -1; + if (!m_LiveSelected) + { + var selectedPath = m_CaptureScreenshot.SelectedImagePath; + for (var i = 0; i < screenshots.Count; i++) + { + if (screenshots[i].Path == selectedPath) + { + selectedRow = i + 1; + break; + } + } + } + + // With nothing selected - an empty list, or a domain reload, which does not + // remember the selected screenshot - open on the live view rather than on an + // empty pane. Once per window: deleting the last screenshot deliberately + // leaves nothing selected rather than starting a stream. + if (!m_InitialSelectionDone) + { + m_InitialSelectionDone = true; + if (selectedRow < 0) + { + SelectRow(screenshots, 0, device); + selectedRow = 0; + } + } + + var rowHeight = EditorGUIUtility.singleLineHeight; + var inner = new Rect(rc.x + 1, rc.y + 1, rc.width - 2, rc.height - 2); + + // Room for the scrollbar is reserved only when there will be one. Reserving + // it unconditionally leaves a dead strip that pushes the delete buttons away + // from the right edge. + var contentHeight = rowCount * rowHeight + (screenshots.Count > 0 ? kGroupGap : 0); + var scrollbarWidth = contentHeight > inner.height ? kScrollbarWidth : 0; + var content = new Rect(0, 0, inner.width - scrollbarWidth, contentHeight); + var hasFocus = GUIUtility.keyboardControl == controlId; + + // Acted on after the loop: deleting invalidates the cached list that is being + // iterated here, and a menu has to be positioned in window coordinates rather + // than the scroll view's. + string deletePath = null; + var deleteRow = -1; + var menuRow = -1; + string menuPath = null; + var menuScreenPosition = Vector2.zero; + + m_Scroll = GUI.BeginScrollView(inner, m_Scroll, content); + for (var row = 0; row < rowCount; row++) + { + var rowRect = new Rect(0, RowTop(row, rowHeight), content.width, rowHeight); + var isSelected = row == selectedRow; + + if (Event.current.type == EventType.Repaint && isSelected) + { + // Dimmer when the list is not focused, the way editor lists behave. + EditorGUI.DrawRect(rowRect, hasFocus + ? new Color(0.24f, 0.48f, 0.90f, 0.85f) + : new Color(0.30f, 0.30f, 0.30f, 0.85f)); + } + + // The Live row has no file behind it, so nothing to delete. + var deleteWidth = row == 0 ? 0 : kDeleteButtonWidth + kDeleteButtonMargin * 2; + var labelRect = new Rect(rowRect.x + 4, rowRect.y, + Mathf.Max(0, rowRect.width - 4 - deleteWidth), rowRect.height); + + if (row > 0 && screenshots[row - 1].Path == m_RenamingPath) + { + DoRenameFieldGUI(labelRect); + } + else + { + // Tooltip relative to the project, because the absolute path is + // mostly project folder and covers the rows around it. + var label = row == 0 + ? Styles.LiveRow + : new GUIContent(screenshots[row - 1].Name, + AndroidLogcatUtilities.ProjectRelativePath(screenshots[row - 1].Path)); + var style = isSelected ? Styles.SelectedRow : EditorStyles.label; + GUI.Label(labelRect, label, style); + } + + if (deleteWidth > 0) + { + // Inset by a pixel top and bottom so the button does not touch the + // rows above and below it. + var deleteRect = new Rect( + rowRect.xMax - kDeleteButtonWidth - kDeleteButtonMargin, + rowRect.y + 1, + kDeleteButtonWidth, + rowRect.height - 2); + if (GUI.Button(deleteRect, Styles.Delete, EditorStyles.miniButton)) + { + deletePath = screenshots[row - 1].Path; + deleteRow = row; + } + } + + // Hit tested against the label rather than the whole row, so that the + // delete button does not also change the selection. Skipped while this + // row is being renamed, so clicking into the text field does not count + // as selecting the row. + if (Event.current.type == EventType.MouseDown && Event.current.button == 0 + && labelRect.Contains(Event.current.mousePosition) + && (row == 0 || screenshots[row - 1].Path != m_RenamingPath)) + { + GUIUtility.keyboardControl = controlId; + SelectRow(screenshots, row, device); + + // The Live row has no file to open. + if (Event.current.clickCount == 2 && row > 0) + AndroidLogcatUtilities.OpenFile(screenshots[row - 1].Path); + + Event.current.Use(); + } + + if (Event.current.type == EventType.ContextClick + && rowRect.Contains(Event.current.mousePosition)) + { + // Selected as well, so the menu acts on what is now on screen. + GUIUtility.keyboardControl = controlId; + SelectRow(screenshots, row, device); + + menuRow = row; + menuPath = row == 0 ? null : screenshots[row - 1].Path; + // Captured in screen space: inside the scroll view the mouse position + // is in content coordinates, which the menu would misplace. + menuScreenPosition = GUIUtility.GUIToScreenPoint(Event.current.mousePosition); + Event.current.Use(); + } + } + + // The Live row is not one of the saved screenshots, and a list that runs + // them together reads as though it were. + if (screenshots.Count > 0 && Event.current.type == EventType.Repaint) + { + var separator = new Rect(kGroupGap, rowHeight + Mathf.Floor(kGroupGap * 0.5f), + Mathf.Max(0, content.width - kGroupGap * 2), 1); + EditorGUI.DrawRect(separator, EditorGUIUtility.isProSkin + ? new Color(1, 1, 1, 0.12f) + : new Color(0, 0, 0, 0.2f)); + } + + GUI.EndScrollView(); + + HandleKeys(controlId, screenshots, rowCount, selectedRow, rowHeight, inner.height, device); + + if (menuRow == 0) + ShowLiveRowContextMenu(GUIUtility.ScreenToGUIPoint(menuScreenPosition), device); + else if (menuRow > 0) + ShowRowContextMenu(menuPath, GUIUtility.ScreenToGUIPoint(menuScreenPosition)); + + if (deletePath != null) + ConfirmAndDelete(deletePath, deleteRow, device); + } + + /// + /// The Live row has no file behind it, so all it offers is starting the stream + /// over - a server that died, or a device that went away and came back, otherwise + /// needs the selection moved off the row and back onto it. + /// + void ShowLiveRowContextMenu(Vector2 position, IAndroidLogcatDevice device) + { + var menu = new AndroidContextMenu(); + // The device travels in the menu item, because the menu is answered long + // after this method has returned - the same reason the screenshot rows put + // their path there. Named argument: the third positional parameter of Add + // is `selected`, not `enabled`, and reconnecting without a device to + // reconnect to does nothing. + menu.Add(ScreenshotContextMenu.Reconnect, "Reconnect", + enabled: device != null, userData: device); + menu.Show(position, OnContextMenuSelection); + } + + void ShowRowContextMenu(string path, Vector2 position) + { + var menu = new AndroidContextMenu(); + menu.Add(ScreenshotContextMenu.ShowInFileBrowser, + AndroidLogcatUtilities.RevealInFileBrowserLabel, userData: path); + menu.Add(ScreenshotContextMenu.Open, "Open", userData: path); + menu.Add(ScreenshotContextMenu.SaveAs, "Save As...", userData: path); + menu.Add(ScreenshotContextMenu.Rename, "Rename", userData: path); + menu.Show(position, OnContextMenuSelection); + } + + /// + /// The row's label replaced by a text field. Enter commits, Escape cancels, and + /// losing focus commits as well - clicking away is not a reason to throw the name + /// the user typed away. + /// + void DoRenameFieldGUI(Rect rc) + { + var e = Event.current; + if (e.type == EventType.KeyDown) + { + if (e.keyCode == KeyCode.Return || e.keyCode == KeyCode.KeypadEnter) + { + CommitRename(); + e.Use(); + return; + } + if (e.keyCode == KeyCode.Escape) + { + CancelRename(); + e.Use(); + return; + } + } + + GUI.SetNextControlName(kRenameControlName); + m_RenameText = EditorGUI.TextField(rc, m_RenameText); + + if (m_RenameNeedsFocus) + { + // Has to happen after the field exists, so a frame later than the menu. + GUI.FocusControl(kRenameControlName); + m_RenameNeedsFocus = false; + } + else if (GUI.GetNameOfFocusedControl() != kRenameControlName) + { + CommitRename(); + } + } + + void BeginRename(string path) + { + m_RenamingPath = path; + m_RenameText = Path.GetFileNameWithoutExtension(path); + m_RenameNeedsFocus = true; + m_Repaint(); + } + + void CommitRename() + { + var path = m_RenamingPath; + var name = m_RenameText; + CancelRename(); + + if (path == null) + return; + + // An unchanged or unusable name is not an error; the row just goes back to + // showing what it showed before. + m_CaptureScreenshot.RenameScreenshot(path, name); + m_Repaint(); + } + + void CancelRename() + { + m_RenamingPath = null; + m_RenameText = null; + m_RenameNeedsFocus = false; + if (GUI.GetNameOfFocusedControl() == kRenameControlName) + GUIUtility.keyboardControl = m_ListControlId; + } + + void OnContextMenuSelection(object userData, string[] options, int selected) + { + var menu = (AndroidContextMenu)userData; + var item = menu.GetItemAt(selected); + if (item == null) + return; + + // What UserData holds depends on the item: a path for the screenshot rows, + // the device for the Live row. + switch (item.Item) + { + case ScreenshotContextMenu.ShowInFileBrowser: + AndroidLogcatUtilities.RevealInFileBrowser((string)item.UserData); + break; + case ScreenshotContextMenu.Open: + AndroidLogcatUtilities.OpenFile((string)item.UserData); + break; + case ScreenshotContextMenu.SaveAs: + SaveAs((string)item.UserData); + break; + case ScreenshotContextMenu.Rename: + BeginRename((string)item.UserData); + break; + case ScreenshotContextMenu.Reconnect: + // The context click selected the row, so the stream is this window's + // to restart by the time this runs. + m_LiveStream.RestartStreaming((IAndroidLogcatDevice)item.UserData); + m_Repaint(); + break; + } + } + + void SaveAs(string path) + { + // Screenshots are always saved under the Screenshot mode's remembered + // location, whatever mode the window happens to be in. + m_Runtime.UserSettings.CaptureSettings.SaveFileAs( + AndroidLogcatScreenCaptureWindow.Mode.Screenshot, path, "Save Screenshot"); + } + + /// + /// F2 everywhere, and Enter as well on macOS - the same bindings the Project + /// window uses, so whichever one the user reaches for works. + /// + static bool IsRenameShortcut(Event e) + { + if (e.keyCode == KeyCode.F2) + return true; + return Application.platform == RuntimePlatform.OSXEditor + && (e.keyCode == KeyCode.Return || e.keyCode == KeyCode.KeypadEnter); + } + + /// + /// Delete everywhere, and Command+Backspace on macOS, where compact keyboards + /// have no forward delete key - again what the Project window takes. + /// + static bool IsDeleteShortcut(Event e) + { + if (e.keyCode == KeyCode.Delete) + return true; + return Application.platform == RuntimePlatform.OSXEditor + && e.keyCode == KeyCode.Backspace && e.command; + } + + /// + /// Up and Down cycle through the list once it has focus, F2 renames and Delete + /// deletes. + /// + void HandleKeys(int controlId, IReadOnlyList screenshots, + int rowCount, int selectedRow, float rowHeight, float viewHeight, IAndroidLogcatDevice device) + { + // While the rename field has focus it owns the keyboard, so none of this runs. + if (GUIUtility.keyboardControl != controlId || Event.current.type != EventType.KeyDown) + return; + + if (IsRenameShortcut(Event.current)) + { + // Row 0 is the live stream, which has no file to rename. + if (selectedRow > 0) + BeginRename(screenshots[selectedRow - 1].Path); + Event.current.Use(); + return; + } + + if (IsDeleteShortcut(Event.current)) + { + // Used before the dialog, which pumps its own events. + Event.current.Use(); + // Row 0 is the live stream, which has no file to delete. Same + // confirmation as the row's own button, and it runs from the same place + // in the frame - after the scroll view has closed. + if (selectedRow > 0) + ConfirmAndDelete(screenshots[selectedRow - 1].Path, selectedRow, device); + return; + } + + var delta = 0; + switch (Event.current.keyCode) + { + case KeyCode.UpArrow: delta = -1; break; + case KeyCode.DownArrow: delta = 1; break; + case KeyCode.Home: delta = -rowCount; break; + case KeyCode.End: delta = rowCount; break; + default: return; + } + + // No selection yet: Down starts at the top, Up at the bottom. + var next = selectedRow < 0 + ? (delta > 0 ? 0 : rowCount - 1) + : Mathf.Clamp(selectedRow + delta, 0, rowCount - 1); + + if (next != selectedRow) + { + SelectRow(screenshots, next, device); + ScrollIntoView(next, rowHeight, viewHeight); + } + Event.current.Use(); + } + + /// + /// Row 0 shows the live stream, the rest a saved screenshot. Streaming starts and + /// stops with the selection rather than needing its own button, so leaving the + /// Live row does not leave the device mirroring for nothing. + /// + void SelectRow(IReadOnlyList screenshots, int row, + IAndroidLogcatDevice device) + { + if (row == 0) + { + if (!m_LiveSelected) + { + m_LiveSelected = true; + m_LiveStream.RestartStreaming(device); + } + } + else + { + if (m_LiveSelected) + { + m_LiveSelected = false; + m_LiveStream.StopStreaming(); + } + m_CaptureScreenshot.SelectImage(screenshots[row - 1].Path); + } + m_Repaint(); + } + + /// + /// Deleting is confirmed first: the button sits next to the row one clicks to + /// select it, and the file is gone for good afterwards. The deletion itself is + /// AndroidLogcatCaptureScreenshot.DeleteScreenshot; what belongs here is the + /// prompt and picking what to select next. + /// + void ConfirmAndDelete(string path, int row, IAndroidLogcatDevice device) + { + var name = Path.GetFileNameWithoutExtension(path); + if (!EditorUtility.DisplayDialog("Delete Screenshot", + $"Delete {name}?\n\nThe file is removed from disk and this cannot be undone.", + "Delete", "Cancel")) + return; + + var wasSelected = m_CaptureScreenshot.SelectedImagePath == path; + if (!m_CaptureScreenshot.DeleteScreenshot(path)) + return; + + if (wasSelected) + { + // Whatever took its place, else the one before it. Deliberately not the + // Live row, which would start streaming because a file was deleted. + var remaining = m_CaptureScreenshot.GetScreenshots(); + if (remaining.Count > 0) + SelectRow(remaining, Mathf.Clamp(row - 1, 0, remaining.Count - 1) + 1, device); + } + m_Repaint(); + } + + /// + /// Where a row sits in the list. Everything below the Live row is pushed down + /// by the gap that separates the two groups. + /// + static float RowTop(int row, float rowHeight) + { + return row * rowHeight + (row > 0 ? kGroupGap : 0); + } + + void ScrollIntoView(int index, float rowHeight, float viewHeight) + { + var top = RowTop(index, rowHeight); + if (top < m_Scroll.y) + m_Scroll.y = top; + else if (top + rowHeight > m_Scroll.y + viewHeight) + m_Scroll.y = top + rowHeight - viewHeight; + } + } +} diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatScreenshotList.cs.meta b/com.unity.mobile.android-logcat/Editor/AndroidLogcatScreenshotList.cs.meta new file mode 100644 index 00000000..27ec6578 --- /dev/null +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatScreenshotList.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 225cc8cc8f773564bb589c0fe90d1599 \ No newline at end of file diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatSettings.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatSettings.cs index f33ea155..7739f437 100644 --- a/com.unity.mobile.android-logcat/Editor/AndroidLogcatSettings.cs +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatSettings.cs @@ -17,6 +17,14 @@ internal class AndroidLogcatSettings // Since querying memory from device is a lengthy operation, here's a cap 500 ms, setting it too low will make memory request to be delayed internal static int kMinMemoryRequestIntervalMS = 500; + // Live stream settings, as default/min/max. The server accepts wider values than + // these, but a stream is only as useful as the link can carry and every frame is + // JPEG encoded on the device, so this is the range the sliders offer and what a + // hand edited settings file is held to. + internal static readonly SettingsRange kLiveStreamMaxSize = new SettingsRange(1024, 256, 2048); + internal static readonly SettingsRange kLiveStreamQuality = new SettingsRange(70, 1, 100); + internal static readonly SettingsRange kLiveStreamMaxFps = new SettingsRange(30, 1, 120); + internal static readonly string[] kAddressResolveRegex = { @"\s*#\d{2}\s*pc\s(?
[a-fA-F0-9xX]+).*\/(?\S+)\/(?lib.*)\.so(?:.*\(BuildId:\s*(?\S+)\))?", @@ -57,6 +65,15 @@ internal class AndroidLogcatSettings [SerializeField] private int m_MaxExitedPackagesToShow; + [SerializeField] + private int m_LiveStreamMaxSize; + + [SerializeField] + private int m_LiveStreamQuality; + + [SerializeField] + private int m_LiveStreamMaxFps; + internal int MemoryRequestIntervalMS { set @@ -128,6 +145,63 @@ internal int MaxExitedPackagesToShow return m_MaxExitedPackagesToShow; } } + /// + /// Longest side of the live stream, in pixels. The device display is scaled down + /// to fit, which is what keeps the bandwidth and the encoding cost down. + /// + internal int LiveStreamMaxSize + { + set + { + var corrected = kLiveStreamMaxSize.Clamp(value); + if (m_LiveStreamMaxSize == corrected) + return; + m_LiveStreamMaxSize = corrected; + InvokeOnSettingsChanged(); + } + get + { + return m_LiveStreamMaxSize; + } + } + + /// JPEG quality of the live stream, 1 to 100. + internal int LiveStreamQuality + { + set + { + var corrected = kLiveStreamQuality.Clamp(value); + if (m_LiveStreamQuality == corrected) + return; + m_LiveStreamQuality = corrected; + InvokeOnSettingsChanged(); + } + get + { + return m_LiveStreamQuality; + } + } + + /// + /// Frame rate cap of the live stream. A mirrored display only produces a frame + /// when the screen changes, so this is a ceiling rather than a rate. + /// + internal int LiveStreamMaxFps + { + set + { + var corrected = kLiveStreamMaxFps.Clamp(value); + if (m_LiveStreamMaxFps == corrected) + return; + m_LiveStreamMaxFps = corrected; + InvokeOnSettingsChanged(); + } + get + { + return m_LiveStreamMaxFps; + } + } + internal Font MessageFont { set @@ -208,6 +282,7 @@ internal void Reset() m_MessageFont = AssetDatabase.LoadAssetAtPath("Packages/com.unity.mobile.android-logcat/Editor/Fonts/consola.ttf"); m_MessageFontSize = 11; m_MaxExitedPackagesToShow = 4; + ResetLiveStreamFields(); if (Enum.GetValues(typeof(Priority)).Length != 6) throw new Exception("Unexpected length of Priority enum."); @@ -227,6 +302,28 @@ internal void Reset() InvokeOnSettingsChanged(); } + /// + /// Just the live stream settings, for the Reset button beside them, so that + /// putting the stream back to its defaults does not take the message colours, + /// fonts and regexes with it. + /// + internal void ResetLiveStreamSettings() + { + ResetLiveStreamFields(); + InvokeOnSettingsChanged(); + } + + /// + /// The fields on their own, so that keeps raising one change + /// notification for the lot rather than one per section. + /// + private void ResetLiveStreamFields() + { + m_LiveStreamMaxSize = kLiveStreamMaxSize.Default; + m_LiveStreamQuality = kLiveStreamQuality.Default; + m_LiveStreamMaxFps = kLiveStreamMaxFps.Default; + } + internal void ResetStacktraceResolveRegex() { // Note: Don't create new instance, if not necessary @@ -313,6 +410,14 @@ private void Validate() var defaultColumnData = GetColumns(); if (m_ColumnData == null || m_ColumnData.Length != defaultColumnData.Length) m_ColumnData = defaultColumnData; + + // Settings saved before the live stream existed deserialize these as 0, and a + // hand edited file can hold anything. Filling them in here is why adding them + // did not need a kVersion bump, which would have reset colours, fonts and + // regexes along with them. + m_LiveStreamMaxSize = kLiveStreamMaxSize.OrDefault(m_LiveStreamMaxSize); + m_LiveStreamQuality = kLiveStreamQuality.OrDefault(m_LiveStreamQuality); + m_LiveStreamMaxFps = kLiveStreamMaxFps.OrDefault(m_LiveStreamMaxFps); } internal static AndroidLogcatSettings Load() diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatSettingsProvider.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatSettingsProvider.cs index a03e6d52..047a0fc0 100644 --- a/com.unity.mobile.android-logcat/Editor/AndroidLogcatSettingsProvider.cs +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatSettingsProvider.cs @@ -20,6 +20,15 @@ class Styles public static GUIContent requestIntervalMS = new GUIContent("Request Interval ms", $"How often to request memory dump from the device? The minimum value is {AndroidLogcatSettings.kMinMemoryRequestIntervalMS} ms"); public static GUIContent maxExitedPackageToShow = new GUIContent("Max Exited Packages", "The maximum number of packages in package selection which have exited."); + + public static GUIContent liveStreamMaxSize = new GUIContent("Max Size", + "Longest side of the streamed image in pixels. The device display is scaled down to fit, which is what keeps the bandwidth and the encoding cost on the device down."); + public static GUIContent liveStreamQuality = new GUIContent("JPEG Quality", + "Quality of each streamed frame. Lower means a smaller frame and less bandwidth."); + public static GUIContent liveStreamReset = new GUIContent("Reset", + $"Put Max Size, JPEG Quality and Max Frame Rate back to {AndroidLogcatSettings.kLiveStreamMaxSize.Default}, {AndroidLogcatSettings.kLiveStreamQuality.Default} and {AndroidLogcatSettings.kLiveStreamMaxFps.Default}, leaving every other setting alone."); + public static GUIContent liveStreamMaxFps = new GUIContent("Max Frame Rate", + "Ceiling on frames per second. A mirrored display only produces a frame when the screen changes, so an idle device sends fewer than this rather than exactly this."); } private AndroidLogcatRuntimeBase m_Runtime; @@ -65,6 +74,31 @@ public override void OnGUI(string searchContext) settings.MaxExitedPackagesToShow = EditorGUILayout.IntSlider(Styles.maxExitedPackageToShow, settings.MaxExitedPackagesToShow, 1, 100); + GUILayout.Space(20); + EditorGUILayout.LabelField("Live Stream", EditorStyles.boldLabel); + // Applied when a stream starts, so a stream that is already running keeps the + // settings it started with until it is reconnected. + settings.LiveStreamMaxSize = LiveStreamSlider(Styles.liveStreamMaxSize, + settings.LiveStreamMaxSize, AndroidLogcatSettings.kLiveStreamMaxSize); + settings.LiveStreamQuality = LiveStreamSlider(Styles.liveStreamQuality, + settings.LiveStreamQuality, AndroidLogcatSettings.kLiveStreamQuality); + settings.LiveStreamMaxFps = LiveStreamSlider(Styles.liveStreamMaxFps, + settings.LiveStreamMaxFps, AndroidLogcatSettings.kLiveStreamMaxFps); + + EditorGUILayout.HelpBox( + "Applied when a stream starts. To apply them to a stream that is already running, " + + "right click the Live row in the Device Screen Capture window and choose Reconnect.", + MessageType.None); + + GUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + // Resets this section only - the button at the bottom of the page is the one + // that resets everything. + if (GUILayout.Button(Styles.liveStreamReset, GUILayout.Width(60))) + settings.ResetLiveStreamSettings(); + GUILayout.Space(5); + GUILayout.EndHorizontal(); + GUILayout.Space(20); EditorGUILayout.LabelField(Styles.stactraceRegex, EditorStyles.boldLabel); m_RegexList.OnGUI(150.0f); @@ -82,6 +116,15 @@ public override void OnGUI(string searchContext) GUILayout.EndHorizontal(); } + /// + /// A slider whose ends come from the setting's own range, so the UI cannot offer + /// what the setter would clamp away. + /// + static int LiveStreamSlider(GUIContent label, int value, SettingsRange range) + { + return EditorGUILayout.IntSlider(label, value, range.Min, range.Max); + } + [SettingsProvider] public static SettingsProvider CreateAndroidLogcatSettingsProvider() { diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatSettingsRange.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatSettingsRange.cs new file mode 100644 index 00000000..52c5e134 --- /dev/null +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatSettingsRange.cs @@ -0,0 +1,51 @@ +using System; + +namespace Unity.Android.Logcat +{ + /// + /// The default and the accepted bounds of a numeric setting, kept in one place so + /// that the slider the user drags, the clamping on the way in and the value a reset + /// restores cannot drift apart. + /// + /// A readonly struct rather than a record: a positional record needs + /// System.Runtime.CompilerServices.IsExternalInit, which Unity's profile does + /// not carry, so it would take a shim type of its own to compile. Nothing here needs + /// the value equality a record would bring. + /// + /// + internal readonly struct SettingsRange + { + internal int Default { get; } + internal int Min { get; } + internal int Max { get; } + + internal SettingsRange(int defaultValue, int min, int max) + { + if (min > max) + throw new ArgumentException($"Min {min} is greater than max {max}"); + if (defaultValue < min || defaultValue > max) + throw new ArgumentException($"Default {defaultValue} is outside {min}..{max}"); + + Default = defaultValue; + Min = min; + Max = max; + } + + /// The value brought inside the bounds, for a value the user chose. + internal int Clamp(int value) + { + return Math.Clamp(value, Min, Max); + } + + /// + /// The value if it is within bounds, otherwise - for a + /// setting read back from a blob written before it existed, where it arrives as + /// 0. Clamping such a value would quietly pick , which is not + /// what a setting nobody has ever chosen should end up as. + /// + internal int OrDefault(int value) + { + return value < Min || value > Max ? Default : value; + } + } +} diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatSettingsRange.cs.meta b/com.unity.mobile.android-logcat/Editor/AndroidLogcatSettingsRange.cs.meta new file mode 100644 index 00000000..b0b68e82 --- /dev/null +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatSettingsRange.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5520d088c648b2c458ad5878a6f117d7 \ No newline at end of file diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatStatsColumn.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatStatsColumn.cs new file mode 100644 index 00000000..dc4760a8 --- /dev/null +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatStatsColumn.cs @@ -0,0 +1,65 @@ +using UnityEditor; +using UnityEngine; + +namespace Unity.Android.Logcat +{ + /// + /// The column of name/value rows beside the image in the Screen Capture window, + /// shared by the live view and the screenshot preview so the two look alike. + /// + internal static class AndroidLogcatStatsColumn + { + const float kWidth = 190; + const float kMargin = 8; + + internal const float kLabelWidth = 80; + + /// + /// Width to reserve out of an area before the image is fitted into it. Capped + /// to a fraction of it, so a narrow window does not lose the image to the column. + /// + internal static float WidthFor(Rect area) + { + return Mathf.Min(kWidth, area.width * 0.4f); + } + + /// + /// The same, widened to hold the values it is given - a device name is longer + /// than anything the live view shows - and capped the same way, so a narrow + /// window keeps its image rather than losing it to the column. + /// + internal static float WidthFor(Rect area, string[] values) + { + var widest = 0.0f; + foreach (var value in values) + widest = Mathf.Max(widest, Mathf.Ceil(EditorStyles.miniLabel.CalcSize(new GUIContent(value)).x)); + + return Mathf.Min(Mathf.Max(kWidth, kLabelWidth + widest + kMargin), area.width * 0.4f); + } + + /// + /// The column's rect, against the image rather than the right edge of the area: + /// the image is centred in what is left over, so the gap beside it varies. + /// + internal static Rect RectBeside(Rect area, Rect imageBox) + { + return new Rect(imageBox.xMax + kMargin, imageBox.y, + Mathf.Max(0, area.xMax - imageBox.xMax - kMargin), imageBox.height); + } + + internal static void Row(Rect rc, float labelWidth, ref float y, GUIContent name, string value, + string valueTooltip = null) + { + var height = EditorGUIUtility.singleLineHeight; + if (y + height > rc.yMax) + return; + + GUI.Label(new Rect(rc.x, y, labelWidth, height), name, EditorStyles.miniLabel); + // The column is narrow enough that long values clip, so the tooltip carries + // the full text where that matters. + GUI.Label(new Rect(rc.x + labelWidth, y, Mathf.Max(0, rc.width - labelWidth), height), + new GUIContent(value, valueTooltip ?? name.tooltip), EditorStyles.miniLabel); + y += height; + } + } +} diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatStatsColumn.cs.meta b/com.unity.mobile.android-logcat/Editor/AndroidLogcatStatsColumn.cs.meta new file mode 100644 index 00000000..be0eb0bb --- /dev/null +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatStatsColumn.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 583c4386639442cdacf72621cb86a42b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatUserSettings.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatUserSettings.cs index 2f7edd09..cb997e35 100644 --- a/com.unity.mobile.android-logcat/Editor/AndroidLogcatUserSettings.cs +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatUserSettings.cs @@ -52,9 +52,23 @@ internal class ScreenCaptureSettings { [SerializeField] internal AndroidLogcatScreenCaptureWindow.Mode Mode; + /// Width of the saved screenshot list, left of the splitter. + [SerializeField] + internal float ScreenshotListWidth; [SerializeField] private string[] m_LastSaveLocation; + /// + /// Saves a copy of a capture wherever the user picks, starting from where + /// they picked last time for this mode, and remembering where that was. + /// + internal void SaveFileAs(AndroidLogcatScreenCaptureWindow.Mode mode, string path, string title) + { + var directory = AndroidLogcatUtilities.SaveFileAs(path, title, GetLastSaveLocation(mode)); + if (directory != null) + SetLastSaveLocation(mode, directory); + } + internal void SetLastSaveLocation(AndroidLogcatScreenCaptureWindow.Mode mode, string path) { if (m_LastSaveLocation == null || (int)mode >= m_LastSaveLocation.Length) @@ -383,7 +397,8 @@ internal void ResetScreenCaptureSettings() { m_ScreenCaptureSettings = new ScreenCaptureSettings { - Mode = AndroidLogcatScreenCaptureWindow.Mode.Screenshot + Mode = AndroidLogcatScreenCaptureWindow.Mode.Screenshot, + ScreenshotListWidth = AndroidLogcatScreenshotList.kDefaultWidth }; m_ScreenCaptureSettings.ResetLastSaveLocation(); } diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatUtilities.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatUtilities.cs index 60cb6cbc..1794a09b 100644 --- a/com.unity.mobile.android-logcat/Editor/AndroidLogcatUtilities.cs +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatUtilities.cs @@ -88,6 +88,359 @@ public static string SanitizeFileName(string name) return name; } + /// + /// What the OS calls its file browser, for menu items that reveal a file in it. + /// + public static string RevealInFileBrowserLabel + { + get + { + switch (Application.platform) + { + case RuntimePlatform.OSXEditor: return "Show In Finder"; + case RuntimePlatform.LinuxEditor: return "Show In File Manager"; + default: return "Show In Explorer"; + } + } + } + + /// Selects a file in the OS file browser, rather than opening it. + public static void RevealInFileBrowser(string path) + { + if (string.IsNullOrEmpty(path) || !File.Exists(path)) + return; + + UnityEditor.EditorUtility.RevealInFinder(path); + } + + /// Opens a file with whatever the OS uses for its type. + public static void OpenFile(string path) + { + if (string.IsNullOrEmpty(path) || !File.Exists(path)) + return; + + switch (Application.platform) + { + case RuntimePlatform.OSXEditor: + // Application.OpenURL on a plain path does nothing useful on macOS. + System.Diagnostics.Process.Start("open", path); + break; + default: + Application.OpenURL(path); + break; + } + } + + /// + /// Asks where to put a copy of and copies it there. + /// The extension offered in the dialog comes from the source file, so callers do + /// not have to know it. + /// + /// + /// The directory saved into, so the caller can remember it, or null if the dialog + /// was cancelled or the copy failed. A failure is logged. + /// + public static string SaveFileAs(string sourcePath, string title, string startDirectory) + { + if (string.IsNullOrEmpty(sourcePath) || !File.Exists(sourcePath)) + return null; + + var extension = Path.GetExtension(sourcePath); + var path = UnityEditor.EditorUtility.SaveFilePanel(title, startDirectory, + Path.GetFileName(sourcePath), + string.IsNullOrEmpty(extension) ? string.Empty : extension.Substring(1)); + if (string.IsNullOrEmpty(path)) + return null; + + try + { + File.Copy(sourcePath, path, true); + } + catch (Exception ex) + { + Debug.LogErrorFormat("Failed to save '{0}' as '{1}'.\n{2}", sourcePath, path, ex.Message); + return null; + } + + // A screenshot's details file goes with the copy. Nothing to do for a + // file that has none, which is every video. + AndroidLogcatScreenshotInfo.CopyBeside(sourcePath, path); + + return Path.GetFullPath(Path.GetDirectoryName(path)); + } + + /// + /// Where captured screenshots are kept: under Library, which is per machine and + /// gitignored, and outside Assets so Unity never imports the images as assets. + /// Not UserSettings - that is for settings, and these are output. + /// + /// Library is also deleted from time to time, by hand or by the Editor. That is + /// the right trade for debugging output: a screenshot worth keeping is one Save + /// As away from somewhere that is not Library. + /// + /// + public static string GetScreenshotsDirectory() + { + return GetCaptureDirectory("Screenshots"); + } + + /// + /// Where the Layout Viewer keeps its screenshot. Its own directory, not the one + /// above: that capture belongs to the layout it was queried with and is replaced + /// by the next query, so it has no business in the saved screenshot list. + /// + public static string GetLayoutViewerDirectory() + { + return GetCaptureDirectory("LayoutViewer"); + } + + static string GetCaptureDirectory(string name) + { + var path = Path.Combine(Application.dataPath, "..", "Library", "AndroidLogcat", name); + return Path.GetFullPath(path).Replace("\\", "/"); + } + + + internal static string ResolvePath(params string[] relativeParts) + { + var package = UnityEditor.PackageManager.PackageInfo.FindForAssembly( + typeof(AndroidLogcatUtilities).Assembly); + if (package == null) + return null; + + var parts = new string[relativeParts.Length + 1]; + parts[0] = package.resolvedPath; + Array.Copy(relativeParts, 0, parts, 1, relativeParts.Length); + return Path.GetFullPath(Path.Combine(parts)); + } + + /// + /// The path with the project folder stripped off, for showing in the UI. A + /// screenshot's absolute path is mostly project folder, which in a tooltip is + /// wide enough to cover the rows around it. + /// + public static string ProjectRelativePath(string path) + { + return ProjectRelativePath(path, GetProjectDirectory()); + } + + /// + /// The same, against a given project folder rather than this project's, so that + /// it can be exercised with paths from a platform other than the one running. + /// + internal static string ProjectRelativePath(string path, string projectDirectory) + { + if (string.IsNullOrEmpty(path)) + return path; + + // Trailing slash trimmed so that the separator check below has a separator + // to find, whatever shape the folder was handed over in. + var project = projectDirectory.Replace("\\", "/").TrimEnd('/'); + var normalized = path.Replace("\\", "/"); + + if (normalized.Length > project.Length + 1 + && normalized[project.Length] == '/' + && normalized.StartsWith(project, StringComparison.OrdinalIgnoreCase)) + return normalized.Substring(project.Length + 1); + + // Not under the project - a screenshot opened from elsewhere, say - so there + // is nothing to strip and the whole path is the most useful thing to show. + return normalized; + } + + static string s_ProjectDirectory; + + /// + /// The folder that holds Assets, cached: tooltips are built per row per repaint, + /// and the project does not move while the Editor is running. + /// + static string GetProjectDirectory() + { + if (s_ProjectDirectory == null) + s_ProjectDirectory = Path.GetFullPath(Path.Combine(Application.dataPath, "..")).Replace("\\", "/"); + return s_ProjectDirectory; + } + + // Long enough for a first run, which downloads Gradle itself. + const int kGradleTimeoutMs = 5 * 60 * 1000; + const int kGradleProgressUpdateMs = 200; + + /// + /// 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. + /// + /// + /// + /// 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}"); + } + } + + 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. diff --git a/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatGeneralTests.cs b/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatGeneralTests.cs index 23364e54..78644973 100644 --- a/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatGeneralTests.cs +++ b/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatGeneralTests.cs @@ -1,12 +1,326 @@ using System; +using System.Linq; +using UnityEngine; using UnityEngine.TestTools; using NUnit.Framework; using System.Collections; using System.Collections.Generic; +using System.Text.RegularExpressions; using Unity.Android.Logcat; class AndroidLogcatGeneralTests { + /// + /// The screenshots folder is an ordinary directory, so files can appear in it or + /// change without the Editor having done anything, and a cached listing cannot + /// notice by itself. This is the API underneath the Screen Capture window's + /// refresh when it regains focus. + /// + /// No device needed: the listing is a directory scan, so the files can simply be + /// written here. + /// + /// + [Test] + public void InvalidateScreenshotsPicksUpExternalChanges() + { + var runtime = new AndroidLogcatTestRuntime(); + runtime.Initialize(); + try + { + // Built directly rather than taken from the runtime: the test runtime has + // no screen capture service, and the two calls used here only read the + // directory - nothing is queued, so nothing needs a device or a dispatcher. + var captureScreenshot = new AndroidLogcatCaptureScreenshot(runtime, + AndroidLogcatUtilities.GetScreenshotsDirectory(), true); + + var directory = AndroidLogcatUtilities.GetScreenshotsDirectory(); + System.IO.Directory.CreateDirectory(directory); + + var first = System.IO.Path.Combine(directory, "unittest-device_1.png").Replace("\\", "/"); + var second = System.IO.Path.Combine(directory, "unittest-device_2.png").Replace("\\", "/"); + System.IO.File.WriteAllBytes(first, new byte[] { 1, 2, 3 }); + + try + { + var screenshots = captureScreenshot.GetScreenshots(); + Assert.IsTrue(screenshots.Any(s => s.Path == first), "The first file should be listed"); + Assert.IsFalse(screenshots.Any(s => s.Path == second), "The second one does not exist yet"); + + // Written behind the cache's back, as a file browser would. + System.IO.File.WriteAllBytes(second, new byte[] { 4, 5, 6 }); + + Assert.IsFalse(captureScreenshot.GetScreenshots().Any(s => s.Path == second), + "A cached listing cannot know about a file the Editor did not write"); + + captureScreenshot.InvalidateScreenshots(); + + Assert.IsTrue(captureScreenshot.GetScreenshots().Any(s => s.Path == second), + "After invalidating, the rescan should pick the file up"); + + // And the same for one that disappears. + System.IO.File.Delete(first); + Assert.IsTrue(captureScreenshot.GetScreenshots().Any(s => s.Path == first), + "Still cached, so still listed"); + + captureScreenshot.InvalidateScreenshots(); + Assert.IsFalse(captureScreenshot.GetScreenshots().Any(s => s.Path == first), + "After invalidating, a file that is gone should be gone from the list"); + } + finally + { + foreach (var path in new[] { first, second }) + { + if (System.IO.File.Exists(path)) + System.IO.File.Delete(path); + } + } + } + finally + { + runtime.Shutdown(); + } + } + + /// + /// A rename moves the details with the image. When something else already holds + /// the name the details would take, the rename is refused rather than leaving the + /// image under one name and its details under another. + /// + [Test] + public void RenameIsRefusedWhenTheDetailsNameIsTaken() + { + var runtime = new AndroidLogcatTestRuntime(); + runtime.Initialize(); + try + { + var captureScreenshot = new AndroidLogcatCaptureScreenshot(runtime, + AndroidLogcatUtilities.GetScreenshotsDirectory(), true); + + var directory = AndroidLogcatUtilities.GetScreenshotsDirectory(); + System.IO.Directory.CreateDirectory(directory); + + var image = System.IO.Path.Combine(directory, "unittest-rename_1.png").Replace("\\", "/"); + var taken = System.IO.Path.Combine(directory, "unittest-rename-taken.json").Replace("\\", "/"); + var renamed = System.IO.Path.Combine(directory, "unittest-rename-free.png").Replace("\\", "/"); + const string keep = "{\"keep\":\"me\"}"; + + System.IO.File.WriteAllBytes(image, new byte[] { 1, 2, 3 }); + System.IO.File.WriteAllText(taken, keep); + + try + { + LogAssert.Expect(LogType.Error, new Regex("was not written by Android Logcat")); + Assert.IsFalse(captureScreenshot.RenameScreenshot(image, "unittest-rename-taken"), + "The rename should be refused"); + FileAssert.Exists(image); + Assert.AreEqual(keep, System.IO.File.ReadAllText(taken), + "A refused rename should leave the other file alone"); + + Assert.IsTrue(captureScreenshot.RenameScreenshot(image, "unittest-rename-free"), + "A name nothing else holds should rename"); + FileAssert.Exists(renamed); + } + finally + { + foreach (var path in new[] { image, taken, renamed }) + { + if (System.IO.File.Exists(path)) + System.IO.File.Delete(path); + } + } + } + finally + { + runtime.Shutdown(); + } + } + + [Test] + public void SettingsRangeTests() + { + var range = new SettingsRange(70, 1, 100); + Assert.AreEqual(70, range.Default); + Assert.AreEqual(1, range.Min); + Assert.AreEqual(100, range.Max); + + // A value the user chose is brought inside the bounds. + Assert.AreEqual(1, range.Clamp(-5)); + Assert.AreEqual(1, range.Clamp(0)); + Assert.AreEqual(100, range.Clamp(1000)); + Assert.AreEqual(55, range.Clamp(55)); + Assert.AreEqual(1, range.Clamp(1)); + Assert.AreEqual(100, range.Clamp(100)); + + // A value read back from an older settings blob falls back to the default + // instead, since 0 there means nobody ever chose one. + Assert.AreEqual(70, range.OrDefault(0)); + Assert.AreEqual(70, range.OrDefault(-5)); + Assert.AreEqual(70, range.OrDefault(1000)); + Assert.AreEqual(55, range.OrDefault(55)); + Assert.AreEqual(1, range.OrDefault(1)); + Assert.AreEqual(100, range.OrDefault(100)); + + // A range that cannot hold its own default is a mistake at the declaration, so + // it is refused rather than silently clamped. + Assert.Throws(typeof(ArgumentException), () => new SettingsRange(0, 1, 100)); + Assert.Throws(typeof(ArgumentException), () => new SettingsRange(500, 1, 100)); + Assert.Throws(typeof(ArgumentException), () => new SettingsRange(5, 100, 1)); + } + + [Test] + public void LiveStreamSettingsTests() + { + var settings = new AndroidLogcatSettings(); + + // The defaults are what the live stream used to hold as constants. + Assert.AreEqual(AndroidLogcatSettings.kLiveStreamMaxSize.Default, settings.LiveStreamMaxSize); + Assert.AreEqual(AndroidLogcatSettings.kLiveStreamQuality.Default, settings.LiveStreamQuality); + Assert.AreEqual(AndroidLogcatSettings.kLiveStreamMaxFps.Default, settings.LiveStreamMaxFps); + + // Clamped on the way in, so a hand edited settings file cannot hand the server + // something it will refuse or choke on. + settings.LiveStreamMaxSize = 1; + Assert.AreEqual(AndroidLogcatSettings.kLiveStreamMaxSize.Min, settings.LiveStreamMaxSize); + settings.LiveStreamMaxSize = 100000; + Assert.AreEqual(AndroidLogcatSettings.kLiveStreamMaxSize.Max, settings.LiveStreamMaxSize); + + settings.LiveStreamQuality = 0; + Assert.AreEqual(AndroidLogcatSettings.kLiveStreamQuality.Min, settings.LiveStreamQuality); + settings.LiveStreamQuality = 1000; + Assert.AreEqual(AndroidLogcatSettings.kLiveStreamQuality.Max, settings.LiveStreamQuality); + + settings.LiveStreamMaxFps = 0; + Assert.AreEqual(AndroidLogcatSettings.kLiveStreamMaxFps.Min, settings.LiveStreamMaxFps); + settings.LiveStreamMaxFps = 1000; + Assert.AreEqual(AndroidLogcatSettings.kLiveStreamMaxFps.Max, settings.LiveStreamMaxFps); + + // A value inside the range is kept as it is. + settings.LiveStreamMaxSize = 512; + settings.LiveStreamQuality = 55; + settings.LiveStreamMaxFps = 15; + Assert.AreEqual(512, settings.LiveStreamMaxSize); + Assert.AreEqual(55, settings.LiveStreamQuality); + Assert.AreEqual(15, settings.LiveStreamMaxFps); + + // The section's own Reset button puts the three back without disturbing + // anything else on the page. + settings.MessageFontSize = 17; + settings.MaxCachedMessageCount = 1234; + settings.ResetLiveStreamSettings(); + Assert.AreEqual(AndroidLogcatSettings.kLiveStreamMaxSize.Default, settings.LiveStreamMaxSize); + Assert.AreEqual(AndroidLogcatSettings.kLiveStreamQuality.Default, settings.LiveStreamQuality); + Assert.AreEqual(AndroidLogcatSettings.kLiveStreamMaxFps.Default, settings.LiveStreamMaxFps); + Assert.AreEqual(17, settings.MessageFontSize, "A live stream reset should not touch the font size"); + Assert.AreEqual(1234, settings.MaxCachedMessageCount, "A live stream reset should not touch the message cap"); + + // And the whole page Reset takes them with everything else. + settings.LiveStreamMaxSize = 512; + settings.Reset(); + Assert.AreEqual(AndroidLogcatSettings.kLiveStreamMaxSize.Default, settings.LiveStreamMaxSize); + Assert.AreEqual(AndroidLogcatSettings.kLiveStreamQuality.Default, settings.LiveStreamQuality); + Assert.AreEqual(AndroidLogcatSettings.kLiveStreamMaxFps.Default, settings.LiveStreamMaxFps); + } + + [Test] + public void LiveStreamEditingShortcutTests() + { + AndroidKeyCode mapped; + + // Ctrl on Windows and Linux, Cmd on macOS: both have to reach the device, where + // they arrive as Ctrl either way. + foreach (var modifier in new[] { EventModifiers.Control, EventModifiers.Command }) + { + Assert.IsTrue(AndroidLogcatLiveStream.TryMapEditingShortcut( + new Event { keyCode = KeyCode.A, modifiers = modifier }, out mapped), $"A with {modifier}"); + Assert.AreEqual(AndroidKeyCode.A, mapped); + + Assert.IsTrue(AndroidLogcatLiveStream.TryMapEditingShortcut( + new Event { keyCode = KeyCode.C, modifiers = modifier }, out mapped), $"C with {modifier}"); + Assert.AreEqual(AndroidKeyCode.C, mapped); + + Assert.IsTrue(AndroidLogcatLiveStream.TryMapEditingShortcut( + new Event { keyCode = KeyCode.V, modifiers = modifier }, out mapped), $"V with {modifier}"); + Assert.AreEqual(AndroidKeyCode.V, mapped); + } + + // A bare letter is ordinary typing, which goes to the device as text instead. + Assert.IsFalse(AndroidLogcatLiveStream.TryMapEditingShortcut( + new Event { keyCode = KeyCode.A, modifiers = EventModifiers.None }, out mapped)); + + // Every other Ctrl chord belongs to the Editor - Ctrl+S in particular. + Assert.IsFalse(AndroidLogcatLiveStream.TryMapEditingShortcut( + new Event { keyCode = KeyCode.S, modifiers = EventModifiers.Control }, out mapped)); + Assert.IsFalse(AndroidLogcatLiveStream.TryMapEditingShortcut( + new Event { keyCode = KeyCode.Z, modifiers = EventModifiers.Control }, out mapped)); + + // And so does anything with a further modifier on top, such as this window's own + // Ctrl+Shift+S, so only the bare chord is taken. + Assert.IsFalse(AndroidLogcatLiveStream.TryMapEditingShortcut( + new Event { keyCode = KeyCode.A, modifiers = EventModifiers.Control | EventModifiers.Shift }, + out mapped)); + Assert.IsFalse(AndroidLogcatLiveStream.TryMapEditingShortcut( + new Event { keyCode = KeyCode.V, modifiers = EventModifiers.Control | EventModifiers.Alt }, + out mapped)); + } + + [Test] + public void ProjectRelativePathTests() + { + const string screenshot = "Library/AndroidLogcat/Screenshots/device_1.png"; + + // Windows, where paths come in with backslashes and in whatever case the caller + // happened to use - hence the case insensitive comparison in the function. + var windows = "C:/Users/tomas/Projects/MyProject"; + StringAssert.AreEqualIgnoringCase(screenshot, + AndroidLogcatUtilities.ProjectRelativePath(windows + "/" + screenshot, windows)); + StringAssert.AreEqualIgnoringCase(screenshot, + AndroidLogcatUtilities.ProjectRelativePath( + @"C:\Users\tomas\Projects\MyProject\Library\AndroidLogcat\Screenshots\device_1.png", windows)); + StringAssert.AreEqualIgnoringCase(screenshot, + AndroidLogcatUtilities.ProjectRelativePath( + @"c:\users\tomas\projects\myproject\Library\AndroidLogcat\Screenshots\device_1.png", windows)); + + // macOS, and Linux with it: rooted at / with no drive, and project folders with + // spaces in them are the norm rather than the exception. + var osx = "/Users/tomas/Projects/MyProject"; + StringAssert.AreEqualIgnoringCase(screenshot, + AndroidLogcatUtilities.ProjectRelativePath(osx + "/" + screenshot, osx)); + + var osxWithSpaces = "/Users/tomas/Unity Projects/My Project"; + StringAssert.AreEqualIgnoringCase(screenshot, + AndroidLogcatUtilities.ProjectRelativePath(osxWithSpaces + "/" + screenshot, osxWithSpaces)); + + // A trailing slash on the project folder must not eat the first character of + // what is left. + StringAssert.AreEqualIgnoringCase(screenshot, + AndroidLogcatUtilities.ProjectRelativePath(osx + "/" + screenshot, osx + "/")); + + // Outside the project there is nothing to strip. + StringAssert.AreEqualIgnoringCase("/Users/tomas/Desktop/shot.png", + AndroidLogcatUtilities.ProjectRelativePath("/Users/tomas/Desktop/shot.png", osx)); + StringAssert.AreEqualIgnoringCase("D:/elsewhere/shot.png", + AndroidLogcatUtilities.ProjectRelativePath("D:/elsewhere/shot.png", windows)); + + // A folder whose name merely starts with the project folder's must not be taken + // for something inside it. + StringAssert.AreEqualIgnoringCase(osx + "2/shot.png", + AndroidLogcatUtilities.ProjectRelativePath(osx + "2/shot.png", osx)); + + // The project folder itself is not a file in the project, so it is left alone + // rather than turned into an empty string. + StringAssert.AreEqualIgnoringCase(osx, AndroidLogcatUtilities.ProjectRelativePath(osx, osx)); + + // And through the public entry point, which is what the screenshot list calls, + // to prove it is wired to this project's folder. + var project = System.IO.Path.GetFullPath( + System.IO.Path.Combine(UnityEngine.Application.dataPath, "..")).Replace("\\", "/"); + StringAssert.AreEqualIgnoringCase(screenshot, + AndroidLogcatUtilities.ProjectRelativePath(project + "/" + screenshot)); + + Assert.AreEqual(string.Empty, AndroidLogcatUtilities.ProjectRelativePath(string.Empty)); + Assert.IsNull(AndroidLogcatUtilities.ProjectRelativePath(null)); + } + [Test] public void ParseVersionTests() { @@ -25,6 +339,195 @@ public void ParseVersionTests() } } + /// + /// The server jar is a build output, not something a clone comes with, and every + /// live stream test needs it on the device. Checking it here means one quick + /// failure saying what to run, rather than a device fixture timing out later. + /// + [Test] + public void LiveStreamServerJarIsBuilt() + { + var path = AndroidLogcatLiveStream.GetServerJarPath().Replace("\\", "/"); + + // Resolved through the Package Manager, so it follows the package wherever it + // is installed from. + StringAssert.EndsWith("External~/unity-logcat-server.jar", path); + FileAssert.Exists(path, + "Was the server jar packed? It is built by 'gradlew dexJar' in External/UnityLogcatServer, " + + "which copies it into the package."); + } + + /// + /// The details file written beside a screenshot. No device: the fake one answers + /// the same calls, and the rest is a file next to a file. + /// + [Test] + public void ScreenshotInfoRoundTripsAndFollowsTheImage() + { + var directory = AndroidLogcatUtilities.GetScreenshotsDirectory(); + System.IO.Directory.CreateDirectory(directory); + + var image = System.IO.Path.Combine(directory, "unittest-info_1.png").Replace("\\", "/"); + var renamed = System.IO.Path.Combine(directory, "unittest-info-renamed.png").Replace("\\", "/"); + var foreign = System.IO.Path.Combine(directory, "unittest-info-foreign.png").Replace("\\", "/"); + + try + { + Assert.IsNull(AndroidLogcatScreenshotInfo.Load(image), + "A screenshot with no details file has nothing to read"); + + var device = new AndroidLogcatFakeDevice90("unittest-device"); + device.SetRawDisplayInfo("Physical size: 1080x2400\nOverride size: 540x1200"); + + var info = AndroidLogcatScreenshotInfo.Create(device); + Assert.AreEqual("unittest-device", info.deviceId); + // An overridden size is what the device composes, so that is what a + // screenshot of it was taken at. + Assert.AreEqual(540, info.displayWidth); + Assert.AreEqual(1200, info.displayHeight); + + info.Save(image); + FileAssert.Exists(AndroidLogcatScreenshotInfo.PathFor(image)); + + var loaded = AndroidLogcatScreenshotInfo.Load(image); + Assert.AreEqual(AndroidLogcatScreenshotInfo.kVersion, loaded.version); + Assert.AreEqual(info.deviceId, loaded.deviceId); + Assert.AreEqual(info.deviceName, loaded.deviceName); + Assert.AreEqual(info.capturedAt, loaded.capturedAt); + Assert.AreEqual(info.displayWidth, loaded.displayWidth); + Assert.AreEqual(info.displayHeight, loaded.displayHeight); + + AndroidLogcatScreenshotInfo.Move(image, renamed); + Assert.IsNull(AndroidLogcatScreenshotInfo.Load(image), + "The details should have moved with the image"); + Assert.AreEqual(info.deviceId, AndroidLogcatScreenshotInfo.Load(renamed).deviceId); + + AndroidLogcatScreenshotInfo.Delete(renamed); + Assert.IsNull(AndroidLogcatScreenshotInfo.Load(renamed)); + + // The details take the image's name with a .json extension, which is a + // name someone else's file can already have - saving a capture as + // settings.png beside an unrelated settings.json must not eat it. + var foreignJson = AndroidLogcatScreenshotInfo.PathFor(foreign); + const string keep = "{\"keep\":\"me\"}"; + System.IO.File.WriteAllText(foreignJson, keep); + + Assert.IsNull(AndroidLogcatScreenshotInfo.Load(foreign), + "Someone else's json is not a details file"); + + LogAssert.Expect(LogType.Warning, new Regex("not written by Android Logcat")); + info.Save(foreign); + Assert.AreEqual(keep, System.IO.File.ReadAllText(foreignJson), + "Saving details should not overwrite a file we did not write"); + + LogAssert.Expect(LogType.Warning, new Regex("not written by Android Logcat")); + AndroidLogcatScreenshotInfo.Delete(foreign); + FileAssert.Exists(foreignJson); + } + finally + { + System.IO.File.Delete(AndroidLogcatScreenshotInfo.PathFor(image)); + System.IO.File.Delete(AndroidLogcatScreenshotInfo.PathFor(renamed)); + System.IO.File.Delete(AndroidLogcatScreenshotInfo.PathFor(foreign)); + } + } + + /// + /// The zoom of the live view and the screenshot preview. No device and no GUI: what + /// the wheel and the drag do to the view is arithmetic that can simply be called. + /// + [Test] + public void ImageViewerZoomsBetween100And4000Percent() + { + var viewer = new AndroidLogcatImageViewer(); + var area = new Rect(0, 0, 400, 300); + // The area's own shape, so the image fills it at 100%. + const float aspect = 4.0f / 3.0f; + + Assert.AreEqual(AndroidLogcatImageViewer.kMinZoom, viewer.Zoom, 0.0001f, + "Expected to start at 100%"); + Assert.IsFalse(viewer.IsZoomed); + + // A notch of the wheel is a delta of 3, and scrolling up reports it negative. + Assert.IsTrue(viewer.ZoomAt(area, aspect, area.center, -3.0f), "Expected one notch to zoom in"); + Assert.Greater(viewer.Zoom, AndroidLogcatImageViewer.kMinZoom); + Assert.IsTrue(viewer.IsZoomed); + + for (var notch = 0; notch < 100; notch++) + viewer.ZoomAt(area, aspect, area.center, -3.0f); + + Assert.AreEqual(AndroidLogcatImageViewer.kMaxZoom, viewer.Zoom, 0.0001f, + "Expected to stop at 4000%"); + Assert.IsFalse(viewer.ZoomAt(area, aspect, area.center, -3.0f), + "Expected no change once the zoom is at its maximum"); + + for (var notch = 0; notch < 100; notch++) + viewer.ZoomAt(area, aspect, area.center, 3.0f); + + Assert.AreEqual(AndroidLogcatImageViewer.kMinZoom, viewer.Zoom, 0.0001f, + "Expected to stop at 100%"); + Assert.IsFalse(viewer.ZoomAt(area, aspect, area.center, 3.0f), + "Expected no change once the zoom is at its minimum"); + Assert.IsFalse(viewer.IsZoomed); + Assert.AreEqual(Vector2.zero, viewer.Scroll, + "Zooming all the way back out should leave nothing scrolled out of view"); + } + + [Test] + public void ImageViewerZoomKeepsWhatIsUnderTheCursorThere() + { + var viewer = new AndroidLogcatImageViewer(); + var area = new Rect(0, 0, 400, 300); + const float aspect = 4.0f / 3.0f; + // The quarter point of the area, and so of the image in it. + var pointer = new Vector2(100, 75); + + // Four notches double the zoom, so twelve wheel units is exactly 200%. + Assert.IsTrue(viewer.ZoomAt(area, aspect, pointer, -12.0f)); + Assert.AreEqual(2.0f, viewer.Zoom, 0.0001f); + + // (100,75) of the 400x300 area is (200,150) of the 800x600 it has become, and + // that has to end up back under the cursor - so the view scrolls by (100,75). + Assert.AreEqual(100.0f, viewer.Scroll.x, 0.001f); + Assert.AreEqual(75.0f, viewer.Scroll.y, 0.001f); + + viewer.Reset(); + Assert.AreEqual(AndroidLogcatImageViewer.kMinZoom, viewer.Zoom, 0.0001f); + Assert.AreEqual(Vector2.zero, viewer.Scroll); + } + + [Test] + public void ImageViewerPansOnlyWithinTheZoomedImage() + { + var viewer = new AndroidLogcatImageViewer(); + var area = new Rect(0, 0, 400, 300); + const float aspect = 4.0f / 3.0f; + + // Nothing to move at 100%: the image is exactly the area. + viewer.Pan(area, aspect, new Vector2(-50, -50)); + Assert.AreEqual(Vector2.zero, viewer.Scroll); + + viewer.ZoomAt(area, aspect, area.min, -12.0f); + Assert.AreEqual(Vector2.zero, viewer.Scroll, + "Zooming in on the top left corner should have nothing scrolled out of view yet"); + + // Far past the end of the image: the far corner has to be reachable, and the + // image must not carry on off the view. + viewer.Pan(area, aspect, new Vector2(-10000, -10000)); + Assert.GreaterOrEqual(viewer.Scroll.x, area.width * (viewer.Zoom - 1.0f), + "Expected to be able to reach the right edge of the image"); + Assert.GreaterOrEqual(viewer.Scroll.y, area.height * (viewer.Zoom - 1.0f), + "Expected to be able to reach the bottom edge of the image"); + Assert.Less(viewer.Scroll.x, area.width * viewer.Zoom, + "Expected not to be able to drag the image out of the view"); + Assert.Less(viewer.Scroll.y, area.height * viewer.Zoom, + "Expected not to be able to drag the image out of the view"); + + // And back, which stops at the near corner rather than going past it. + viewer.Pan(area, aspect, new Vector2(10000, 10000)); + Assert.AreEqual(Vector2.zero, viewer.Scroll); + } + [Test] public void ParsePIDNameTests() { diff --git a/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatTestRuntime.cs b/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatTestRuntime.cs index ce4b8d44..37722bea 100644 --- a/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatTestRuntime.cs +++ b/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatTestRuntime.cs @@ -34,7 +34,13 @@ protected override AndroidLogcatCaptureVideo CreateScreenRecorder() { return null; } - protected override AndroidLogcatCaptureScreenshot CreateScreenCapture() + + protected override AndroidLogcatCaptureScreenshot CreateScreenCapture(string directory, bool keepHistory) + { + return null; + } + + protected override AndroidLogcatLiveStream CreateLiveStream() { return null; } diff --git a/com.unity.mobile.android-logcat/Tests/Editor/Integration/AndroidLogcatIntegrationLiveStream.cs b/com.unity.mobile.android-logcat/Tests/Editor/Integration/AndroidLogcatIntegrationLiveStream.cs new file mode 100644 index 00000000..3adf06e7 --- /dev/null +++ b/com.unity.mobile.android-logcat/Tests/Editor/Integration/AndroidLogcatIntegrationLiveStream.cs @@ -0,0 +1,469 @@ +using System; +using NUnit.Framework; +using System.Collections; +using Unity.Android.Logcat; +using UnityEngine; +using UnityEngine.TestTools; +using System.IO; +using System.Net; +using System.Text; + +[TestFixture] +[RequiresAndroidDevice] +internal class AndroidLogcatRuntimeIntegrationLiveStream : AndroidLogcatIntegrationTestBase +{ + // Small and slow on purpose: these tests care that frames arrive and are + // decodable, not about throughput, and a smaller stream starts sooner. + const int kMaxSize = 512; + const int kMaxFps = 15; + + // On these agents adb's forward is created beside the device rather than beside + // the Editor, so connecting to the loopback is refused however healthy + // 'adb forward --list' looks. The device is reached instead at its own address, + // through a port the platform maps for us - and that mapping points at a fixed + // port on the far side, which is the one the forward has to ask for here. + const int kMappedLocalPort = 27183; + const string kMappedPortUrl = "https://api.platform-env.ds.unity3d.com/mappedPort"; + + /// + /// Points the live stream at the device the way the agents reach it. Does nothing + /// off a build agent, where adb's forward is on the loopback like anywhere else. + /// + private void ConfigureLiveStreamTunnel() + { + var deviceId = Environment.GetEnvironmentVariable("BOKKEN_DEVICE_ID"); + var deviceIp = Environment.GetEnvironmentVariable("BOKKEN_DEVICE_IP"); + if (string.IsNullOrEmpty(deviceId) || string.IsNullOrEmpty(deviceIp)) + return; + + int mappedPort; + try + { + mappedPort = QueryMappedPort(deviceId); + } + catch (Exception ex) + { + Assert.Fail($"Failed to ask {kMappedPortUrl} which port is mapped to device '{deviceId}': {ex.Message}"); + return; + } + + Runtime.LiveStream.ForwardLocalPort = kMappedLocalPort; + Runtime.LiveStream.TunnelHost = deviceIp; + Runtime.LiveStream.TunnelPort = mappedPort; + Console.WriteLine($"Live stream tunnel: forwarding tcp:{kMappedLocalPort}, connecting to {deviceIp}:{mappedPort}"); + } + + static int QueryMappedPort(string deviceId) + { + var request = (HttpWebRequest)WebRequest.Create(kMappedPortUrl); + request.Method = "POST"; + request.Headers["Authorization"] = "Bearer bokken"; + request.ContentType = "text/plain"; + + var body = Encoding.UTF8.GetBytes(deviceId); + request.ContentLength = body.Length; + using (var stream = request.GetRequestStream()) + stream.Write(body, 0, body.Length); + + string answer; + using (var response = (HttpWebResponse)request.GetResponse()) + using (var reader = new StreamReader(response.GetResponseStream())) + answer = reader.ReadToEnd().Trim(); + + if (!int.TryParse(answer, out var port) || port <= 0) + throw new Exception($"Expected a port number, got '{answer}'"); + return port; + } + + [SetUp] + protected void Init() + { + Cleanup(); + ConfigureLiveStreamTunnel(); + + // Every test here pushes the server to the device, so without a jar the whole + // fixture fails one slow timeout at a time, saying nothing useful. It is a + // build output and is not committed, so a fresh clone has none yet. + FileAssert.Exists(AndroidLogcatLiveStream.GetServerJarPath(), + "Build the live stream server by running 'gradlew dexJar' in External/UnityLogcatServer."); + } + + [TearDown] + protected void Deinit() + { + Cleanup(); + } + + private void Cleanup() + { + // Leaving a stream running would hold a mirrored display on the device and + // make the next test fail with "Already streaming". + Runtime.LiveStream.StopStreaming(); + } + + [UnityTest] + public IEnumerator CanStreamDeviceScreen() + { + var result = AndroidLogcatLiveStream.Result.Failure; + var stopped = false; + + Runtime.LiveStream.StartStreaming(Device, r => + { + result = r; + stopped = true; + }, maxSize: kMaxSize, maxFps: kMaxFps); + + Assert.IsTrue(Runtime.LiveStream.IsStreaming, "Expected to be streaming right after starting"); + + // Starting a second stream without stopping the first should throw + Assert.Throws(typeof(InvalidOperationException), () => Runtime.LiveStream.StartStreaming(Device, null)); + + // The texture, not the frame count, because this test is about the texture + // being there to draw. Everything else uses WaitForFirstFrame. + yield return WaitForCondition("Waiting for the first frame", + () => Runtime.LiveStream.Texture != null, + kDefaultTimeout, + () => Runtime.LiveStream.Errors); + + Assert.AreEqual(string.Empty, Runtime.LiveStream.Errors, "Did not expect any errors while streaming"); + + var texture = Runtime.LiveStream.Texture; + Assert.IsNotNull(texture, "Expected to have a valid texture"); + Assert.Greater(texture.width, 10); + Assert.Greater(texture.height, 10); + // max_size caps the longest side, so neither side may exceed it. + Assert.LessOrEqual(Math.Max(texture.width, texture.height), kMaxSize, + $"Expected the longest side to be capped at {kMaxSize}"); + + // Written out so the frame can be eyeballed: a channel-order or row-order + // mistake still produces a texture of the right size. + ReportArtifact("frame.png", texture); + + // Note there is deliberately no assertion about a frame rate here. A mirrored + // display only produces a buffer when the screen changes, so a device sitting + // on a static screen legitimately sends nothing at all - see + // StreamsFramesWhileScreenChanges. What matters here is that the stream stays + // up rather than dying once the first frame is through. + yield return WaitFor(2.0, "Letting the stream run"); + + Assert.IsTrue(Runtime.LiveStream.IsStreaming, "Expected the stream to still be running"); + Assert.AreEqual(string.Empty, Runtime.LiveStream.Errors, "Did not expect errors while streaming"); + + Log($"Received {Runtime.LiveStream.FramesReceived} frames at {texture.width}x{texture.height}"); + + Assert.IsTrue(Runtime.LiveStream.StopStreaming(), "Failed to stop the stream"); + Assert.IsFalse(Runtime.LiveStream.IsStreaming, "Expected to have stopped streaming"); + Assert.IsTrue(stopped, "Expected the stop callback to have been invoked"); + Assert.AreEqual(AndroidLogcatLiveStream.Result.Success, result); + + Assert.IsFalse(Runtime.LiveStream.StopStreaming(), + "StopStreaming should return false, since it was already stopped"); + } + + /// + /// A mirrored display hands over a buffer only when composition changes, so frame + /// delivery is driven by the screen rather than by a clock: an idle device sends + /// roughly nothing (measured: 1 frame in 5 seconds), and a screen that is animating + /// saturates the max_fps cap. This makes the screen change and checks that frames + /// follow. + /// + [UnityTest] + public IEnumerator StreamsFramesWhileScreenChanges() + { + Runtime.LiveStream.StartStreaming(Device, null, maxSize: kMaxSize, maxFps: kMaxFps); + + yield return WaitForFirstFrame(); + + var framesBefore = Runtime.LiveStream.FramesReceived; + + // Each of these animates for a few hundred milliseconds. They are sent before + // waiting rather than interleaved because the reader thread counts frames on + // its own, so the count has already moved by the time we look. + SendKeyEvent("KEYCODE_APP_SWITCH"); + SendKeyEvent("KEYCODE_HOME"); + SendKeyEvent("KEYCODE_APP_SWITCH"); + SendKeyEvent("KEYCODE_HOME"); + + yield return WaitForMoreFrames("Waiting for frames produced by the screen changing", + framesBefore, 5); + + Log($"Received {Runtime.LiveStream.FramesReceived - framesBefore} frames while the screen was changing"); + Assert.AreEqual(string.Empty, Runtime.LiveStream.Errors); + Assert.IsTrue(Runtime.LiveStream.StopStreaming()); + } + + /// + /// Injected touch is verified by its effect: a swipe up from the bottom of the + /// screen changes what is displayed, and nothing else is touching the device, so + /// frames arriving afterwards can only be the result of our own gesture. + /// + [UnityTest] + public IEnumerator CanSendTouchToDevice() + { + // Start from the home screen so the swipe has something to act on. + SendKeyEvent("KEYCODE_HOME"); + + Runtime.LiveStream.StartStreaming(Device, null, maxSize: kMaxSize, maxFps: kMaxFps); + + yield return WaitForFirstFrame(); + + Assert.IsTrue(Runtime.LiveStream.ControlSupported, + "Expected the server to report that it can inject input"); + + // Let the home screen settle, so the frames counted below are the swipe's. + yield return WaitFor(1.5, "Letting the screen settle"); + + var framesBefore = Runtime.LiveStream.FramesReceived; + Runtime.LiveStream.SendTouch(AndroidLogcatLiveStream.TouchAction.Down, 0.5f, 0.85f); + for (var step = 1; step <= 10; step++) + { + Runtime.LiveStream.SendTouch(AndroidLogcatLiveStream.TouchAction.Move, + 0.5f, 0.85f - 0.55f * step / 10.0f); + yield return Waiting(); + } + Runtime.LiveStream.SendTouch(AndroidLogcatLiveStream.TouchAction.Up, 0.5f, 0.30f); + + yield return WaitForMoreFrames("Waiting for the screen to react to the injected swipe", framesBefore, 5); + + Log($"Injected swipe produced {Runtime.LiveStream.FramesReceived - framesBefore} frames"); + Assert.AreEqual(string.Empty, Runtime.LiveStream.Errors); + + var texture = Runtime.LiveStream.Texture; + ReportArtifact("after-swipe.png", texture); + + Assert.IsTrue(Runtime.LiveStream.StopStreaming()); + SendKeyEvent("KEYCODE_HOME"); + } + + /// + /// Same reasoning as the touch test: an injected key changes what is on screen, and + /// nothing else is touching the device, so the frames that follow are its effect. + /// + [UnityTest] + public IEnumerator CanSendKeysToDevice() + { + SendKeyEvent("KEYCODE_HOME"); + + Runtime.LiveStream.StartStreaming(Device, null, maxSize: kMaxSize, maxFps: kMaxFps); + + yield return WaitForFirstFrame(); + + Assert.IsTrue(Runtime.LiveStream.ControlSupported, + "Expected the server to report that it can inject input"); + + yield return WaitFor(1.5, "Letting the screen settle"); + + // Recents animates in, so it is a visible effect that needs no app installed. + var framesBefore = Runtime.LiveStream.FramesReceived; + // SendKeyPress is what the toolbar's Back / Home / Recents buttons call. + Runtime.LiveStream.SendKeyPress(AndroidKeyCode.APP_SWITCH); + + yield return WaitForMoreFrames("Waiting for the screen to react to the injected key", framesBefore, 5); + + Log($"Injected key produced {Runtime.LiveStream.FramesReceived - framesBefore} frames"); + + // Text goes through a different path on the device - KeyCharacterMap rather than + // a keycode - so it is worth exercising separately. It needs somewhere to land: + // neither the home screen nor Recents focuses anything that takes text, so + // typing there changes nothing and proves nothing. The settings search opens + // with its field focused. + Device.ActivityManager.StartAction("android.settings.APP_SEARCH_SETTINGS"); + yield return WaitFor(2.5, "Letting the search screen settle"); + + // Unique per run: the field keeps what was typed into it last time, so a + // fixed word would pass on a leftover value even if nothing arrived. + var typed = "unity" + UnityEngine.Random.Range(1000, 10000); + framesBefore = Runtime.LiveStream.FramesReceived; + Runtime.LiveStream.SendText(typed); + + yield return WaitForMoreFrames("Waiting for the screen to react to injected text", framesBefore, 2); + + Log($"Injected text produced {Runtime.LiveStream.FramesReceived - framesBefore} frames"); + Assert.AreEqual(string.Empty, Runtime.LiveStream.Errors); + + // Frames only say the screen moved. This says the characters arrived, and + // arrived as typed - uiautomator will not dump while the window is still + // animating, hence the retries. + var contents = string.Empty; + for (var attempt = 0; attempt < 3 && !contents.Contains(typed); attempt++) + { + if (attempt > 0) + yield return WaitFor(1.0, "Waiting for the search field to settle"); + contents = DumpWindowContents(); + } + StringAssert.Contains(typed, contents, "Expected the injected text in the focused field"); + + ReportArtifact("after-keys.png", Runtime.LiveStream.Texture); + + Assert.IsTrue(Runtime.LiveStream.StopStreaming()); + SendKeyEvent("KEYCODE_HOME"); + } + + /// + /// Scroll goes in as ACTION_SCROLL from a mouse source, which is a different + /// path on the device again - and one that needs a hover in front of it, see + /// `ScrollInjector`. Settings stands in for "something long enough to scroll", + /// since it is on every device. + /// + [UnityTest] + public IEnumerator CanScrollDeviceScreen() + { + Device.ActivityManager.StartOrResumePackage("com.android.settings"); + + Runtime.LiveStream.StartStreaming(Device, null, maxSize: kMaxSize, maxFps: kMaxFps); + + yield return WaitForFirstFrame(); + + Assert.IsTrue(Runtime.LiveStream.ControlSupported, + "Expected the server to report that it can inject input"); + + yield return WaitFor(2.0, "Letting Settings settle"); + + var framesBefore = Runtime.LiveStream.FramesReceived; + + // Left of centre and low down, which is inside the list on every device tried - + // the middle of the screen can be covered by a picture-in-picture window, which + // reacts to the hover and then has nothing to scroll. + for (var i = 0; i < 5; i++) + Runtime.LiveStream.SendScroll(0.3f, 0.7f, 0f, -3f); + + yield return WaitForMoreFrames("Waiting for the screen to react to the injected scroll", framesBefore, 3); + + Log($"Injected scroll produced {Runtime.LiveStream.FramesReceived - framesBefore} frames"); + Assert.AreEqual(string.Empty, Runtime.LiveStream.Errors); + + // Written out because a frame count only says the screen changed, not that it + // scrolled - the artifact is what shows the list moved. + ReportArtifact("after-scroll.png", Runtime.LiveStream.Texture); + + Assert.IsTrue(Runtime.LiveStream.StopStreaming()); + SendKeyEvent("KEYCODE_HOME"); + } + + /// + /// A device whose screen is off composes nothing, so a mirrored display produces no + /// frames and the view sits blank - which is why starting a stream wakes it. The + /// fixture wakes the device before every test, so this one puts it back to sleep to + /// have something to prove. + /// + [UnityTest] + public IEnumerator StreamsAfterWakingASleepingDevice() + { + Device.Sleep(); + + yield return WaitFor(1.5, "Letting the device fall asleep"); + + Runtime.LiveStream.StartStreaming(Device, null, maxSize: kMaxSize, maxFps: kMaxFps); + + yield return WaitForFirstFrame("Waiting for a frame from a device that was asleep"); + + Assert.AreEqual(string.Empty, Runtime.LiveStream.Errors); + Assert.IsTrue(Runtime.LiveStream.StopStreaming()); + } + + /// + /// Waits for the stream to deliver its first frame, which is the earliest a test + /// can tell that the server is up, connected and mirroring. + /// + /// Frames rather than Texture: the texture outlives a stream, so a restart + /// would see the previous one and wait for nothing. The frame count is reset by + /// every start. + /// + /// + /// Returns the wait instead of yielding it, so that callers keep the single level + /// of enumerator the test runner drives. + /// + /// + private IEnumerator WaitForFirstFrame(string what = "Waiting for the first frame") + { + return WaitForCondition(what, + () => Runtime.LiveStream.FramesReceived > 0, + kDefaultTimeout, + () => Runtime.LiveStream.Errors); + } + + /// + /// Waits for the screen to produce another frames, which + /// is how a test sees that something it injected actually did something. The device + /// only sends a frame when the screen changes, so this is the effect, not a clock. + /// + private IEnumerator WaitForMoreFrames(string what, int framesBefore, int count) + { + return WaitForCondition(what, + () => Runtime.LiveStream.FramesReceived >= framesBefore + count, + kDefaultTimeout, + () => $"Frames before {framesBefore}, now {Runtime.LiveStream.FramesReceived}. " + + $"{Runtime.LiveStream.Errors}"); + } + + /// What the device's windows hold right now, as uiautomator's xml. + private string DumpWindowContents() + { + const string onDevice = "/sdcard/unity-logcat-test-ui.xml"; + try + { + Runtime.Tools.ADB.Run(new[] + { + $"-s {Device.Id}", "shell", "uiautomator", "dump", onDevice + }, "Failed to dump the device's window contents"); + + return Runtime.Tools.ADB.Run(new[] + { + $"-s {Device.Id}", "shell", "cat", onDevice + }, "Failed to read the device's window contents"); + } + catch (Exception ex) + { + Log($"Failed to dump the device's window contents: {ex.Message}"); + return string.Empty; + } + finally + { + SafeDeleteOnDevice(Device, onDevice); + } + } + + private void SendKeyEvent(string keyCode) + { + Runtime.Tools.ADB.Run(new[] + { + $"-s {Device.Id}", + "shell", + "input", + "keyevent", + keyCode + }, $"Failed to send {keyCode} to the device"); + } + + [UnityTest] + public IEnumerator CanRestartStreaming() + { + for (var attempt = 0; attempt < 2; attempt++) + { + Runtime.LiveStream.StartStreaming(Device, null, maxSize: kMaxSize, maxFps: kMaxFps); + + yield return WaitForFirstFrame($"Waiting for a frame on attempt {attempt + 1}"); + + Assert.AreEqual(string.Empty, Runtime.LiveStream.Errors); + Assert.IsTrue(Runtime.LiveStream.StopStreaming()); + } + } + + [UnityTest] + public IEnumerator LiveStreamHandlesUnknownDisplay() + { + var result = AndroidLogcatLiveStream.Result.Success; + Runtime.LiveStream.StartStreaming(Device, r => result = r, + maxSize: kMaxSize, maxFps: kMaxFps, displayId: "12345"); + + yield return WaitForCondition("Waiting for the stream to fail", + () => result == AndroidLogcatLiveStream.Result.Failure, kDefaultTimeout); + + var errors = Runtime.LiveStream.Errors; + Assert.Greater(errors.Length, 0, "Expected an error explaining why the stream failed"); + Assert.IsFalse(Runtime.LiveStream.IsStreaming); + + Log(errors); + ReportArtifact("errors.txt", errors); + } +} diff --git a/com.unity.mobile.android-logcat/Tests/Editor/Integration/AndroidLogcatIntegrationLiveStream.cs.meta b/com.unity.mobile.android-logcat/Tests/Editor/Integration/AndroidLogcatIntegrationLiveStream.cs.meta new file mode 100644 index 00000000..907ec0d4 --- /dev/null +++ b/com.unity.mobile.android-logcat/Tests/Editor/Integration/AndroidLogcatIntegrationLiveStream.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f8b3067bcd99e9b46aa40e89fa178628 \ No newline at end of file diff --git a/com.unity.mobile.android-logcat/Tests/Editor/Integration/AndroidLogcatIntegrationScreenCapture.cs b/com.unity.mobile.android-logcat/Tests/Editor/Integration/AndroidLogcatIntegrationScreenCapture.cs index 10402144..8ac43dc3 100644 --- a/com.unity.mobile.android-logcat/Tests/Editor/Integration/AndroidLogcatIntegrationScreenCapture.cs +++ b/com.unity.mobile.android-logcat/Tests/Editor/Integration/AndroidLogcatIntegrationScreenCapture.cs @@ -5,6 +5,8 @@ using UnityEngine; using UnityEngine.TestTools; using System.IO; +using System.Linq; +using System.Text.RegularExpressions; [TestFixture] [RequiresAndroidDevice] @@ -30,6 +32,11 @@ private void Cleanup() AndroidLogcatUtilities.KillScreenRecordProcess(Runtime, Device); SafeDeleteOnDevice(Device, AndroidLogcatCaptureVideo.VideoPathOnDevice); SafeDeleteOnHost(VideoPathOnHost); + + // Start from an empty folder. Leftovers from an earlier run are still listed, + // and they carry another device's prefix or a name a test is about to reuse. + foreach (var screenshot in Runtime.CaptureScreenshot.GetScreenshots().ToArray()) + Runtime.CaptureScreenshot.DeleteScreenshot(screenshot.Path); } /// @@ -55,7 +62,157 @@ public IEnumerator CanGetScreenshot() Assert.Greater(texture.width, 10); Assert.Greater(texture.height, 10); - CopyToArtifacts("screenshot.png", Runtime.CaptureScreenshot.GetImagePath(Device)); + var path = Runtime.CaptureScreenshot.GetLatestImagePath(Device); + CopyToArtifacts("screenshot.png", path); + + var info = AndroidLogcatScreenshotInfo.Load(path); + Assert.IsNotNull(info, "Expected details to be saved beside the screenshot"); + Assert.AreEqual(Device.Id, info.deviceId); + Assert.AreEqual(Device.APILevel, info.apiLevel); + Assert.Greater(info.displayWidth, 0, $"Expected a display size, got {info.displayWidth}x{info.displayHeight}"); + CopyToArtifacts("screenshot.json", AndroidLogcatScreenshotInfo.PathFor(path)); + } + + /// + /// The screenshot list view depends on three things this checks: that a capture + /// shows up in the list at all (the cache has to be dropped, or the list never + /// grows), that the list is ordered by number, and that the newest capture becomes + /// the selected one. + /// + [UnityTest] + public IEnumerator ScreenshotsAreListedInOrder() + { + var prefix = AndroidLogcatUtilities.SanitizeFileName(Device.Id); + var before = CountScreenshotsOf(prefix); + + for (var i = 0; i < 2; i++) + { + yield return CaptureScreenshot($"Waiting for screenshot {i + 1}"); + } + + var screenshots = Runtime.CaptureScreenshot.GetScreenshots(); + Assert.AreEqual(before + 2, CountScreenshotsOf(prefix), + "Both captures should have appeared in the list"); + + // The whole order: files that still carry a device prefix first, grouped by + // device and ascending by number, then renamed ones by name. A renamed file + // has neither a prefix nor a number, so it takes part in neither comparison. + for (var i = 1; i < screenshots.Count; i++) + { + var previous = screenshots[i - 1]; + var current = screenshots[i]; + var previousRenamed = string.IsNullOrEmpty(previous.DevicePrefix); + var currentRenamed = string.IsNullOrEmpty(current.DevicePrefix); + var pair = $"'{previous.Name}' before '{current.Name}'"; + + if (previousRenamed != currentRenamed) + Assert.IsTrue(currentRenamed, $"Renamed screenshots should come last, found {pair}"); + else if (previousRenamed) + Assert.Less(string.Compare(previous.Name, current.Name, StringComparison.Ordinal), 0, + $"Renamed screenshots should be in name order, found {pair}"); + else if (previous.DevicePrefix == current.DevicePrefix) + Assert.Less(previous.Number, current.Number, + $"Numbering within a device should ascend, found {pair}"); + else + Assert.Less(string.Compare(previous.DevicePrefix, current.DevicePrefix, StringComparison.Ordinal), 0, + $"Devices should be grouped together, found {pair}"); + } + + foreach (var screenshot in screenshots) + { + Assert.IsTrue(File.Exists(screenshot.Path), $"{screenshot.Path} should exist"); + Assert.AreEqual(Path.GetFileNameWithoutExtension(screenshot.Path), screenshot.Name, + "The list label should be the file name without its extension"); + } + + var latest = Runtime.CaptureScreenshot.GetLatestImagePath(Device); + Assert.AreEqual(latest, Runtime.CaptureScreenshot.SelectedImagePath, + "The newest capture should be the selected one"); + } + + [UnityTest] + public IEnumerator CanDeleteScreenshot() + { + yield return CaptureScreenshot(); + + var path = Runtime.CaptureScreenshot.GetLatestImagePath(Device); + Assert.IsTrue(File.Exists(path)); + var before = Runtime.CaptureScreenshot.GetScreenshots().Count; + + Assert.IsTrue(Runtime.CaptureScreenshot.DeleteScreenshot(path), "Delete should have succeeded"); + + Assert.IsFalse(File.Exists(path), "The file should be gone from disk"); + Assert.IsNull(AndroidLogcatScreenshotInfo.Load(path), "Its details should go with it"); + Assert.AreEqual(before - 1, Runtime.CaptureScreenshot.GetScreenshots().Count, + "The list should have lost the row"); + // It was the displayed one, so the image is cleared rather than left pointing at + // a file that no longer exists. + Assert.AreEqual(string.Empty, Runtime.CaptureScreenshot.SelectedImagePath); + Assert.IsNull(Runtime.CaptureScreenshot.ImageTexture); + + // Deleting the same path again is not an error, it is just already gone. + Assert.IsTrue(Runtime.CaptureScreenshot.DeleteScreenshot(path)); + } + + /// + /// A renamed screenshot no longer matches <device>_<number>, so this also + /// covers the scan listing files that do not match the pattern - without that, a + /// rename would make the file disappear from the list. + /// + [UnityTest] + public IEnumerator CanRenameScreenshot() + { + yield return CaptureScreenshot(); + + var path = Runtime.CaptureScreenshot.GetLatestImagePath(Device); + var prefix = AndroidLogcatUtilities.SanitizeFileName(Device.Id); + var countBefore = Runtime.CaptureScreenshot.GetScreenshots().Count; + var ofDeviceBefore = CountScreenshotsOf(prefix); + var newName = "renamed-by-test"; + + Assert.IsTrue(Runtime.CaptureScreenshot.RenameScreenshot(path, newName), "Rename should have succeeded"); + + var renamed = Path.Combine(Path.GetDirectoryName(path), newName + ".png").Replace("\\", "/"); + Assert.IsFalse(File.Exists(path), "The old name should be gone"); + Assert.IsTrue(File.Exists(renamed), "The new name should exist"); + + // Still listed, still the displayed image, but no longer attributed to a device. + var screenshots = Runtime.CaptureScreenshot.GetScreenshots(); + Assert.AreEqual(countBefore, screenshots.Count, "The list should still hold it"); + Assert.AreEqual(ofDeviceBefore - 1, CountScreenshotsOf(prefix), + "A renamed file no longer counts towards its device"); + Assert.AreEqual(renamed, Runtime.CaptureScreenshot.SelectedImagePath, + "The displayed image should follow the rename"); + + Assert.IsNull(AndroidLogcatScreenshotInfo.Load(path), "The details should not be left behind"); + Assert.IsNotNull(AndroidLogcatScreenshotInfo.Load(renamed), "The details should follow the image"); + + var entry = screenshots.First(s => s.Path == renamed); + Assert.AreEqual(newName, entry.Name); + Assert.AreEqual(string.Empty, entry.DevicePrefix); + Assert.AreEqual(0, entry.Number); + + // Renaming onto a name that already exists must refuse rather than overwrite. + yield return CaptureScreenshot("Waiting for a second screenshot"); + var other = Runtime.CaptureScreenshot.GetLatestImagePath(Device); + + LogAssert.Expect(LogType.Error, new Regex("already exists")); + Assert.IsFalse(Runtime.CaptureScreenshot.RenameScreenshot(other, newName)); + Assert.IsTrue(File.Exists(other), "The file should be untouched after a refused rename"); + + LogAssert.Expect(LogType.Error, new Regex("not a usable file name")); + Assert.IsFalse(Runtime.CaptureScreenshot.RenameScreenshot(other, "bad/name")); + } + + private int CountScreenshotsOf(string devicePrefix) + { + var count = 0; + foreach (var screenshot in Runtime.CaptureScreenshot.GetScreenshots()) + { + if (screenshot.DevicePrefix == devicePrefix) + count++; + } + return count; } [UnityTest]