diff --git a/com.unity.mobile.android-logcat/CHANGELOG.md b/com.unity.mobile.android-logcat/CHANGELOG.md index 2a2d481a..0447e4c5 100644 --- a/com.unity.mobile.android-logcat/CHANGELOG.md +++ b/com.unity.mobile.android-logcat/CHANGELOG.md @@ -8,6 +8,14 @@ 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. - Tools->Open Terminal is now supported on Linux Editor. - Fixed Tools->Open Terminal not opening a window on Windows Editor when running on CoreCLR. 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..2b2de521 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,32 @@ 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. +## Capture Settings + +Use **Captures Folder** to choose where captures are written. Screenshots are written straight into it, under the name of the device they came from. The note underneath the field spells out where that is at the moment, which a relative path does not show on its own. + +A relative path starts at your project folder, which is how the default `Library/AndroidLogcat/Screenshots` is written, and follows the project when it moves or is opened on another machine. An absolute path is used as it stands, for captures kept outside the project. + +Select **Browse...** to pick a folder. A folder inside the project is stored relative to it, and one outside it is stored as an absolute path. + +The default, `Library/AndroidLogcat/Screenshots`, is local to your machine and is not part of a build, and Unity deletes `Library` from time to time, so choose a folder of your own for captures you want to keep. + +Changing the folder takes effect immediately. Captures already written stay where they are, and the [Capture list](screen-capture-window-reference.md#capture-list) shows what is in the new folder. + +### 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, turn **Live** off and on again in the [Device Screen Capture window](screen-capture-window-reference.md#capture-list). + +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 +93,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..e4d8bc6c 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..384e0993 --- /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, select **Reconnect** in the live view, or turn **Live** off and on again. + +## 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, select **Reconnect** in the live view, or turn **Live** off and on again. + +> [!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..a3dccf97 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 the **Live** row in the [Capture list](screen-capture-window-reference.md#capture-list), then select **Take Screenshot** in the [Live view details](screen-capture-window-reference.md#live-view-details). Pressing Ctrl+Shift+S (Cmd+Shift+S on macOS) does the same while the live view is showing. 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 list. +5. Right-click the screenshot's row, select **Copy To...**, and use the file explorer to save a copy of the image file elsewhere on your computer. Select several screenshots first to copy them all into one folder. + +Every screenshot you capture is kept, so you do not have to save one before taking the next. Screenshots are named `_.png` and are stored in your project, in `Library/AndroidLogcat/Screenshots`, unless you point [Captures Folder](android-logcat-settings.md#capture-settings) somewhere else. The default folder is local to your machine and is not part of your build, so use **Copy To...**, or a captures folder of your own, to keep a screenshot permanently. + +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..80b04519 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,24 @@ 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. | +| [Status bar](#status-bar) | Where the last screenshot or video was saved. | ## Toolbar @@ -26,11 +31,42 @@ The toolbar contains options to control the Screen Capture tool. | **Toolbar option** | **Description** | | ----------------------- | ------------------------------------------------------------ | | **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. | +| **Screen Capture Mode** | Specifies the screen capture mode to use. The options are:
• **Screenshot**: Switches the Screen Capture tool to screenshot mode. Take screenshots with **Take Screenshot** in the [Live view details](#live-view-details).
• **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** | Begins video recording.
This option only appears in video mode. To take a screenshot, use **Take Screenshot** in the [Live view details](#live-view-details), or press Ctrl+Shift+S (Cmd+Shift+S on macOS) while the live view is showing. | | **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. | +| **Open** | Opens the video using the application associated with the `.mp4` file extension.
This option only appears in video mode. To open a screenshot, right-click its row in the [Capture list](#capture-list). | +| **Save As** | Saves the video as a file on your computer.
This option only appears in video mode. To save a screenshot, right-click its row in the [Capture list](#capture-list); its details file is saved next to the copy. | + +## Capture list + +The left of the window holds the **Live** button and, under it, every capture you have taken, from every device. Select **Live** to mirror the device's screen, or select a capture to show it in the [Capture preview](#capture-preview). The Up and Down arrow keys move through the captures. Drag the divider between the list and the preview to resize the list. + +Select more than one capture to act on several at once: Shift and click extends the selection, Ctrl and click (Cmd on macOS) adds or removes one, and Ctrl+A (Cmd+A) selects all of them. Press Delete, or right-click and select **Delete**, to remove every selected capture in one step - which is how you clear the list. + +| **Item** | **Description** | +| ------------------ | ------------------------------------------------------------ | +| **Live** | The button above the list. Select it to view the selected device's screen live, and to take screenshots. Refer to [View the device screen live](screen-capture-live-stream.md). | +| A capture | Named after its file, without the `.png` extension. Captures are saved as you take them, so every one stays until you delete it. | + +Screenshots are stored in your project, in the folder set by [Captures Folder](android-logcat-settings.md#capture-settings), and are named `_.png`. The default folder is not part of your build, and deleting the `Library` folder deletes them with it. + +To work with the captures in the list: + +| **Action** | **Result** | +| ----------------------------------------- | ------------------------------------------------------- | +| Click a row | Selects that capture and shows it in the [Capture preview](#capture-preview). | +| Shift-click a row | Extends the selection from the last one you clicked. | +| Ctrl-click a row (Cmd-click on macOS) | Adds a capture to the selection, or removes it. | +| Press Ctrl+A (Cmd+A on macOS) | Selects every capture. | +| Double-click a row | Opens the image in the application associated with `.png`. | +| Press Delete (Cmd+Backspace on macOS) | Deletes every selected capture from disk, after asking you to confirm. | +| Right-click a row | Opens a menu with **Show In Explorer** (**Show In Finder** on macOS), **Open**, **Copy To...**, **Rename**, **Delete** and **Select All**. **Show In Explorer**, **Open** and **Rename** act on the row you clicked and are unavailable while several captures are selected. **Copy To...** and **Delete** act on the whole selection: copying one capture asks for a name, copying several asks for a folder to put them in. | +| Press F2 (Enter on macOS) | Renames the selected capture. Enter confirms the new name and Escape cancels. | + +To empty the list, select every capture with Ctrl+A (Cmd+A on macOS) and press Delete. + +> [!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 +84,85 @@ 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. | + +Below them, **Device Rotation** turns the device's screen: + +| **Button** | **Description** | +| ----------- | ------------------------------------------------------------ | +| **Auto** | Hands the rotation back to the device's accelerometer. | +| **0°** | Locks the device to its natural orientation. | +| **90°** | Locks the device rotated 90°. | +| **180°** | Locks the device rotated 180°. | +| **270°** | Locks the device rotated 270°. | + +On a foldable, **Device Fold** follows: **Fold**, **Unfold**, and **Half** on a device that reports half open as a state of its own, hold the device that way whatever its hinge is doing - which is how the other display is reached without touching the device. **Auto** hands it back to the hinge. The row does not appear for a device that does not fold. + +**Take Screenshot**, at the bottom, captures the device screen and adds it to the [Capture list](#capture-list). The screenshot is taken on the device rather than copied from the stream, so it is full resolution whatever the stream is scaled down to. Ctrl+Shift+S (Cmd+Shift+S on macOS) does the same while this window has focus and the **Live** row is selected - the shortcut follows the button, so it does nothing while a saved capture is on screen. + +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). + +## Status bar + +The bar along the bottom of the window reports what the window last did: + +* Where a capture was written, for example `Screenshot saved to Library/AndroidLogcat/Screenshots/_1.png`. A path inside your project is shown relative to it. +* What the live view is streaming, once its first frame arrives, for example `Live stream: Google Pixel 7 Pro (36081FDH3002Q8), 1080x2340 scaled to 232x512, up to 15 fps`. It is reported again when the streamed size changes, which happens when the device is rotated or a foldable is opened, and `Live stream stopped` when it ends. + +Videos are written to a temporary file that the next recording from the same device replaces, so use **Save As** to keep one. Screenshots are kept until you delete them. ## 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/AndroidLogcatCaptureList.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatCaptureList.cs new file mode 100644 index 00000000..5dc890a4 --- /dev/null +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatCaptureList.cs @@ -0,0 +1,1065 @@ +using System; +using System.Collections.Generic; +using System.IO; +using UnityEditor; +using UnityEngine; + +namespace Unity.Android.Logcat +{ + /// + /// The captures taken from a device - screenshots, and recordings once there are + /// any - with the Live button above them, and the splitter that separates the + /// column 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 AndroidLogcatCaptureList + { + static class Styles + { + internal static readonly GUIContent Live = new GUIContent("Live", + "Show the device screen live. Streaming stops when a capture is selected."); + // The Editor's own asset icons, so a row reads as what it holds before it + // is read. Through IconContent, which is what picks the icon for the skin. + static readonly GUIContent kImage = EditorGUIUtility.IconContent("Image Icon"); + static readonly GUIContent kVideo = EditorGUIUtility.IconContent("VideoPlayer Icon"); + + /// The icon for a capture, by what kind of file it is. + internal static Texture IconFor(string path) + { + var extension = Path.GetExtension(path); + var video = extension == ".mp4" || extension == ".webm"; + var icon = video ? kVideo : kImage; + return icon != null ? icon.image : null; + } + + internal static readonly GUIContent Captures = new GUIContent("Captures", + "Everything captured from a device, from every device. Shift click and " + + "Ctrl click select more than one; Ctrl+A selects all."); + 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 kIconSize = 16; + const float kIconMargin = 2; + // Air between the Live button and the captures under it. + const float kGroupGap = 5; + // How long a new capture's row is lit, and how bright it starts. + const double kFlashSeconds = 0.6; + const float kFlashStrength = 0.6f; + + 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; + + // The selection, by path: a rescan renumbers rows, where a path stays itself. + // The one being previewed is the capture screenshot's own selection, which is + // always one of these. + readonly HashSet m_Selected = new HashSet(); + // Where a shift click measures from. + string m_SelectionAnchor; + // The device the column was last drawn with. A context menu is answered after + // the frame that opened it, so it cannot be handed one then. + IAndroidLogcatDevice m_SelectedDevice; + // What the list held last pass, so a selection can be pruned of files that + // have gone without walking it on every repaint. + int m_KnownCount = -1; + // Whether the one-off "what should this window open on" decision has been made. + bool m_InitialSelectionDone; + + // The capture that has just landed, lit until m_FlashUntil. The window is + // showing the device rather than the list when one arrives, so the row is what + // says where it went. + string m_FlashPath; + double m_FlashUntil; + bool m_FlashNeedsScroll; + + // 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 AndroidLogcatCaptureList(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.CaptureListWidth < kMinWidth) + settings.CaptureListWidth = 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_Selected.Clear(); + m_SelectionAnchor = null; + 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(); + } + + /// + /// Lights a row, for a capture that has just been taken. The selection is left + /// alone: it was taken from the live view, which is worth staying on. + /// + internal void Flash(string path) + { + if (string.IsNullOrEmpty(path)) + return; + + m_FlashPath = path; + m_FlashUntil = EditorApplication.timeSinceStartup + kFlashSeconds; + m_FlashNeedsScroll = true; + m_Repaint(); + } + + /// + /// 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; + + AndroidLogcatStatsColumn.DrawBox(rc); + + // 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.CaptureListWidth, + Mathf.Max(0, rc.width - kSplitterWidth - kMinPreviewWidth)); + + // The fade runs on a clock, and a clock only moves here if something + // repaints. Expired centrally rather than in the row, which is not drawn + // at all while it is scrolled out of sight. + if (m_FlashPath != null) + { + if (EditorApplication.timeSinceStartup >= m_FlashUntil) + m_FlashPath = null; + else + m_Repaint(); + } + + var listRect = new Rect(rc.x, rc.y, width, rc.height); + var splitterRect = new Rect(listRect.xMax, rc.y, kSplitterWidth, rc.height); + + DoColumnGUI(listRect, device); + + // Clamped to the window, so storing it unmoved would shrink a saved width + // that this window is too narrow to show. + var before = width; + if (m_Splitter.DoGUI(splitterRect, ref width)) + { + if (!Mathf.Approximately(width, before)) + settings.CaptureListWidth = width; + m_Repaint(); + } + + return new Rect(splitterRect.xMax, rc.y, Mathf.Max(0, rc.xMax - splitterRect.xMax), rc.height); + } + + /// + /// The Live button, and under it the captures. Live is a button rather than a + /// row in the list: it is a view of the device rather than a file, it is not + /// one of the things a selection can span, and it stays put while the list + /// scrolls. + /// + void DoColumnGUI(Rect rc, IAndroidLogcatDevice device) + { + m_SelectedDevice = device; + var rowHeight = EditorGUIUtility.singleLineHeight; + + var liveRect = new Rect(rc.x, rc.y, rc.width, rowHeight); + DoLiveGUI(liveRect, device); + + var headerRect = new Rect(rc.x, liveRect.yMax + kGroupGap, rc.width, rowHeight); + GUI.Label(headerRect, Styles.Captures, EditorStyles.miniBoldLabel); + + var listRect = new Rect(rc.x, headerRect.yMax, rc.width, + Mathf.Max(0, rc.yMax - headerRect.yMax)); + DoCapturesGUI(listRect, device); + } + + void DoLiveGUI(Rect rc, IAndroidLogcatDevice device) + { + // A toggle for the pressed look, but it only ever switches on here: what + // switches it off is selecting a capture, the way one row of a list gives + // way to another rather than being clicked off. + EditorGUI.BeginChangeCheck(); + GUI.Toggle(rc, m_LiveSelected, Styles.Live, EditorStyles.miniButton); + if (EditorGUI.EndChangeCheck()) + SetLive(true, device); + } + + void DoCapturesGUI(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 capture is worth looking at + // whichever device it came from, and the file name says which that was. + var captures = m_CaptureScreenshot.GetScreenshots(); + + SyncPreview(); + PruneSelection(captures); + + // With nothing selected - a first run, or a domain reload, which does not + // remember what was selected - open on the live view rather than on an + // empty pane. Once per window: deleting the last capture deliberately + // leaves nothing selected rather than starting a stream. + if (!m_InitialSelectionDone) + { + m_InitialSelectionDone = true; + if (!m_LiveSelected && m_Selected.Count == 0) + SetLive(true, device); + } + + 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 = captures.Count * rowHeight; + var scrollbarWidth = contentHeight > inner.height ? kScrollbarWidth : 0; + var content = new Rect(0, 0, inner.width - scrollbarWidth, contentHeight); + var hasFocus = GUIUtility.keyboardControl == controlId; + + // A row that cannot be seen cannot say anything. + if (m_FlashNeedsScroll && m_FlashPath != null) + { + m_FlashNeedsScroll = false; + var flashed = IndexOf(captures, m_FlashPath); + if (flashed >= 0) + ScrollIntoView(flashed, rowHeight, inner.height); + } + + // Acted on after the loop: a menu has to be positioned in window + // coordinates rather than the scroll view's, and answering it can + // invalidate the cached list that is being iterated here. + string menuPath = null; + var menuScreenPosition = Vector2.zero; + + m_Scroll = GUI.BeginScrollView(inner, m_Scroll, content); + for (var row = 0; row < captures.Count; row++) + { + var path = captures[row].Path; + var rowRect = new Rect(0, row * rowHeight, content.width, rowHeight); + var isSelected = m_Selected.Contains(path); + + 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)); + } + + if (Event.current.type == EventType.Repaint && path == m_FlashPath) + { + var left = (float)((m_FlashUntil - EditorApplication.timeSinceStartup) / kFlashSeconds); + EditorGUI.DrawRect(rowRect, new Color(1, 1, 1, Mathf.Clamp01(left) * kFlashStrength)); + } + + var iconRect = new Rect(rowRect.x + 4, rowRect.y + (rowRect.height - kIconSize) * 0.5f, + kIconSize, kIconSize); + var labelRect = new Rect(iconRect.xMax + kIconMargin, rowRect.y, + Mathf.Max(0, rowRect.xMax - iconRect.xMax - kIconMargin), rowRect.height); + + var icon = Styles.IconFor(path); + if (icon != null && Event.current.type == EventType.Repaint) + GUI.DrawTexture(iconRect, icon, ScaleMode.ScaleToFit); + + if (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 = new GUIContent(captures[row].Name, + AndroidLogcatUtilities.ProjectRelativePath(path)); + var style = isSelected ? Styles.SelectedRow : EditorStyles.label; + GUI.Label(labelRect, label, style); + } + + // The whole row, icon included. 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 + && rowRect.Contains(Event.current.mousePosition) && path != m_RenamingPath) + { + GUIUtility.keyboardControl = controlId; + ClickRow(captures, path, Event.current, device); + + if (Event.current.clickCount == 2) + AndroidLogcatUtilities.OpenFile(path); + + Event.current.Use(); + } + + if (Event.current.type == EventType.ContextClick + && rowRect.Contains(Event.current.mousePosition)) + { + GUIUtility.keyboardControl = controlId; + // A click inside the selection acts on the whole of it; one + // outside moves the selection there first, as everywhere else. + if (!m_Selected.Contains(path)) + SelectOnly(path, device); + + menuPath = 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(); + } + } + + GUI.EndScrollView(); + + HandleKeys(controlId, captures, rowHeight, inner.height, device); + + if (menuPath != null) + ShowRowContextMenu(captures, menuPath, GUIUtility.ScreenToGUIPoint(menuScreenPosition)); + } + + // ------------------------------------------------------------------ + // Selection + // ------------------------------------------------------------------ + + /// + /// Drops paths whose files are gone, so that a selection cannot act on them. + /// Only when the list has changed length: the common case is that it has not. + /// + void PruneSelection(IReadOnlyList captures) + { + if (captures.Count == m_KnownCount) + return; + m_KnownCount = captures.Count; + + if (m_Selected.Count == 0) + return; + + m_Selected.RemoveWhere(path => IndexOf(captures, path) < 0); + if (m_SelectionAnchor != null && !m_Selected.Contains(m_SelectionAnchor)) + m_SelectionAnchor = null; + } + + /// + /// Points the selection at a renamed row: it is held by path, and the old one + /// would match nothing. + /// + void FollowRename(string path, string renamed) + { + if (string.IsNullOrEmpty(renamed) || !m_Selected.Remove(path)) + return; + + m_Selected.Add(renamed); + if (m_SelectionAnchor == path) + m_SelectionAnchor = renamed; + } + + static int IndexOf(IReadOnlyList captures, string path) + { + for (var i = 0; i < captures.Count; i++) + { + if (captures[i].Path == path) + return i; + } + return -1; + } + + /// + /// Watching the device and looking at a capture are the same piece of screen, + /// so turning one on turns the other off. + /// + void SetLive(bool live, IAndroidLogcatDevice device) + { + if (m_LiveSelected == live) + return; + + m_LiveSelected = live; + if (live) + { + m_Selected.Clear(); + m_SelectionAnchor = null; + m_CaptureScreenshot.SelectImage(null); + m_LiveStream.RestartStreaming(device); + } + else + { + m_LiveStream.StopStreaming(); + } + m_Repaint(); + } + + /// + /// Shift extends from the last click, Ctrl - Cmd on macOS - adds and removes + /// one, and a plain click replaces the selection, as lists elsewhere behave. + /// + void ClickRow(IReadOnlyList captures, string path, + Event e, IAndroidLogcatDevice device) + { + if (e.shift && m_SelectionAnchor != null) + SelectRange(captures, m_SelectionAnchor, path, device); + else if (EditorGUI.actionKey) + ToggleSelected(path, device); + else + SelectOnly(path, device); + } + + void SelectOnly(string path, IAndroidLogcatDevice device) + { + SetLive(false, device); + m_Selected.Clear(); + m_Selected.Add(path); + m_SelectionAnchor = path; + m_CaptureScreenshot.SelectImage(path); + m_Repaint(); + } + + void ToggleSelected(string path, IAndroidLogcatDevice device) + { + SetLive(false, device); + + if (!m_Selected.Remove(path)) + { + m_Selected.Add(path); + m_SelectionAnchor = path; + // The one just added is the one to look at. + m_CaptureScreenshot.SelectImage(path); + } + else if (m_CaptureScreenshot.SelectedImagePath == path) + { + // The previewed one was removed from the selection, so the preview + // moves to whatever is still selected, or to nothing. + m_CaptureScreenshot.SelectImage(m_Selected.Count > 0 ? First(m_Selected) : null); + } + + m_Repaint(); + } + + void SelectRange(IReadOnlyList captures, + string fromPath, string toPath, IAndroidLogcatDevice device) + { + var from = IndexOf(captures, fromPath); + var to = IndexOf(captures, toPath); + if (from < 0 || to < 0) + { + SelectOnly(toPath, device); + return; + } + + SetLive(false, device); + m_Selected.Clear(); + for (var i = Mathf.Min(from, to); i <= Mathf.Max(from, to); i++) + m_Selected.Add(captures[i].Path); + + // The anchor stays where the range started, so dragging the other end + // back and forth keeps measuring from the same row. + m_SelectionAnchor = fromPath; + m_CaptureScreenshot.SelectImage(toPath); + m_Repaint(); + } + + void SelectAll(IReadOnlyList captures, + IAndroidLogcatDevice device) + { + if (captures.Count == 0) + return; + + SetLive(false, device); + m_Selected.Clear(); + foreach (var capture in captures) + m_Selected.Add(capture.Path); + + m_SelectionAnchor = captures[0].Path; + m_CaptureScreenshot.SelectImage(captures[captures.Count - 1].Path); + m_Repaint(); + } + + static string First(HashSet paths) + { + foreach (var path in paths) + return path; + return null; + } + + /// The selection in the order it is shown, which is how it is deleted. + List SelectedInOrder(IReadOnlyList captures) + { + var paths = new List(m_Selected.Count); + foreach (var capture in captures) + { + if (m_Selected.Contains(capture.Path)) + paths.Add(capture.Path); + } + return paths; + } + + /// + /// What can be done with the row that was clicked, and with the selection it + /// belongs to. The single item entries act on that row: opening, saving and + /// renaming several at once means several dialogs, which is not what a menu + /// click asks for. + /// + void ShowRowContextMenu(IReadOnlyList captures, + string path, Vector2 position) + { + var single = m_Selected.Count <= 1; + var menu = new AndroidContextMenu(); + + menu.Add(ScreenshotContextMenu.ShowInFileBrowser, + AndroidLogcatUtilities.RevealInFileBrowserLabel, enabled: single, userData: path); + menu.Add(ScreenshotContextMenu.Open, "Open", enabled: single, userData: path); + menu.Add(ScreenshotContextMenu.CopyTo, + single ? "Copy To..." : $"Copy {m_Selected.Count} Captures To..."); + menu.Add(ScreenshotContextMenu.Rename, "Rename", enabled: single, userData: path); + menu.Add(ScreenshotContextMenu.Delete, + single ? "Delete" : $"Delete {m_Selected.Count} Captures"); + menu.Add(ScreenshotContextMenu.SelectAll, "Select All", + enabled: m_Selected.Count < captures.Count); + + 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. + if (m_CaptureScreenshot.RenameScreenshot(path, name, out var renamed)) + FollowRename(path, renamed); + 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.CopyTo: + // Read when the menu is answered, not when it was opened. + CopyTo(SelectedInOrder(m_CaptureScreenshot.GetScreenshots())); + break; + case ScreenshotContextMenu.Rename: + BeginRename((string)item.UserData); + break; + case ScreenshotContextMenu.Delete: + { + // Read again rather than carried in the menu item: the menu is + // answered long after it was opened. + var captures = m_CaptureScreenshot.GetScreenshots(); + ConfirmAndDelete(captures, SelectedInOrder(captures), m_SelectedDevice); + break; + } + case ScreenshotContextMenu.SelectAll: + SelectAll(m_CaptureScreenshot.GetScreenshots(), m_SelectedDevice); + break; + } + } + + /// + /// Copies captures somewhere they will be kept. One is copied under a name of + /// the user's choosing, several into a folder under the names they have - a + /// dialog per file is not what one click asks for. + /// + void CopyTo(IReadOnlyList paths) + { + if (paths.Count == 0) + return; + + // Captures are always copied from the Screenshot mode's remembered + // location, whatever mode the window happens to be in. + var settings = m_Runtime.UserSettings.CaptureSettings; + const AndroidLogcatScreenCaptureWindow.Mode mode = + AndroidLogcatScreenCaptureWindow.Mode.Screenshot; + + if (paths.Count == 1) + { + settings.SaveFileAs(mode, paths[0], "Copy Screenshot"); + return; + } + + var directory = EditorUtility.OpenFolderPanel($"Copy {paths.Count} Captures", + settings.GetLastSaveLocation(mode), string.Empty); + if (string.IsNullOrEmpty(directory)) + return; + + if (!ConfirmOverwrites(paths, directory)) + return; + + var copied = 0; + foreach (var path in paths) + { + if (AndroidLogcatUtilities.CopyInto(path, directory)) + copied++; + } + + if (copied > 0) + settings.SetLastSaveLocation(mode, Path.GetFullPath(directory)); + } + + /// + /// Asks once about the files already in the folder, rather than once each. + /// + static bool ConfirmOverwrites(IReadOnlyList paths, string directory) + { + var existing = 0; + foreach (var path in paths) + { + if (File.Exists(Path.Combine(directory, Path.GetFileName(path)))) + existing++; + } + + if (existing == 0) + return true; + + var what = existing == 1 ? "One capture" : $"{existing} captures"; + return EditorUtility.DisplayDialog("Copy Captures", + $"{what} of the same name already exist in {directory}.\n\nReplace them?", + "Replace", "Cancel"); + } + + /// + /// 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 captures, + 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; + + var e = Event.current; + var selectedRow = IndexOf(captures, m_CaptureScreenshot.SelectedImagePath); + + if (e.keyCode == KeyCode.A && EditorGUI.actionKey) + { + SelectAll(captures, device); + e.Use(); + return; + } + + if (IsRenameShortcut(e)) + { + // One name at a time: renaming is a text field on a row. + if (m_Selected.Count == 1 && selectedRow >= 0) + BeginRename(captures[selectedRow].Path); + e.Use(); + return; + } + + if (IsDeleteShortcut(e)) + { + // Used before the dialog, which pumps its own events. + e.Use(); + // Same confirmation as a row's own button, and it runs from the same + // place in the frame - after the scroll view has closed. + ConfirmAndDelete(captures, SelectedInOrder(captures), device); + return; + } + + if (captures.Count == 0) + return; + + var last = captures.Count - 1; + int next; + switch (e.keyCode) + { + // With nothing selected, Down starts at the top and Up at the bottom. + case KeyCode.Home: next = 0; break; + case KeyCode.End: next = last; break; + case KeyCode.UpArrow: next = selectedRow < 0 ? last : selectedRow - 1; break; + case KeyCode.DownArrow: next = selectedRow < 0 ? 0 : selectedRow + 1; break; + default: return; + } + + next = Mathf.Clamp(next, 0, last); + + if (next != selectedRow) + { + // Shift grows the selection the way shift clicking does, from wherever + // the last plain click left the anchor. + if (e.shift && m_SelectionAnchor != null) + SelectRange(captures, m_SelectionAnchor, captures[next].Path, device); + else + SelectOnly(captures[next].Path, device); + + ScrollIntoView(next, rowHeight, viewHeight); + } + e.Use(); + } + + /// + /// Deletes what was asked for, with one confirmation for the lot of it, and + /// leaves the selection on whatever took the place of the first one deleted. + /// + void ConfirmAndDelete(IReadOnlyList captures, + IReadOnlyList paths, IAndroidLogcatDevice device) + { + if (paths.Count == 0) + return; + + var what = paths.Count == 1 + ? $"Delete {Path.GetFileNameWithoutExtension(paths[0])}?" + : $"Delete {paths.Count} captures?"; + var files = paths.Count == 1 ? "The file is" : "The files are"; + if (!EditorUtility.DisplayDialog(paths.Count == 1 ? "Delete Capture" : "Delete Captures", + $"{what}\n\n{files} removed from disk and this cannot be undone.", + "Delete", "Cancel")) + return; + + var firstRow = IndexOf(captures, paths[0]); + foreach (var path in paths) + { + if (m_CaptureScreenshot.DeleteScreenshot(path)) + m_Selected.Remove(path); + } + m_SelectionAnchor = null; + + // Whatever took the place of the first one, else the one before it. + // Deliberately not Live, which would start streaming because a file was + // deleted. + var remaining = m_CaptureScreenshot.GetScreenshots(); + if (m_Selected.Count == 0 && remaining.Count > 0 && firstRow >= 0) + SelectOnly(remaining[Mathf.Clamp(firstRow, 0, remaining.Count - 1)].Path, device); + else if (remaining.Count == 0) + m_CaptureScreenshot.SelectImage(null); + + m_Repaint(); + } + + void ScrollIntoView(int index, float rowHeight, float viewHeight) + { + var top = 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/AndroidLogcatCaptureList.cs.meta b/com.unity.mobile.android-logcat/Editor/AndroidLogcatCaptureList.cs.meta new file mode 100644 index 00000000..27ec6578 --- /dev/null +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatCaptureList.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/AndroidLogcatCaptureScreenshot.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatCaptureScreenshot.cs index 18487fd0..68cd350c 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,371 @@ 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. + // Asked rather than remembered: the folder is a setting, and changing it has + // to take effect without restarting the Editor. + private readonly Func 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; + + // Which folder the cache above was read from, so that pointing the setting + // somewhere else is noticed without anything having to say so. + private string m_ScannedDirectory; + + // 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() + { + var directory = m_Directory(); + if (m_Screenshots == null || directory != m_ScannedDirectory) + { + m_Screenshots = ScanScreenshots(directory); + m_ScannedDirectory = directory; + } + 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. + /// Internal rather than private so a test can reserve without a device. + /// + internal 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(string directory) + { + var screenshots = new List(); + 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; + // A capture queued before the captures folder was changed reserved its + // name in the folder of the day, which is not this one. + if (!IsIn(directory, 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; + } + + static bool IsIn(string directory, string path) + { + var parent = Path.GetDirectoryName(path); + return parent != null && string.Equals(parent.Replace("\\", "/"), directory, + StringComparison.OrdinalIgnoreCase); + } + + /// + /// 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) + { + return RenameScreenshot(path, newName, out _); + } + + /// Where the capture ended up, empty when it did not move. + public bool RenameScreenshot(string path, string newName, out string renamed) + { + renamed = string.Empty; + 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) + { + renamed = 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; + + renamed = 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 +385,11 @@ public string GetImageExtension() return ".png"; } - internal AndroidLogcatCaptureScreenshot(AndroidLogcatRuntimeBase runtime) + internal AndroidLogcatCaptureScreenshot(AndroidLogcatRuntimeBase runtime, Func directory, bool keepHistory) { m_Runtime = runtime; + m_Directory = directory; + m_KeepHistory = keepHistory; } public void QueueScreenCapture(IAndroidLogcatDevice device, Action onCompleted) @@ -54,12 +397,28 @@ public void QueueScreenCapture(IAndroidLogcatDevice device, Action onCompleted) if (device == null) return; + // The folder is a setting, so it may not be usable - reported like any + // other capture failure rather than thrown out of the button. + string imagePath; + try + { + imagePath = AllocateImagePath(device); + } + catch (Exception ex) + { + m_Error = $"Could not use the captures folder '{m_Directory()}'.\n{ex.Message}"; + onCompleted?.Invoke(); + return; + } + m_Runtime.Dispatcher.Schedule( new AndroidLogcatCaptureScreenCaptureInput() { adb = m_Runtime.Tools.ADB, - imagePath = GetImagePath(device), + // Allocated here on the main thread, before the task is scheduled. + imagePath = imagePath, deviceId = device.Id, + device = device, onCompleted = onCompleted }, ExecuteScreenCapture, @@ -76,31 +435,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..d01286f8 100644 --- a/com.unity.mobile.android-logcat/Editor/AndroidLogcatContextMenu.cs +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatContextMenu.cs @@ -39,6 +39,17 @@ internal enum FilterContextMenu MatchCase } + internal enum ScreenshotContextMenu + { + None, + ShowInFileBrowser, + Open, + CopyTo, + Rename, + Delete, + SelectAll + } + class AndroidContextMenu { internal class MenuItemData diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatDevice.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatDevice.cs index 745cc154..67e919c4 100644 --- a/com.unity.mobile.android-logcat/Editor/AndroidLogcatDevice.cs +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatDevice.cs @@ -20,6 +20,33 @@ internal enum AndroidDeviceRotation Rotate270 = 3 } + /// + /// The states a foldable reports to Android, as `cmd device_state print-states` + /// lists them. Their numbers are the device's own, so they are asked for rather + /// than assumed. + /// + internal readonly struct FoldStates + { + internal static readonly FoldStates None = new FoldStates(-1, -1, -1); + + internal readonly int Folded; + internal readonly int Half; + internal readonly int Unfolded; + + internal FoldStates(int folded, int half, int unfolded) + { + Folded = folded; + Half = half; + Unfolded = unfolded; + } + + /// Folded and unfolded are what a foldable has to report to be one. + internal bool Supported => Folded >= 0 && Unfolded >= 0; + + /// Half open is a state of its own on some devices, and absent on others. + internal bool HasHalf => Half >= 0; + } + internal abstract class IAndroidLogcatDevice { internal IAndroidLogcatActivityManager m_ActivityManager; @@ -90,6 +117,18 @@ internal enum DeviceState /// internal abstract void SetRotation(AndroidDeviceRotation rotation); + /// + /// Which states this device folds between, or + /// for a device that does not fold. + /// + internal abstract FoldStates QueryFoldStates(); + + /// + /// Holds the device in one of the states reports. + /// A negative identifier hands it back to the device's own sensors. + /// + internal abstract void SetDeviceState(int identifier); + protected void ParseDisplaySize(string input, out Vector2 displaySize, out Vector2? overridenDisplaySize) { displaySize = Vector2.zero; @@ -316,6 +355,61 @@ void SendPowerKey(string keyCode, string failureMessage) } } + // identifier=0, name='CLOSED' - one line of `cmd device_state print-states`. + static readonly Regex DeviceStateRegex = + new Regex(@"identifier=(?\d+), name='(?[^']+)'"); + + internal override FoldStates QueryFoldStates() + { + if (m_Device == null || State != DeviceState.Connected) + return FoldStates.None; + + string output; + try + { + output = m_ADB.Run(new[] { $"-s {Id}", "shell", "cmd", "device_state", "print-states" }, + "Failed to query the device's states"); + } + catch (Exception ex) + { + // Every device without the service answers this way, so it is not an + // error - it is how a device says it does not fold. + AndroidLogcatInternalLog.Log($"Failed to query the device's states: {ex.Message}"); + return FoldStates.None; + } + + var folded = -1; + var half = -1; + var unfolded = -1; + foreach (Match match in DeviceStateRegex.Matches(output)) + { + var name = match.Groups["name"].Value.ToUpperInvariant(); + var id = int.Parse(match.Groups["id"].Value, CultureInfo.InvariantCulture); + + // Matched by name, because the numbers differ between devices. Half + // open is checked first: its name holds OPEN as well. + if (half < 0 && name.Contains("HALF")) + half = id; + else if (folded < 0 && name.Contains("CLOS")) + folded = id; + else if (unfolded < 0 && name.Contains("OPEN")) + unfolded = id; + } + + return new FoldStates(folded, half, unfolded); + } + + internal override void SetDeviceState(int identifier) + { + if (m_Device == null || State != DeviceState.Connected) + return; + + var state = identifier < 0 ? "reset" : identifier.ToString(CultureInfo.InvariantCulture); + var args = $"-s {Id} shell cmd device_state state {state}"; + AndroidLogcatInternalLog.Log($"adb {args}"); + m_ADB.Run(new[] { args }, $"Failed to set the device state to '{state}'"); + } + internal override void SetRotation(AndroidDeviceRotation rotation) { if (m_Device == null || State != DeviceState.Connected) diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatDeviceSelection.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatDeviceSelection.cs index d7c6524b..e0a54428 100644 --- a/com.unity.mobile.android-logcat/Editor/AndroidLogcatDeviceSelection.cs +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatDeviceSelection.cs @@ -10,6 +10,10 @@ namespace Unity.Android.Logcat /// class AndroidLogcatDeviceSelection : IDisposable { + // Fixed, so that everything to the right of it stays put when the selected + // device changes - device names differ wildly in length. + const float kWidth = 250; + AndroidLogcatRuntimeBase m_Runtime; IAndroidLogcatDevice[] m_Devices; int m_SelectedDeviceIdx; @@ -85,7 +89,9 @@ public void DoGUI() { var currentSelectedDevice = SelectedDevice == null ? "No device" : SelectedDevice.ShortDisplayName; - GUILayout.Label(new GUIContent(currentSelectedDevice, "Select android device"), AndroidLogcatStyles.toolbarPopup); + // The name is in the tooltip as well, since a long one is clipped. + GUILayout.Label(new GUIContent(currentSelectedDevice, $"{currentSelectedDevice}\nSelect android device"), + AndroidLogcatStyles.toolbarPopup, GUILayout.Width(kWidth)); var rect = GUILayoutUtility.GetLastRect(); if (Event.current.type == EventType.MouseDown && rect.Contains(Event.current.mousePosition)) 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..b70c5192 --- /dev/null +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatImageViewer.cs @@ -0,0 +1,307 @@ +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; + // Inset of the image inside the box it is drawn in. + const float kFramePadding = 2; + + 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); + + // Inside the box the caller drew - see AndroidLogcatStatsColumn.DrawBox. + area = new Rect(area.x + kFramePadding, area.y + kFramePadding, + Mathf.Max(0, area.width - kFramePadding * 2), + Mathf.Max(0, area.height - kFramePadding * 2)); + + // 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..33a14c48 --- /dev/null +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatLiveStream.cs @@ -0,0 +1,2302 @@ +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 = 1; + 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 Fold = new GUIContent("Fold", + "Hold the device folded, whatever its hinge is doing."); + internal static readonly GUIContent FoldHalf = new GUIContent("Half", + "Hold the device half open, whatever its hinge is doing. Only for devices that " + + "report it as a state of its own."); + internal static readonly GUIContent Unfold = new GUIContent("Unfold", + "Hold the device unfolded, whatever its hinge is doing."); + internal static readonly GUIContent FoldAuto = new GUIContent("Auto", + "Follow the hinge again."); + internal static readonly GUIContent DeviceCapture = new GUIContent("Device Capture", + "Captures taken on the device itself, at its own resolution, rather than copied " + + "from the stream."); + + internal static readonly GUIContent DeviceFold = new GUIContent("Device Fold", + "Folds and unfolds the device without touching it, which is how a foldable's " + + "two displays are reached from here. Only for devices that fold."); + + 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; + /// + /// A button the window puts under the stream's own controls, for doing + /// something with the device being watched - taking a screenshot of it, and + /// later recording it. The stream captures nothing itself, it only draws + /// these. + /// + internal readonly struct CaptureAction + { + internal readonly GUIContent Label; + internal readonly Action Action; + /// Asked on every repaint. Null is a button that is always enabled. + internal readonly Func Enabled; + + internal CaptureAction(GUIContent label, Action action, Func enabled = null) + { + Label = label; + Action = action; + Enabled = enabled; + } + } + + /// One per stream, so a reader cannot outlive its own session. + sealed class ReaderSession + { + internal volatile bool Stop; + } + + ReaderSession m_Session; + + // What the device folds between, asked for once per stream: it costs an adb + // call, and a device does not start folding half way through one. + FoldStates m_FoldStates = FoldStates.None; + bool m_FoldStatesQueried; + 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; + + /// + /// Raised when there is something new to say about the stream: the first frame + /// has arrived and named its size, that size changed under it - a rotation, a + /// foldable opening - or the stream ended. Always on the main thread. + /// + /// An event rather than a callback passed to , + /// because the row that starts a stream is not the window that reports it. + /// + /// + internal event Action StreamChanged; + + /// + /// The size frames are arriving at, and the size of the display they are + /// scaled down from. Both zero until the first frame says what they are. + /// + internal Vector2Int StreamSize => new Vector2Int(m_FrameWidth, m_FrameHeight); + + internal Vector2Int DisplaySize => new Vector2Int(m_DisplayWidth, m_DisplayHeight); + 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_FoldStates = FoldStates.None; + m_FoldStatesQueried = false; + 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); + StreamChanged?.Invoke(); + } + + 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))) + { + var resized = m_FrameWidth != width || m_FrameHeight != height + || m_DisplayWidth != displayWidth || m_DisplayHeight != displayHeight; + + m_FrameWidth = width; + m_FrameHeight = height; + m_DisplayWidth = displayWidth; + m_DisplayHeight = displayHeight; + + // The first frame counts as a resize, since everything was zero + // until it arrived. + if (resized) + StreamChanged?.Invoke(); + } + + // 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. + /// + /// + /// Buttons for the window's own capture actions, drawn under the stream's + /// controls. See . + /// + internal void DoGUI(Rect rc, IAndroidLogcatDevice selectedDevice, Action repaint, + IReadOnlyList captureActions = null) + { + // 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); + + var showingStream = m_Errors.Length == 0 && selectedDevice != null && m_Texture != null; + // A drag that was under way when the stream died has no mouse up coming: + // the image that handles one is not drawn any more, so the touch would stay + // down and the window would go on holding the mouse. + if (m_TouchDown && !showingStream) + ReleaseTouch(controlId); + + 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, captureActions); + } + + /// 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, IReadOnlyList captureActions) + { + AndroidLogcatStatsColumn.DrawBox(rc); + + // 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), captureActions); + } + + void DoStatsGUI(Rect rc, IReadOnlyList captureActions) + { + 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); + DoFoldGUI(rc, ref y); + DoCaptureActionsGUI(rc, ref y, captureActions); + 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; + } + + /// + /// The window's capture actions, under the stream's own controls: capturing the + /// device is usually wanted while watching it. + /// + void DoCaptureActionsGUI(Rect rc, ref float y, IReadOnlyList captureActions) + { + if (captureActions == null || captureActions.Count == 0) + return; + + var height = EditorGUIUtility.singleLineHeight; + y += kNavigationSpacing; + if (y + height * 2 > rc.yMax) + return; + + GUI.Label(new Rect(rc.x, y, rc.width, height), Styles.DeviceCapture, EditorStyles.miniBoldLabel); + y += height; + + foreach (var capture in captureActions) + { + if (y + height > rc.yMax) + return; + + var enabled = capture.Action != null && (capture.Enabled == null || capture.Enabled()); + EditorGUI.BeginDisabledGroup(!enabled); + if (GUI.Button(new Rect(rc.x, y, ButtonRowWidth(rc), height), + capture.Label, EditorStyles.miniButton)) + { + capture.Action(); + } + EditorGUI.EndDisabledGroup(); + + y += height; + } + } + + /// + /// Folds and unfolds a foldable, which is the only way to see its other + /// display from here. Nothing is drawn for a device that does not fold. + /// + void DoFoldGUI(Rect rc, ref float y) + { + if (!m_FoldStatesQueried && m_Device != null) + { + m_FoldStatesQueried = true; + m_FoldStates = m_Device.QueryFoldStates(); + } + + if (!m_FoldStates.Supported) + return; + + var height = EditorGUIUtility.singleLineHeight; + y += kNavigationSpacing; + if (y + height * 2 > rc.yMax) + return; + + GUI.Label(new Rect(rc.x, y, rc.width, height), Styles.DeviceFold, EditorStyles.miniBoldLabel); + y += height; + + EditorGUI.BeginDisabledGroup(m_Device == null); + + // Auto first, as in the rotation row above: the state the device is in + // until something here overrides it. Then the hinge's own order. + var count = m_FoldStates.HasHalf ? 4 : 3; + var width = Mathf.Floor(ButtonRowWidth(rc) / count); + + if (FoldButton(rc, y, width, 0, count, Styles.FoldAuto)) + SetDeviceState(-1); + if (FoldButton(rc, y, width, 1, count, Styles.Fold)) + SetDeviceState(m_FoldStates.Folded); + if (m_FoldStates.HasHalf && FoldButton(rc, y, width, 2, count, Styles.FoldHalf)) + SetDeviceState(m_FoldStates.Half); + if (FoldButton(rc, y, width, count - 1, count, Styles.Unfold)) + SetDeviceState(m_FoldStates.Unfolded); + + EditorGUI.EndDisabledGroup(); + y += height; + } + + static bool FoldButton(Rect rc, float y, float width, int index, int count, GUIContent label) + { + var style = index == 0 ? EditorStyles.miniButtonLeft + : index == count - 1 ? EditorStyles.miniButtonRight + : EditorStyles.miniButtonMid; + + return GUI.Button(new Rect(rc.x + width * index, y, width, + EditorGUIUtility.singleLineHeight), label, style); + } + + void SetDeviceState(int identifier) + { + try + { + m_Device.SetDeviceState(identifier); + } + catch (Exception ex) + { + AndroidLogcatInternalLog.Log($"Failed to set the device state: {InnermostMessage(ex)}"); + } + } + + 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..91ccc476 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(Func directory, bool keepHistory); protected abstract AndroidLogcatQueryLayout CreateQueryLayout(); + protected abstract AndroidLogcatLiveStream CreateLiveStream(); protected abstract void SaveEditorSettings(AndroidLogcatSettings settings); public virtual void Initialize() @@ -92,8 +110,15 @@ public virtual void Initialize() m_Tools = CreateAndroidTools(); m_DeviceQuery = CreateDeviceQuery(); m_CaptureVideo = CreateScreenRecorder(); - m_CaptureScreenshot = CreateScreenCapture(); + // The screenshots folder follows the setting, so it is read when it is + // needed rather than captured here. The Layout Viewer's is not a setting - + // it is a working folder of its own. + m_CaptureScreenshot = CreateScreenCapture( + () => AndroidLogcatUtilities.GetCapturesDirectory(Settings), true); + m_LayoutCaptureScreenshot = CreateScreenCapture( + AndroidLogcatUtilities.GetLayoutViewerDirectory, false); m_QueryLayout = CreateQueryLayout(); + m_LiveSream = CreateLiveStream(); m_Initialized = true; } @@ -170,9 +195,9 @@ protected override AndroidLogcatCaptureVideo CreateScreenRecorder() return new AndroidLogcatCaptureVideo(this); } - protected override AndroidLogcatCaptureScreenshot CreateScreenCapture() + protected override AndroidLogcatCaptureScreenshot CreateScreenCapture(Func directory, bool keepHistory) { - return new AndroidLogcatCaptureScreenshot(this); + return new AndroidLogcatCaptureScreenshot(this, directory, keepHistory); } protected override AndroidLogcatQueryLayout CreateQueryLayout() @@ -180,6 +205,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..8a8dbeb6 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 { @@ -17,9 +18,17 @@ class Styles public static GUIContent BitRate = new GUIContent("Bit Rate", "Toggle to overide bit rate (in Kbps), the default is 2000Kbps."); public static GUIContent DisplayId = new GUIContent("Display Id", "Toggle to overide the display to record, the default is primary display, enter 'adb shell dumpsys SurfaceFlinger--display - id' in the terminal for valid display IDs. If empty string is provided primary display will be used."); 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 Open = new GUIContent("Open", "Open the recorded video."); + public static GUIContent SaveAs = new GUIContent("Save As", "Save the recorded video as a file on your computer."); + static readonly GUIContent kMore = EditorGUIUtility.IconContent("_Menu"); + public static GUIContent Advanced = kMore != null && kMore.image != null + ? new GUIContent(kMore.image) + : new GUIContent("..."); + + public static GUIContent TakeScreenshot = new GUIContent("Take Screenshot", + "Capture the device screen and add it to the list. The screenshot comes from the device " + + "rather than from the stream, so it is full resolution whatever the stream is scaled to. " + + "Shortcut: Ctrl+Shift+S, Cmd+Shift+S on macOS, while this view is showing."); 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 +40,25 @@ 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 AndroidLogcatCaptureList m_CaptureList; + private AndroidLogcatStatusBar m_StatusBar; + + // Recording the screen will join this. + private AndroidLogcatLiveStream.CaptureAction[] m_CaptureActions; + + // Fixed, so that what follows it does not move when the mode changes. + const float kModeDropdownWidth = 90; + const float kAdvancedMenuWidth = 26; + private bool IsCapturing { get @@ -46,7 +66,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()); @@ -54,29 +74,17 @@ private bool IsCapturing } } - private string TemporaryPath - { - get - { - var mode = m_Runtime.UserSettings.CaptureSettings.Mode; - switch (mode) - { - case Mode.Screenshot: return m_CaptureScreenshot.GetImagePath(m_DeviceSelection.SelectedDevice); - case Mode.Video: return m_CaptureVideo.GetVideoPath(m_DeviceSelection.SelectedDevice); - default: - throw new NotImplementedException(mode.ToString()); - } - } - } - - private string ExtensionForDialog - { - get - { - return Path.GetExtension(TemporaryPath).Substring(1); - } - } - + /// + /// The recording of the selected device. Only video has one: a screenshot is + /// opened and saved from its own row in the list. + /// + private string VideoPath => m_CaptureVideo.GetVideoPath(m_DeviceSelection.SelectedDevice); + + // 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 +100,26 @@ private void OnEnable() m_Runtime.Closing += OnDisable; m_CaptureScreenshot = m_Runtime.CaptureScreenshot; m_CaptureVideo = m_Runtime.CaptureVideo; + m_LiveStream = m_Runtime.LiveStream; + m_LiveStream.StreamChanged += ReportStream; m_VideoPlayer = new AndroidLogcatVideoPlayer(); + m_CaptureList = new AndroidLogcatCaptureList(m_Runtime, Repaint); + // Nothing here connects to anything, so the bar carries the message alone. + m_StatusBar = new AndroidLogcatStatusBar() { ShowConnection = false }; + + // The buttons the live view draws for this window, in the order they + // appear. Each says for itself when it can run. + m_CaptureActions = new[] + { + new AndroidLogcatLiveStream.CaptureAction(Styles.TakeScreenshot, QueueScreenCapture, + () => CanCaptureScreenshot) + }; + + // 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 +131,27 @@ 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_CaptureList.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 - and + // keep reporting to a status bar that is gone with it. + m_CaptureList?.Deselect(); + + if (m_LiveStream != null) + { + m_LiveStream.StreamChanged -= ReportStream; + m_LiveStream = null; + } + if (m_VideoPlayer != null) { m_VideoPlayer.Dispose(); @@ -124,13 +167,118 @@ private void OnDisable() m_Runtime = null; } + /// + /// Whether a screenshot can be taken right now. Take Screenshot is drawn in the + /// live view and nowhere else, so the shortcut goes where the button goes. + /// + private bool CanCaptureScreenshot => + m_CaptureList != null && m_CaptureList.LiveSelected + && m_DeviceSelection.SelectedDevice != null && !m_CaptureScreenshot.IsCapturing; + private void QueueScreenCapture() { + // Whatever the bar said about the last one is about to be out of date. + m_StatusBar.Message = string.Empty; m_CaptureScreenshot.QueueScreenCapture(m_DeviceSelection.SelectedDevice, OnScreenshotCompleted); } + /// + /// Says what the stream is doing, whenever it has something new to say - see + /// . + /// + private void ReportStream() + { + var stream = m_LiveStream.StreamSize; + + if (!m_LiveStream.IsStreaming) + { + // The sizes outlive the stream, so a stream that ended having never + // delivered a frame has nothing here. It failed, and the view says so + // where the image would be. + if (stream.x > 0) + m_StatusBar.Message = "Live stream stopped"; + } + else + { + var display = m_LiveStream.DisplaySize; + var scaledFrom = display.x > 0 ? $"{display.x}x{display.y} scaled to " : string.Empty; + var device = m_DeviceSelection.SelectedDevice; + var name = device != null ? device.ShortDisplayName : "device"; + + m_StatusBar.Message = $"Live stream: {name}, {scaledFrom}{stream.x}x{stream.y}, " + + $"up to {m_Runtime.Settings.LiveStreamMaxFps} fps"; + } + + Repaint(); + } + + /// + /// Says where a capture landed, in the status bar. A path inside the project + /// is shown relative to it, which is short enough to read at a glance. + /// + private void ReportSaved(string what, string path) + { + if (string.IsNullOrEmpty(path)) + { + m_StatusBar.Message = string.Empty; + return; + } + + var full = Path.GetFullPath(path).Replace("\\", "/"); + var project = Path.GetFullPath(Path.Combine(Application.dataPath, "..")).Replace("\\", "/") + "/"; + var shown = full.StartsWith(project, StringComparison.OrdinalIgnoreCase) + ? full.Substring(project.Length) + : full; + + m_StatusBar.Message = $"{what} saved to '{shown}'"; + } + + /// + /// 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 button's own conditions, plus the mode: Video mode has no live view. + if (m_Runtime == null || m_DeviceSelection == null) + return; + if (m_Runtime.UserSettings.CaptureSettings.Mode != Mode.Screenshot) + return; + if (!CanCaptureScreenshot) + 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_CaptureList?.InvalidatePreview(); + + // Set after the capture was integrated, so this is the new screenshot - + // and empty when the capture failed, where the error is reported already. + var captured = m_CaptureScreenshot.SelectedImagePath; + ReportSaved("Screenshot", captured); + m_CaptureList?.Flash(captured); + var texture = m_CaptureScreenshot.ImageTexture; if (texture != null) maxSize = new Vector2(Math.Max(texture.width, position.width), texture.height + kButtonAreaHeight); @@ -139,13 +287,46 @@ void OnScreenshotCompleted() void OnVideoCompleted(AndroidLogcatCaptureVideo.Result result, string videoPath) { + ReportSaved("Video", result == AndroidLogcatCaptureVideo.Result.Success ? videoPath : null); + if (result == AndroidLogcatCaptureVideo.Result.Success) m_VideoPlayer.Play(videoPath); + Repaint(); } 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, + GUILayout.Width(kModeDropdownWidth)); + 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_CaptureList.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_CaptureList?.InvalidatePreview(); + Repaint(); } void OnGUI() @@ -161,11 +342,17 @@ 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(); + + // Video mode's settings take only the height they need, so the bar would + // sit under the last control rather than at the bottom of the window. + // Screenshot mode claims what is left for the list and the preview, and + // must not be made to share it. + if (m_Runtime.UserSettings.CaptureSettings.Mode == Mode.Video) + GUILayout.FlexibleSpace(); + + m_StatusBar?.DoGUI(); EditorGUILayout.EndVertical(); } @@ -179,10 +366,42 @@ private void DoToolbarGUI() DoModeGUI(); DoCaptureGUI(); - DoOpenGUI(); - DoSaveAsGUI(); + + // Remove this once Live view is reimplemented in Video mode, or the button is moved to the video player. + if (m_Runtime.UserSettings.CaptureSettings.Mode == Mode.Video) + { + DoOpenGUI(); + DoSaveAsGUI(); + } + + GUILayout.FlexibleSpace(); + DoAdvancedMenuGUI(); EditorGUILayout.EndHorizontal(); + + var toolbarRect = GUILayoutUtility.GetLastRect(); + if (Event.current.type == EventType.Repaint) + { + EditorGUI.DrawRect(new Rect(toolbarRect.x, toolbarRect.yMax - 1, toolbarRect.width, 1), + EditorGUIUtility.isProSkin ? new Color(0.14f, 0.14f, 0.14f) : new Color(0.6f, 0.6f, 0.6f)); + } + } + + /// + /// The settings this window has, reached the way the Package Manager's toolbar + /// reaches its own: a menu at the right hand end of the toolbar. + /// + private void DoAdvancedMenuGUI() + { + var rect = GUILayoutUtility.GetRect(Styles.Advanced, AndroidLogcatStyles.toolbarButton, + GUILayout.Width(kAdvancedMenuWidth)); + if (!GUI.Button(rect, Styles.Advanced, AndroidLogcatStyles.toolbarButton)) + return; + + var menu = new GenericMenu(); + menu.AddItem(EditorGUIUtility.TrTextContent("Preferences"), false, + () => SettingsService.OpenUserPreferences(AndroidLogcatSettingsProvider.kSettingsPath)); + menu.DropDown(rect); } private void DoProgressGUI() @@ -198,10 +417,8 @@ private void DoCaptureGUI() switch (m_Runtime.UserSettings.CaptureSettings.Mode) { case Mode.Screenshot: - EditorGUI.BeginDisabledGroup(m_CaptureScreenshot.IsCapturing); - if (GUILayout.Button(Styles.CaptureScreenshot, AndroidLogcatStyles.toolbarButton)) - QueueScreenCapture(); - EditorGUI.EndDisabledGroup(); + // Taking a screenshot lives in the live view, beside the screen it + // captures - see DoScreenshotGUI. break; case Mode.Video: if (m_CaptureVideo.IsRecording) @@ -237,6 +454,7 @@ private void DoCaptureGUI() if (vs.DisplayIdEnabled && !string.IsNullOrEmpty(vs.DisplayId)) displayId = vs.DisplayId; + m_StatusBar.Message = string.Empty; m_CaptureVideo.StartRecording(m_DeviceSelection.SelectedDevice, OnVideoCompleted, timeLimit, videoSizeX, videoSizeY, bitRate, displayId); } } @@ -247,62 +465,80 @@ private void DoCaptureGUI() private void DoOpenGUI() { - EditorGUI.BeginDisabledGroup(!File.Exists(TemporaryPath)); + EditorGUI.BeginDisabledGroup(!File.Exists(VideoPath)); 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(VideoPath); EditorGUI.EndDisabledGroup(); } private void DoSaveAsGUI() { - EditorGUI.BeginDisabledGroup(!File.Exists(TemporaryPath)); + EditorGUI.BeginDisabledGroup(!File.Exists(VideoPath)); 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, VideoPath, "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_CaptureList.DoGUI(rc, m_DeviceSelection.SelectedDevice); + + if (m_CaptureList.LiveSelected) + { + // The developer-mode details are drawn by DoGUI, in the info column. + m_LiveStream.DoGUI(imageRect, m_DeviceSelection.SelectedDevice, Repaint, m_CaptureActions); + // 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_CaptureList.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 +559,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/AndroidLogcatSettings.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatSettings.cs index f33ea155..49f5392c 100644 --- a/com.unity.mobile.android-logcat/Editor/AndroidLogcatSettings.cs +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatSettings.cs @@ -17,6 +17,19 @@ 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); + + // Where captures go unless told otherwise, relative to the project. Library is + // local to the machine and outside any build, which suits output that is cheap + // to produce again. + internal const string kDefaultCaptureOutputDirectory = "Library/AndroidLogcat/Screenshots"; + internal static readonly string[] kAddressResolveRegex = { @"\s*#\d{2}\s*pc\s(?
[a-fA-F0-9xX]+).*\/(?\S+)\/(?lib.*)\.so(?:.*\(BuildId:\s*(?\S+)\))?", @@ -57,6 +70,20 @@ internal class AndroidLogcatSettings [SerializeField] private int m_MaxExitedPackagesToShow; + // A relative path starts at the project folder, an absolute one is where it + // says - see AndroidLogcatUtilities.GetCapturesDirectory. + [SerializeField] + private string m_CaptureOutputDirectory; + + [SerializeField] + private int m_LiveStreamMaxSize; + + [SerializeField] + private int m_LiveStreamQuality; + + [SerializeField] + private int m_LiveStreamMaxFps; + internal int MemoryRequestIntervalMS { set @@ -128,6 +155,80 @@ internal int MaxExitedPackagesToShow return m_MaxExitedPackagesToShow; } } + /// + /// Where screenshots, and later videos, are written. A relative path starts at + /// the project folder; empty falls back to + /// . + /// + internal string CaptureOutputDirectory + { + set + { + var corrected = string.IsNullOrEmpty(value) ? string.Empty : value.Trim().Replace("\\", "/"); + if (m_CaptureOutputDirectory == corrected) + return; + m_CaptureOutputDirectory = corrected; + InvokeOnSettingsChanged(); + } + get + { + return m_CaptureOutputDirectory ?? string.Empty; + } + } + + 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 +309,8 @@ internal void Reset() m_MessageFont = AssetDatabase.LoadAssetAtPath("Packages/com.unity.mobile.android-logcat/Editor/Fonts/consola.ttf"); m_MessageFontSize = 11; m_MaxExitedPackagesToShow = 4; + m_CaptureOutputDirectory = kDefaultCaptureOutputDirectory; + ResetLiveStreamFields(); if (Enum.GetValues(typeof(Priority)).Length != 6) throw new Exception("Unexpected length of Priority enum."); @@ -227,6 +330,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 +438,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..32148d34 100644 --- a/com.unity.mobile.android-logcat/Editor/AndroidLogcatSettingsProvider.cs +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatSettingsProvider.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using UnityEditor; using UnityEngine; @@ -20,6 +21,21 @@ 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 capturesFolder = new GUIContent("Captures Folder", + "Where screenshots are written. A relative path starts at the project folder; empty " + + $"means {AndroidLogcatSettings.kDefaultCaptureOutputDirectory}, which is local to " + + "this machine and not part of a build."); + public static GUIContent browse = new GUIContent("Browse...", "Pick the captures folder."); + + 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 +81,35 @@ public override void OnGUI(string searchContext) settings.MaxExitedPackagesToShow = EditorGUILayout.IntSlider(Styles.maxExitedPackageToShow, settings.MaxExitedPackagesToShow, 1, 100); + GUILayout.Space(20); + EditorGUILayout.LabelField("Capture Settings", EditorStyles.boldLabel); + DoCapturesFolderGUI(settings); + + GUILayout.Space(10); + EditorGUILayout.LabelField("Live Stream", EditorStyles.miniBoldLabel); + // 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, " + + "turn Live off and on again in the Device Screen Capture window.", + 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 +127,74 @@ public override void OnGUI(string searchContext) GUILayout.EndHorizontal(); } + /// + /// The folder captures are written to, as a field that can be typed into, a + /// browse button, and a way back to the default. The resolved folder is shown + /// underneath, since an empty setting and a relative path both say little on + /// their own. + /// + void DoCapturesFolderGUI(AndroidLogcatSettings settings) + { + // Applied after the row, so that opening a modal dialog cannot leave the + // layout half drawn. Empty means the dialog was cancelled. + string picked = null; + + EditorGUILayout.BeginHorizontal(); + var folder = EditorGUILayout.TextField(Styles.capturesFolder, settings.CaptureOutputDirectory); + + if (GUILayout.Button(Styles.browse, EditorStyles.miniButton, GUILayout.Width(70))) + picked = EditorUtility.OpenFolderPanel(Styles.capturesFolder.text, + AndroidLogcatUtilities.GetCapturesDirectory(settings), string.Empty); + + EditorGUILayout.EndHorizontal(); + + if (!string.IsNullOrEmpty(picked)) + SetCapturesFolder(settings, picked); + else + settings.CaptureOutputDirectory = folder; + + if (AndroidLogcatUtilities.TryResolveCapturesDirectory(settings.CaptureOutputDirectory, + out var resolved)) + { + EditorGUILayout.HelpBox( + "The path can be relative to the project folder, or an absolute path. " + + $"Captures are written to '{resolved}'.", + MessageType.None); + } + else + { + EditorGUILayout.HelpBox( + "This path cannot be used. Remove any quotes or characters a folder name " + + $"cannot contain. Captures go to '{AndroidLogcatUtilities.GetCapturesDirectory(null)}' " + + "until it is fixed.", + MessageType.Warning); + } + } + + /// + /// Stores a folder picked in the file browser, which arrives absolute. One + /// inside the project is stored relative to it, so that the project can move + /// or be opened elsewhere and still find it. + /// + static void SetCapturesFolder(AndroidLogcatSettings settings, string picked) + { + var full = Path.GetFullPath(picked).Replace("\\", "/"); + var project = AndroidLogcatUtilities.ProjectDirectory() + "/"; + + settings.CaptureOutputDirectory = full.StartsWith(project, StringComparison.OrdinalIgnoreCase) + ? full.Substring(project.Length) + : full; + } + + /// + /// 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..07f0c53f --- /dev/null +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatStatsColumn.cs @@ -0,0 +1,76 @@ +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; + + /// + /// The box an image and its column sit in, which is the same help box a message + /// in that area is drawn in - so the view keeps its shape whether it is showing + /// a stream, a capture, or a line of text. + /// + internal static void DrawBox(Rect area) + { + if (Event.current.type == EventType.Repaint) + EditorStyles.helpBox.Draw(area, false, false, false, false); + } + + /// + /// 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/AndroidLogcatStatusBar.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatStatusBar.cs index 524d6c44..af638585 100644 --- a/com.unity.mobile.android-logcat/Editor/AndroidLogcatStatusBar.cs +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatStatusBar.cs @@ -5,13 +5,22 @@ namespace Unity.Android.Logcat { internal class AndroidLogcatStatusBar { + const float kMargin = 10.0f; + public string Message { set; get; } public bool Connected { set; get; } + /// + /// Whether the bar leads with the connection state. A window that has nothing + /// to connect to - the Screen Capture window - shows only its message. + /// + public bool ShowConnection { set; get; } + public AndroidLogcatStatusBar() { Message = String.Empty; + ShowConnection = true; } public void DoGUI() @@ -21,13 +30,16 @@ public void DoGUI() { AndroidLogcatStyles.statusBarBackground.Draw(rc, false, true, false, false); } - rc.x += 10.0f; - rc.width -= 10.0f; - var msg = Connected ? "Connected" : "Disconnected"; + rc.x += kMargin; + rc.width -= kMargin * 2; + var msg = string.Empty; + if (ShowConnection) + msg = Connected ? "Connected" : "Disconnected"; if (!string.IsNullOrEmpty(Message)) { - msg += " : "; + if (msg.Length > 0) + msg += " : "; msg += Message; } diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatStyles.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatStyles.cs index 5fbb3f1d..4d5869db 100644 --- a/com.unity.mobile.android-logcat/Editor/AndroidLogcatStyles.cs +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatStyles.cs @@ -49,8 +49,8 @@ static class AndroidLogcatStyles public const int kStatusBarFontSize = 13; public const int kLStatusBarFixedHeight = kStatusBarFontSize + 5; - public static GUIStyle statusBarBackground = new GUIStyle("AppToolbar") { fixedHeight = kStatusBarFontSize }; - public static GUIStyle statusLabel = new GUIStyle("AppToolbar") { fontSize = kStatusBarFontSize, fixedHeight = kLStatusBarFixedHeight, richText = true }; + public static GUIStyle statusBarBackground = new GUIStyle("toolbar") { fixedHeight = kLStatusBarFixedHeight }; + public static GUIStyle statusLabel = new GUIStyle("label") { fontSize = kStatusBarFontSize, fixedHeight = kLStatusBarFixedHeight, richText = true }; public const int kTagEntryFontSize = 11; public const int kTagEntryFixedHeight = kTagEntryFontSize + 7; diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatUserSettings.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatUserSettings.cs index 2f7edd09..53d70da3 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 CaptureListWidth; [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, + CaptureListWidth = AndroidLogcatCaptureList.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 c891654e..0c62b966 100644 --- a/com.unity.mobile.android-logcat/Editor/AndroidLogcatUtilities.cs +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatUtilities.cs @@ -88,6 +88,234 @@ 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)); + } + + /// + /// Copies a capture into a folder, keeping its name, and takes its details file + /// along with it. Overwrites what is there: the caller has already asked. + /// + /// False if the copy failed, which is logged. + public static bool CopyInto(string sourcePath, string directory) + { + var target = Path.Combine(directory, Path.GetFileName(sourcePath)).Replace("\\", "/"); + try + { + File.Copy(sourcePath, target, true); + } + catch (Exception ex) + { + Debug.LogError($"Failed to copy '{sourcePath}' to '{target}'.\n{ex.Message}"); + return false; + } + + AndroidLogcatScreenshotInfo.CopyBeside(sourcePath, target); + return true; + } + + /// + /// The folder captures are written to - screenshots today, videos later. + /// Settings can point it anywhere: a relative path starts at the project + /// folder, an absolute one is where it says. A settings object that is null, + /// or holds nothing, is the default described above. + /// + public static string GetCapturesDirectory(AndroidLogcatSettings settings = null) + { + var configured = settings != null ? settings.CaptureOutputDirectory : null; + return TryResolveCapturesDirectory(configured, out var resolved) + ? resolved + : ResolveCapturesDirectory(AndroidLogcatSettings.kDefaultCaptureOutputDirectory); + } + + /// + /// Where a configured captures folder resolves to, or false when it resolves + /// nowhere. The setting is free text, and this runs from OnGUI. + /// + public static bool TryResolveCapturesDirectory(string configured, out string resolved) + { + if (string.IsNullOrEmpty(configured)) + configured = AndroidLogcatSettings.kDefaultCaptureOutputDirectory; + + try + { + resolved = ResolveCapturesDirectory(configured); + return true; + } + catch (Exception) + { + resolved = null; + return false; + } + } + + static string ResolveCapturesDirectory(string configured) + { + var path = Path.IsPathRooted(configured) + ? configured + : Path.Combine(ProjectDirectory(), configured); + return Path.GetFullPath(path).Replace("\\", "/"); + } + + /// The folder holding Assets, which is what a project path starts at. + public static string ProjectDirectory() + { + return Path.GetFullPath(Path.Combine(Application.dataPath, "..")).Replace("\\", "/"); + } + + /// + /// 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(ProjectDirectory(), "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; @@ -498,7 +726,7 @@ public static void OpenTerminal(string workingDirectory) switch (Application.platform) { case RuntimePlatform.WindowsEditor: - System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("cmd.exe") { WorkingDirectory = workingDirectory, UseShellExecute = true }); + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("cmd.exe") { UseShellExecute = true, WorkingDirectory = workingDirectory }); break; case RuntimePlatform.OSXEditor: var pathsToCheck = new[] diff --git a/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatFakeDevice.cs b/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatFakeDevice.cs index ef20bedd..4a54dc8b 100644 --- a/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatFakeDevice.cs +++ b/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatFakeDevice.cs @@ -44,6 +44,13 @@ internal override void SetRotation(AndroidDeviceRotation rotation) { } + /// Nothing that folds. + internal override FoldStates QueryFoldStates() => FoldStates.None; + + internal override void SetDeviceState(int identifier) + { + } + internal override void QueryDisplaySize(out Vector2 displaySize, out Vector2? overridenDisplaySize) { ParseDisplaySize(m_DisplayInfo, out displaySize, out overridenDisplaySize); diff --git a/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatGeneralTests.cs b/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatGeneralTests.cs index 23364e54..8020d6da 100644 --- a/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatGeneralTests.cs +++ b/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatGeneralTests.cs @@ -1,12 +1,361 @@ 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 { + /// + /// A captures folder of the calling test's own. The configured one belongs to + /// whoever is running the tests, and holds their captures. + /// + static string CreateCapturesFolder() + { + var directory = System.IO.Path.Combine(System.IO.Path.GetTempPath(), + "AndroidLogcatTests", Guid.NewGuid().ToString("N")).Replace("\\", "/"); + System.IO.Directory.CreateDirectory(directory); + return directory; + } + + /// + /// 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 directory = CreateCapturesFolder(); + var captureScreenshot = new AndroidLogcatCaptureScreenshot(runtime, () => directory, true); + + 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 + { + System.IO.Directory.Delete(directory, true); + } + } + finally + { + runtime.Shutdown(); + } + } + + /// + /// A capture reserves its name before the file exists, so that a second one queued + /// behind it cannot take the same number. Changing the captures folder while one is + /// in flight must not carry that reservation into the new folder's listing. + /// + [Test] + public void AReservationIsListedOnlyInTheFolderItWasMadeIn() + { + var runtime = new AndroidLogcatTestRuntime(); + runtime.Initialize(); + var first = CreateCapturesFolder(); + var second = CreateCapturesFolder(); + try + { + var directory = first; + var captureScreenshot = new AndroidLogcatCaptureScreenshot(runtime, () => directory, true); + var device = new AndroidLogcatFakeDevice90("unittest-device"); + + var reserved = captureScreenshot.AllocateImagePath(device); + StringAssert.StartsWith(first, reserved, "The reservation belongs to the folder in use"); + Assert.IsTrue(captureScreenshot.GetScreenshots().Any(s => s.Path == reserved), + "A capture in flight is listed, so the next one cannot reuse its number"); + + // As changing the setting does, which rescans on the next listing. + directory = second; + + Assert.IsFalse(captureScreenshot.GetScreenshots().Any(s => s.Path == reserved), + "A reservation made in another folder has nothing to do with this one"); + } + finally + { + System.IO.Directory.Delete(first, true); + System.IO.Directory.Delete(second, true); + 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 directory = CreateCapturesFolder(); + var captureScreenshot = new AndroidLogcatCaptureScreenshot(runtime, () => directory, true); + + 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 + { + System.IO.Directory.Delete(directory, true); + } + } + 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 +374,192 @@ 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 = CreateCapturesFolder(); + + 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.Directory.Delete(directory, true); + } + } + + /// + /// 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..7c8ef734 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(Func 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..0efa43f3 --- /dev/null +++ b/com.unity.mobile.android-logcat/Tests/Editor/Integration/AndroidLogcatIntegrationLiveStream.cs @@ -0,0 +1,504 @@ +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. + yield return WaitForWindowContents(typed); + StringAssert.Contains(typed, m_WindowContents, + "Expected the injected text in the focused field"); + + // A character the device's keyboard cannot produce - no stock layout has a dead + // key for an ogonek - must cost only itself. The server retries text character + // by character for exactly this reason: one of these used to take the whole + // message with it, everything typable in it included. + var mixed = "unity" + UnityEngine.Random.Range(1000, 10000); + framesBefore = Runtime.LiveStream.FramesReceived; + Runtime.LiveStream.SendText("\u0105" + mixed); + + yield return WaitForMoreFrames("Waiting for the screen to react to the mixed text", + framesBefore, 2); + Assert.AreEqual(string.Empty, Runtime.LiveStream.Errors); + + // Passes either way: a layout that can type the ogonek puts it in front, and the + // typable part has to arrive regardless. + yield return WaitForWindowContents(mixed); + StringAssert.Contains(mixed, m_WindowContents, + "Expected the typable part of the text to arrive, with only the rest dropped"); + + 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 last dumped. A field because a + /// coroutine cannot hand anything back. + /// + private string m_WindowContents = string.Empty; + + /// + /// Dumps the device's windows until shows up in them, + /// or three attempts have gone by. uiautomator will not dump while a window is + /// still animating, and a field that was just typed into may not have redrawn yet, + /// hence the retries. + /// + private IEnumerator WaitForWindowContents(string expected) + { + m_WindowContents = string.Empty; + for (var attempt = 0; attempt < 3 && !m_WindowContents.Contains(expected); attempt++) + { + if (attempt > 0) + yield return WaitFor(1.0, "Waiting for the search field to settle"); + m_WindowContents = DumpWindowContents(); + } + } + + /// 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..c365164c 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] @@ -12,6 +14,43 @@ internal class AndroidLogcatRuntimeIntegrationScreenCapture : AndroidLogcatInteg { private string VideoPathOnHost => Runtime.CaptureVideo.GetVideoPath(Device); + // These tests take captures and clear the folder between them, so they get one of + // their own rather than whatever the machine running them has configured. It sits + // with the rest of the test output - LocalTestResults, or the artifacts path on a + // build agent - which is where anyone looking at a failure is already looking. + private string m_CapturesFolder; + + [OneTimeSetUp] + protected void RedirectCaptures() + { + m_CapturesFolder = Runtime.Settings.CaptureOutputDirectory; + Runtime.Settings.CaptureOutputDirectory = + Path.Combine(Workspace.GetAritfactsPath(), "Captures").Replace("\\", "/"); + Runtime.CaptureScreenshot.InvalidateScreenshots(); + Log($"Captures go to '{AndroidLogcatUtilities.GetCapturesDirectory(Runtime.Settings)}'"); + } + + [OneTimeTearDown] + protected void RestoreCaptures() + { + // Resolved before the setting goes back, and removed whole: what is in there + // was all written by these tests. + var folder = AndroidLogcatUtilities.GetCapturesDirectory(Runtime.Settings); + + Runtime.Settings.CaptureOutputDirectory = m_CapturesFolder; + Runtime.CaptureScreenshot.InvalidateScreenshots(); + + try + { + if (Directory.Exists(folder)) + Directory.Delete(folder, true); + } + catch (Exception ex) + { + Log($"Failed to remove '{folder}': {ex.Message}"); + } + } + [SetUp] protected void Init() { @@ -30,6 +69,12 @@ private void Cleanup() AndroidLogcatUtilities.KillScreenRecordProcess(Runtime, Device); SafeDeleteOnDevice(Device, AndroidLogcatCaptureVideo.VideoPathOnDevice); SafeDeleteOnHost(VideoPathOnHost); + + // Start from an empty folder - the fixture's own, so this takes nothing with + // it that these tests did not write. Leftovers from an earlier run are still + // listed, and they carry another device's prefix or a name a test reuses. + foreach (var screenshot in Runtime.CaptureScreenshot.GetScreenshots().ToArray()) + Runtime.CaptureScreenshot.DeleteScreenshot(screenshot.Path); } /// @@ -55,7 +100,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]