From 560d10ed0048f31e2f94e807403201d6cb5c2bda Mon Sep 17 00:00:00 2001 From: dungngminh Date: Thu, 22 Oct 2026 01:54:51 +0700 Subject: [PATCH] feat(app): stream Android emulators over the emulator's gRPC API Emulators on macOS now stream like Android Studio's embedded emulator: EmulatorController.streamScreenshot with the MMAP transport writes raw RGB888 frames into a file the new SharedFrameTexture (simutil_h264) maps and converts to a BGRA texture, sized to the view and re-requested on resize/rotation. Input goes through streamInputEvent (touch with rotation undone, W3C keys, text); recording uses adb emu screenrecord and ffmpeg. The endpoint comes from the emulator's discovery file (avd/running/pid_.ini, matched by port.serial). Emulators without gRPC, physical devices and Linux/Windows keep scrcpy. Measured on a Pixel 7 Pro AVD (-gpu host) while scrolling: 60.0 fps steady vs 51-59 fps with scrcpy, emulator CPU ~10% vs ~15%. Stubs are generated from a trimmed copy of emulator_controller.proto (melos run protoc). --- AGENTS.md | 10 +- app/CHANGELOG.md | 8 + .../stream/android/emulator_video_view.dart | 111 +++ app/lib/src/stream/device_keyboard.dart | 37 +- app/lib/src/stream/streams_cubit.dart | 27 +- app/lib/src/ui/shared/session_view.dart | 2 + app/pubspec.yaml | 2 +- app/test/device_keyboard_test.dart | 21 + docs/ai/architecture.md | 6 +- packages/simutil_adb/CHANGELOG.md | 6 + packages/simutil_adb/analysis_options.yaml | 4 + packages/simutil_adb/lib/simutil_adb.dart | 5 +- .../lib/src/emulator/emulator_endpoint.dart | 103 +++ .../src/emulator/emulator_grpc_session.dart | 300 ++++++ .../lib/src/emulator/emulator_input.dart | 61 ++ .../generated/emulator_controller.pb.dart | 864 ++++++++++++++++++ .../generated/emulator_controller.pbenum.dart | 140 +++ .../generated/emulator_controller.pbgrpc.dart | 123 +++ packages/simutil_adb/lib/src/ffmpeg.dart | 37 + .../lib/src/scrcpy/scrcpy_session.dart | 47 +- .../protos/emulator_controller.proto | 124 +++ packages/simutil_adb/pubspec.yaml | 2 + .../test/emulator/emulator_endpoint_test.dart | 120 +++ .../test/emulator/emulator_input_test.dart | 64 ++ packages/simutil_h264/CHANGELOG.md | 6 + packages/simutil_h264/lib/simutil_h264.dart | 55 ++ .../Sources/simutil_h264/SharedFrames.swift | 133 +++ .../simutil_h264/SimutilH264Plugin.swift | 77 +- packages/simutil_h264/pubspec.yaml | 2 +- pubspec.lock | 56 ++ pubspec.yaml | 7 + 31 files changed, 2484 insertions(+), 76 deletions(-) create mode 100644 app/lib/src/stream/android/emulator_video_view.dart create mode 100644 packages/simutil_adb/lib/src/emulator/emulator_endpoint.dart create mode 100644 packages/simutil_adb/lib/src/emulator/emulator_grpc_session.dart create mode 100644 packages/simutil_adb/lib/src/emulator/emulator_input.dart create mode 100644 packages/simutil_adb/lib/src/emulator/generated/emulator_controller.pb.dart create mode 100644 packages/simutil_adb/lib/src/emulator/generated/emulator_controller.pbenum.dart create mode 100644 packages/simutil_adb/lib/src/emulator/generated/emulator_controller.pbgrpc.dart create mode 100644 packages/simutil_adb/lib/src/ffmpeg.dart create mode 100644 packages/simutil_adb/protos/emulator_controller.proto create mode 100644 packages/simutil_adb/test/emulator/emulator_endpoint_test.dart create mode 100644 packages/simutil_adb/test/emulator/emulator_input_test.dart create mode 100644 packages/simutil_h264/macos/simutil_h264/Sources/simutil_h264/SharedFrames.swift diff --git a/AGENTS.md b/AGENTS.md index bd88d82..f0050f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,7 +64,8 @@ stdio with the user: - Long-lived streaming / recording processes: the scrcpy server in `ScrcpySession` (`simutil_adb`), `simctl io recordVideo` in `SimulatorRecorder` (`simutil_apple`), and the grid recorder's ffmpeg in - `app/`. + `app/`. The emulator gRPC streams in `EmulatorGrpcSession` are not + processes either. **Testing:** import `package:simutil_core/testing.dart` (`FakeCommandExec`, `FakeDeviceService`, device fixtures) or `package:simutil_plugins/testing.dart` (`FakePluginRunner`) instead of spawning real processes. Never call `Process.run` inside service unit tests @@ -111,8 +112,11 @@ installs Flutter for that. UI from the tokens and primitives, not Material styling. Screens live under `app/lib/src/ui/` (`devices/`, `streams/`), shared pieces in `app/lib/src/ui/shared/`. -- Streaming goes through `DeviceSession` (`simutil_core`): `ScrcpySession` - for Android, `IosSimSession` (app) over the native `SimStreamPlugin` in +- Streaming goes through `DeviceSession` (`simutil_core`): on macOS + `EmulatorGrpcSession` for Android emulators (the emulator's gRPC API, + raw RGB888 frames through a shared file shown by `SharedFrameTexture` in + `simutil_h264`), `ScrcpySession` for other Android devices and as the + fallback, `IosSimSession` (app) over the native `SimStreamPlugin` in `app/macos/Runner/SimStream.swift` (private CoreSimulator/SimulatorKit, adapted from serve-sim). macOS runs unsandboxed with library validation off for that. diff --git a/app/CHANGELOG.md b/app/CHANGELOG.md index 9c4fdf7..f1ddbe6 100644 --- a/app/CHANGELOG.md +++ b/app/CHANGELOG.md @@ -21,6 +21,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 turns the camera off. Agents can pass a camera to launch_app over MCP. Uses serve-sim's camera injector. +### Changed + +- Android emulators stream straight from the emulator on macOS, like + Android Studio's embedded emulator: a steady 60 fps with less load on + the emulator, sized to the view. Older emulators, ones started without + gRPC, and other platforms keep using scrcpy. Emulator recordings are + made by the emulator itself. + ### Fixed - Android emulators started for streaming (headless) use your computer's diff --git a/app/lib/src/stream/android/emulator_video_view.dart b/app/lib/src/stream/android/emulator_video_view.dart new file mode 100644 index 0000000..c5e7bc0 --- /dev/null +++ b/app/lib/src/stream/android/emulator_video_view.dart @@ -0,0 +1,111 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/widgets.dart'; +import 'package:simutil_adb/simutil_adb.dart'; +import 'package:simutil_core/simutil_core.dart'; +import 'package:simutil_h264/simutil_h264.dart'; + +import 'package:simutil_app/src/stream/stream_fps.dart'; + +/// Shows an [EmulatorGrpcSession]'s raw frames on a shared-file texture, +/// requested at the size the view takes on screen (like Android Studio). +class EmulatorVideoView extends StatefulWidget { + /// Shows [session]'s screen. + const EmulatorVideoView({super.key, required this.session}); + + /// The session whose screen is shown. + final EmulatorGrpcSession session; + + @override + State createState() => _EmulatorVideoViewState(); +} + +class _EmulatorVideoViewState extends State { + SharedFrameTexture? _texture; + StreamSubscription? _frames; + Timer? _resize; + bool _disposed = false; + + /// Requested frame size in physical pixels; [_wanted] is the latest + /// layout's, applied after it settles for a moment. + (int, int)? _requested; + (int, int)? _wanted; + + /// Layout changes (window resize, rotation) settle before a new request. + static const _settle = Duration(milliseconds: 150); + + void _want(Size size, double pixelRatio) { + final live = widget.session.status; + if (live is! SessionLive || size.isEmpty) return; + final wanted = ( + min((size.width * pixelRatio).round(), live.width), + min((size.height * pixelRatio).round(), live.height), + ); + if (wanted == _wanted) return; + _wanted = wanted; + _resize?.cancel(); + _resize = Timer(_requested == null ? Duration.zero : _settle, _request); + } + + Future _request() async { + final wanted = _wanted; + if (wanted == null || wanted == _requested || _disposed) return; + _requested = wanted; + final (width, height) = wanted; + await _frames?.cancel(); + _frames = null; + final capacity = width * height * 3; + var texture = _texture; + try { + if (texture == null) { + texture = await SharedFrameTexture.create(capacity); + if (_disposed) return unawaited(texture.dispose()); + setFrameCounter(widget.session, texture.framesShown); + setState(() => _texture = texture); + } else { + await texture.resize(capacity); + } + } catch (_) { + _requested = null; + return; + } + if (_disposed || _requested != wanted) return; + _frames = widget.session + .frames(path: texture.path, width: width, height: height) + .listen( + (frame) => texture!.present(frame.width, frame.height), + // The session reports a lost emulator through its status. + onError: (Object _) {}, + ); + } + + @override + void dispose() { + _disposed = true; + _resize?.cancel(); + unawaited(_frames?.cancel()); + if (_texture case final texture?) { + removeFrameCounter(widget.session, texture.framesShown); + unawaited(texture.dispose()); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final pixelRatio = MediaQuery.devicePixelRatioOf(context); + return LayoutBuilder( + builder: (context, constraints) { + _want(constraints.biggest, pixelRatio); + return switch (_texture) { + final texture? => Texture( + textureId: texture.textureId, + filterQuality: FilterQuality.low, + ), + null => const ColoredBox(color: Color(0xFF000000)), + }; + }, + ); + } +} diff --git a/app/lib/src/stream/device_keyboard.dart b/app/lib/src/stream/device_keyboard.dart index 1de5f7b..ae28d0a 100644 --- a/app/lib/src/stream/device_keyboard.dart +++ b/app/lib/src/stream/device_keyboard.dart @@ -5,23 +5,24 @@ import 'package:simutil_adb/simutil_adb.dart'; import 'package:simutil_app/src/stream/ios/ios_sim_session.dart'; import 'package:simutil_core/simutil_core.dart'; -/// Non-printing keys: Android `AKEYCODE_*` and the character the iOS -/// simulator plugin types for it (AppKit function-key characters). -final _keys = { - LogicalKeyboardKey.enter: (66, '\n'), - LogicalKeyboardKey.numpadEnter: (66, '\n'), - LogicalKeyboardKey.backspace: (67, '\b'), - LogicalKeyboardKey.delete: (112, ''), - LogicalKeyboardKey.tab: (61, '\t'), - LogicalKeyboardKey.escape: (111, '\x1b'), - LogicalKeyboardKey.arrowUp: (19, ''), - LogicalKeyboardKey.arrowDown: (20, ''), - LogicalKeyboardKey.arrowLeft: (21, ''), - LogicalKeyboardKey.arrowRight: (22, ''), - LogicalKeyboardKey.home: (122, ''), - LogicalKeyboardKey.end: (123, ''), - LogicalKeyboardKey.pageUp: (92, ''), - LogicalKeyboardKey.pageDown: (93, ''), +/// Non-printing keys: Android `AKEYCODE_*`, the character the iOS +/// simulator plugin types for it (AppKit function-key characters) and the +/// W3C key value the Android emulator's gRPC API takes. +final _keys = { + LogicalKeyboardKey.enter: (66, '\n', 'Enter'), + LogicalKeyboardKey.numpadEnter: (66, '\n', 'Enter'), + LogicalKeyboardKey.backspace: (67, '\b', 'Backspace'), + LogicalKeyboardKey.delete: (112, '', 'Delete'), + LogicalKeyboardKey.tab: (61, '\t', 'Tab'), + LogicalKeyboardKey.escape: (111, '\x1b', 'Escape'), + LogicalKeyboardKey.arrowUp: (19, '', 'ArrowUp'), + LogicalKeyboardKey.arrowDown: (20, '', 'ArrowDown'), + LogicalKeyboardKey.arrowLeft: (21, '', 'ArrowLeft'), + LogicalKeyboardKey.arrowRight: (22, '', 'ArrowRight'), + LogicalKeyboardKey.home: (122, '', 'Home'), + LogicalKeyboardKey.end: (123, '', 'End'), + LogicalKeyboardKey.pageUp: (92, '', 'PageUp'), + LogicalKeyboardKey.pageDown: (93, '', 'PageDown'), }; /// Forwards a host key press to [session]: special keys as key codes, @@ -42,6 +43,8 @@ bool sendKeyToDevice(DeviceSession session, KeyEvent event) { switch (session) { case final ScrcpySession s: special != null ? s.pressKey(special.$1) : s.typeText(text!); + case final EmulatorGrpcSession s: + special != null ? s.pressKey(special.$3) : s.typeText(text!); case final IosSimSession s: unawaited(s.typeText(special?.$2 ?? text!)); default: diff --git a/app/lib/src/stream/streams_cubit.dart b/app/lib/src/stream/streams_cubit.dart index 8ec5ee5..a35d82e 100644 --- a/app/lib/src/stream/streams_cubit.dart +++ b/app/lib/src/stream/streams_cubit.dart @@ -263,8 +263,9 @@ class StreamsCubit extends CubitSignal { } } -/// Default factory: scrcpy for Android, the native capture for iOS -/// simulators and USB devices. +/// Default factory: the emulator's own gRPC API for Android emulators on +/// macOS (scrcpy when it has none), scrcpy for other Android devices, the +/// native capture for iOS simulators and USB devices. DeviceSessionFactory defaultSessionFactory({ required CommandExec exec, required String Function() adbPath, @@ -274,6 +275,16 @@ DeviceSessionFactory defaultSessionFactory({ return (device) async { switch (device.os) { case DeviceOs.android: + if (Platform.isMacOS && device.type.isSimulator) { + if (await _emulatorEndpoint(device.id) case final endpoint?) { + return EmulatorGrpcSession( + serial: device.id, + endpoint: endpoint, + adbPath: adbPath(), + exec: exec, + ); + } + } final install = await (scrcpy ??= _locateScrcpy(exec)); final options = settings(device); return ScrcpySession( @@ -301,6 +312,18 @@ DeviceSessionFactory defaultSessionFactory({ }; } +/// The emulator writes its discovery file a moment after adb lists it. +/// ponytail: macOS only until Linux / Windows get a shared-frame texture. +Future _emulatorEndpoint(String serial) async { + for (var attempt = 0; attempt < 10; attempt++) { + if (await EmulatorEndpoint.find(serial) case final endpoint?) { + return endpoint; + } + await Future.delayed(const Duration(milliseconds: 500)); + } + return null; +} + /// Version of `assets/scrcpy-server` (Genymobile/scrcpy release, Apache-2.0). const _bundledScrcpyVersion = '4.0'; diff --git a/app/lib/src/ui/shared/session_view.dart b/app/lib/src/ui/shared/session_view.dart index 2116c55..6092436 100644 --- a/app/lib/src/ui/shared/session_view.dart +++ b/app/lib/src/ui/shared/session_view.dart @@ -1,5 +1,6 @@ import 'package:flutter/widgets.dart'; import 'package:simutil_adb/simutil_adb.dart'; +import 'package:simutil_app/src/stream/android/emulator_video_view.dart'; import 'package:simutil_app/src/stream/android/scrcpy_video_view.dart'; import 'package:simutil_app/src/stream/ios/ios_device_session.dart'; import 'package:simutil_app/src/stream/ios/ios_sim_session.dart'; @@ -14,6 +15,7 @@ class SessionView extends StatelessWidget { @override Widget build(BuildContext context) => switch (session) { final ScrcpySession s => ScrcpyVideoView(session: s), + final EmulatorGrpcSession s => EmulatorVideoView(session: s), IosSimSession(:final textureId?) || IosDeviceSession(:final textureId?) => Texture(textureId: textureId, filterQuality: FilterQuality.low), _ => const SizedBox.shrink(), diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 5b0180b..f3c4875 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -19,7 +19,7 @@ dependencies: simutil_adb: ^1.4.0 simutil_apple: ^1.4.0 simutil_core: ^1.3.0 - simutil_h264: ^0.1.0 + simutil_h264: ^0.2.0 simutil_shared: ^1.1.0 window_manager: ^0.5.0 cupertino_icons: ^2.0.0 diff --git a/app/test/device_keyboard_test.dart b/app/test/device_keyboard_test.dart index 2365fae..523cf38 100644 --- a/app/test/device_keyboard_test.dart +++ b/app/test/device_keyboard_test.dart @@ -16,6 +16,19 @@ class _Scrcpy implements ScrcpySession { dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } +class _Emulator implements EmulatorGrpcSession { + final sent = []; + + @override + void typeText(String text) => sent.add(text); + + @override + void pressKey(String key) => sent.add('<$key>'); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + KeyEvent _down(LogicalKeyboardKey key, {String? character}) => KeyDownEvent( physicalKey: PhysicalKeyboardKey.keyA, logicalKey: key, @@ -52,4 +65,12 @@ void main() { expect(sendKeyToDevice(s, _down(LogicalKeyboardKey.shiftLeft)), isFalse); expect(s.sent, ['a', 67, 21]); }); + + test('Android emulator: text as text, special keys as W3C key values', () { + final s = _Emulator(); + sendKeyToDevice(s, _down(LogicalKeyboardKey.keyA, character: 'a')); + sendKeyToDevice(s, _down(LogicalKeyboardKey.enter)); + sendKeyToDevice(s, _down(LogicalKeyboardKey.arrowUp, character: '')); + expect(s.sent, ['a', '', '']); + }); } diff --git a/docs/ai/architecture.md b/docs/ai/architecture.md index 2fdd114..ecac358 100644 --- a/docs/ai/architecture.md +++ b/docs/ai/architecture.md @@ -15,7 +15,7 @@ package has its own version. | Package | Role | | --- | --- | | [packages/simutil_core](../../packages/simutil_core/) | `Device`, `DeviceService`, `DeviceSession`, `CommandExec`, `IsolateRunner` (no user data); `testing.dart` fakes | -| [packages/simutil_adb](../../packages/simutil_adb/) | `AndroidDeviceService`, wireless pairing, mDNS discovery, `LogcatHelper`, `ScrcpySession` (scrcpy streaming as H.264 access units) | +| [packages/simutil_adb](../../packages/simutil_adb/) | `AndroidDeviceService`, wireless pairing, mDNS discovery, `LogcatHelper`, `ScrcpySession` (scrcpy streaming as H.264 access units), `EmulatorGrpcSession` (emulator gRPC API: raw frames through a shared file, input) | | [packages/simutil_apple](../../packages/simutil_apple/) | `IOSDeviceService` (simctl + devicectl), `XcodeCacheService`, `SimulatorSlimmer`, `SimulatorRecorder` | | [packages/simutil_plugins](../../packages/simutil_plugins/) | `PluginCatalog` / `loadPluginCatalog`, `PluginRunner`; `testing.dart` fakes | | [packages/simutil_h264](../../packages/simutil_h264/) | Flutter plugin for `app/` only, outside the pub workspace: `H264Decoder` decodes Annex-B H.264 into a texture (VideoToolbox, Media Foundation, libavcodec) | @@ -25,8 +25,8 @@ package has its own version. `app/` (Flutter desktop GUI) depends on the libraries via pub.dev versions overridden with `../packages/*` paths and stays **outside** the pub workspace so root `dart pub get` never needs the Flutter SDK. Live device screens use -`DeviceSession` (`simutil_core`), implemented by `ScrcpySession` -(`simutil_adb`) and the app's iOS session over a native plugin. +`DeviceSession` (`simutil_core`), implemented by `ScrcpySession` and +`EmulatorGrpcSession` (`simutil_adb`) and the app's iOS session over a native plugin. Rules: `simutil_shared` must not import `nocterm` or Flutter; libraries never import `package:simutil/` (the app). diff --git a/packages/simutil_adb/CHANGELOG.md b/packages/simutil_adb/CHANGELOG.md index 96d7572..f15eb6b 100644 --- a/packages/simutil_adb/CHANGELOG.md +++ b/packages/simutil_adb/CHANGELOG.md @@ -2,6 +2,12 @@ ## 1.4.0 +- `EmulatorGrpcSession` streams and controls an Android emulator over its + gRPC API (`streamScreenshot` with the MMAP transport into a caller-owned + RGB888 file, input through `streamInputEvent`, recording with + `adb emu screenrecord`), like Android Studio's embedded emulator. +- `EmulatorEndpoint.find(serial)` reads an emulator's gRPC port and token + from its discovery file (`avd/running/pid_.ini`). - `emulatorCameraArgs(back:, front:)` builds `-camera-back` / `-camera-front` emulator args from `webcam`, `image:`, `video:`, `virtualscene`, `emulated` or `none`; one webcam cannot diff --git a/packages/simutil_adb/analysis_options.yaml b/packages/simutil_adb/analysis_options.yaml index aafbc40..83d8058 100644 --- a/packages/simutil_adb/analysis_options.yaml +++ b/packages/simutil_adb/analysis_options.yaml @@ -1,5 +1,9 @@ include: ../../analysis_options.yaml +analyzer: + exclude: + - lib/src/emulator/generated/** + linter: rules: public_member_api_docs: true diff --git a/packages/simutil_adb/lib/simutil_adb.dart b/packages/simutil_adb/lib/simutil_adb.dart index b9fa9a8..1e3c66d 100644 --- a/packages/simutil_adb/lib/simutil_adb.dart +++ b/packages/simutil_adb/lib/simutil_adb.dart @@ -2,7 +2,8 @@ /// /// Lists AVDs and hardware, launches the emulator, and handles wireless /// `adb connect` / `adb pair`, and streams/controls devices through scrcpy -/// ([ScrcpySession]). +/// ([ScrcpySession]) or emulators through their gRPC API +/// ([EmulatorGrpcSession]). /// /// ```dart /// import 'package:simutil_adb/simutil_adb.dart'; @@ -15,6 +16,8 @@ library; export 'src/adb_wireless_pairing.dart'; export 'src/android_device_service.dart'; +export 'src/emulator/emulator_endpoint.dart'; +export 'src/emulator/emulator_grpc_session.dart'; export 'src/logcat_helper.dart'; export 'src/scrcpy/scrcpy_install.dart'; export 'src/scrcpy/scrcpy_session.dart'; diff --git a/packages/simutil_adb/lib/src/emulator/emulator_endpoint.dart b/packages/simutil_adb/lib/src/emulator/emulator_endpoint.dart new file mode 100644 index 0000000..8849387 --- /dev/null +++ b/packages/simutil_adb/lib/src/emulator/emulator_endpoint.dart @@ -0,0 +1,103 @@ +import 'dart:io'; + +/// Where a running Android emulator serves its gRPC API, read from the +/// discovery file it writes (`/pid_.ini`). +final class EmulatorEndpoint { + /// Creates an endpoint on localhost [port]. + const EmulatorEndpoint({required this.port, this.token}); + + /// gRPC port on localhost. + final int port; + + /// Bearer token for the `authorization` header; null when the emulator + /// accepts calls without one. Never log it. + final String? token; + + /// The endpoint in a discovery file's [contents] when it belongs to the + /// emulator with console port [serialPort] (`emulator-5554` → 5554). + static EmulatorEndpoint? parse(String contents, {required int serialPort}) { + final values = { + for (final line in contents.split('\n')) + if (line.indexOf('=') case final i when i > 0) + line.substring(0, i).trim(): line.substring(i + 1).trim(), + }; + if (int.tryParse(values['port.serial'] ?? '') != serialPort) return null; + final port = int.tryParse(values['grpc.port'] ?? ''); + if (port == null) return null; + final token = values['grpc.token']; + return EmulatorEndpoint( + port: port, + token: token == null || token.isEmpty ? null : token, + ); + } + + /// The gRPC endpoint of emulator [serial] (`emulator-`), or null when + /// it serves none (`-no-grpc`, emulators before 30) or is not running. + static Future find( + String serial, { + String? directory, + }) async { + final serialPort = int.tryParse(serial.replaceFirst('emulator-', '')); + final dir = directory ?? discoveryDirectory(Platform.environment); + if (serialPort == null || !serial.startsWith('emulator-') || dir == null) { + return null; + } + final files = []; + try { + await for (final entity in Directory(dir).list()) { + final name = entity.uri.pathSegments.last; + if (entity is File && + name.startsWith('pid_') && + name.endsWith('.ini')) { + files.add(entity); + } + } + } on FileSystemException { + return null; + } + // A crashed emulator leaves its file behind: the newest one wins. + // ponytail: a stale file with the same serial and a newer mtime than the + // live one would win; check the pid is alive if that ever shows up. + final candidates = <(DateTime, EmulatorEndpoint)>[]; + for (final file in files) { + try { + final endpoint = parse( + await file.readAsString(), + serialPort: serialPort, + ); + if (endpoint != null) { + candidates.add((await file.lastModified(), endpoint)); + } + } on FileSystemException { + continue; + } + } + candidates.sort((a, b) => b.$1.compareTo(a.$1)); + return candidates.firstOrNull?.$2; + } + + /// The emulator's discovery directory (`avd/running`) for the platform, + /// from [environment] (Android Studio's `computeRegistrationDirectory`). + static String? discoveryDirectory( + Map environment, { + String? operatingSystem, + }) { + final base = switch (operatingSystem ?? Platform.operatingSystem) { + 'macos' => switch (environment['HOME']) { + final home? => '$home/Library/Caches/TemporaryItems', + null => null, + }, + 'windows' => switch (environment['LOCALAPPDATA']) { + final local? => '$local\\Temp', + null => null, + }, + _ => switch (environment['XDG_RUNTIME_DIR']) { + final runtime? when runtime.isNotEmpty => runtime, + _ => '/tmp/android-${environment['USER'] ?? ''}', + }, + }; + if (base == null) return null; + final separator = base.contains('\\') ? '\\' : '/'; + return '$base${separator}avd${separator}running'; + } +} diff --git a/packages/simutil_adb/lib/src/emulator/emulator_grpc_session.dart b/packages/simutil_adb/lib/src/emulator/emulator_grpc_session.dart new file mode 100644 index 0000000..53f8b12 --- /dev/null +++ b/packages/simutil_adb/lib/src/emulator/emulator_grpc_session.dart @@ -0,0 +1,300 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:grpc/grpc.dart'; +import 'package:simutil_adb/src/emulator/emulator_endpoint.dart'; +import 'package:simutil_adb/src/emulator/emulator_input.dart'; +import 'package:simutil_adb/src/emulator/generated/emulator_controller.pbgrpc.dart'; +import 'package:simutil_adb/src/ffmpeg.dart'; +import 'package:simutil_core/simutil_core.dart'; + +/// One frame [EmulatorGrpcSession.frames] wrote: [width]x[height] packed +/// RGB888 pixels at the start of the shared file. +typedef EmulatorFrame = ({int width, int height}); + +/// Streams and controls an Android emulator over its own gRPC API, the way +/// Android Studio's embedded emulator does: raw frames through a shared file +/// (no video codec in the guest) and input through `streamInputEvent`. +abstract interface class EmulatorGrpcSession implements DeviceSession { + /// Creates a session for emulator [serial] served at [endpoint]; call + /// [start] to connect. [adbPath] and [exec] run `adb emu` for recording. + factory EmulatorGrpcSession({ + required String serial, + required EmulatorEndpoint endpoint, + required String adbPath, + required CommandExec exec, + }) = _EmulatorGrpcSession; + + /// Writes each new screen frame, scaled to fit [width]x[height] pixels + /// (capped at the display size), into the file at [path] and reports its + /// size. The file must hold `width * height * 3` bytes; the emulator maps + /// it itself. Listen again with a new size to resize. + Stream frames({ + required String path, + required int width, + required int height, + }); + + /// Types [text]; characters outside printable ASCII are dropped. + void typeText(String text); + + /// Presses and releases [key], a W3C `KeyboardEvent.key` value + /// (`Enter`, `Backspace`, `ArrowUp`…). + void pressKey(String key); +} + +class _EmulatorGrpcSession implements EmulatorGrpcSession { + _EmulatorGrpcSession({ + required this.serial, + required EmulatorEndpoint endpoint, + required this.adbPath, + required CommandExec exec, + }) : _exec = exec, + _channel = ClientChannel( + InternetAddress.loopbackIPv4, + port: endpoint.port, + options: const ChannelOptions( + credentials: ChannelCredentials.insecure(), + ), + ), + _metadata = { + if (endpoint.token case final token?) 'authorization': 'Bearer $token', + }; + + final String serial; + final String adbPath; + final CommandExec _exec; + final ClientChannel _channel; + final Map _metadata; + late final _client = EmulatorControllerClient( + _channel, + options: CallOptions(metadata: _metadata), + ); + + final _statusController = StreamController.broadcast(); + SessionStatus _status = const SessionConnecting(); + StreamController? _input; + bool _stopped = false; + + /// Display size in its natural orientation, and the current rotation. + int? _width; + int? _height; + var _rotation = Rotation_SkinRotation.PORTRAIT; + String? _recordingPath; + + @override + String get deviceId => serial; + + @override + List get buttons => const [ + DeviceButton.back, + DeviceButton.home, + DeviceButton.recents, + ]; + + @override + SessionStatus get status => _status; + + @override + Stream get statusChanges => _statusController.stream; + + void _emit(SessionStatus status) { + if (_stopped && status is! SessionFailed) return; + _status = status; + if (!_statusController.isClosed) _statusController.add(status); + } + + @override + Future start() async { + _emit(const SessionConnecting()); + try { + await _readDisplay(); + if (_stopped) return; + _input = StreamController(); + unawaited( + _client + .streamInputEvent(_input!.stream) + .then( + (_) {}, + onError: (Object e) { + if (!_stopped) { + _emit(const SessionFailed('Emulator disconnected')); + } + }, + ), + ); + _emitLive(); + } catch (e) { + // Emit before stop(): it closes the status stream. + _emit(SessionFailed(e is GrpcError ? 'gRPC: ${e.message}' : '$e')); + await stop(); + } + } + + /// A cold boot can take minutes before the display shows anything. + static const _bootTimeout = Duration(minutes: 3); + + /// Reads the display size from a full-size screenshot (an oversized + /// request is capped at the display); 0x0 until the display is on. + Future _readDisplay() async { + final deadline = DateTime.now().add(_bootTimeout); + while (!_stopped) { + final image = await _client.getScreenshot( + ImageFormat( + format: ImageFormat_ImgFormat.RGB888, + width: 1 << 16, + height: 1 << 16, + ), + options: CallOptions(timeout: const Duration(seconds: 10)), + ); + final format = image.format; + if (format.width > 0 && format.height > 0) { + _rotation = format.rotation.rotation; + final swap = _sideways(_rotation); + _width = swap ? format.height : format.width; + _height = swap ? format.width : format.height; + return; + } + if (DateTime.now().isAfter(deadline)) { + throw StateError('emulator display did not turn on'); + } + await Future.delayed(const Duration(milliseconds: 500)); + } + } + + static bool _sideways(Rotation_SkinRotation rotation) => + rotation == Rotation_SkinRotation.LANDSCAPE || + rotation == Rotation_SkinRotation.REVERSE_LANDSCAPE; + + void _emitLive() { + final (width, height) = (_width, _height); + if (width == null || height == null || _input == null) return; + final swap = _sideways(_rotation); + _emit( + SessionLive(width: swap ? height : width, height: swap ? width : height), + ); + } + + @override + Stream frames({ + required String path, + required int width, + required int height, + }) => _client + .streamScreenshot( + ImageFormat( + format: ImageFormat_ImgFormat.RGB888, + width: width, + height: height, + transport: ImageTransport( + channel: ImageTransport_TransportChannel.MMAP, + handle: Uri.file(path).toString(), + ), + ), + ) + .where((image) => image.format.width > 0 && image.format.height > 0) + .map((image) { + final rotation = image.format.rotation.rotation; + if (rotation != _rotation) { + _rotation = rotation; + _emitLive(); + } + return (width: image.format.width, height: image.format.height); + }); + + @override + void touch(TouchPhase phase, double x, double y) { + final (width, height) = (_width, _height); + if (width == null || height == null) return; + _send( + EmulatorInput.touch( + phase, + x, + y, + width: width, + height: height, + rotation: _rotation, + ), + ); + } + + @override + void press(DeviceButton button) => + _send(EmulatorInput.key(EmulatorInput.buttonKey(button))); + + @override + void typeText(String text) => _send(EmulatorInput.text(text)); + + @override + void pressKey(String key) => _send(EmulatorInput.key(key)); + + void _send(InputEvent event) { + final input = _input; + if (input != null && !input.isClosed) input.add(event); + } + + @override + Future repairInput() async {} + + @override + bool get isRecording => _recordingPath != null; + + /// The emulator records its display itself (VP9 `.webm`, host-side); on + /// stop it is converted to [path] with ffmpeg when available. + @override + Future startRecording(String path) async { + if (_recordingPath != null) return; + final result = await _emu([ + 'screenrecord', + 'start', + '--time-limit', + '14400', + '$path.webm', + ]); + // `adb emu` exits 0 even when the console says KO. + if (!result.success || result.stdout.contains('KO')) { + throw StateError('Recording failed: ${result.stdout.trim()}'); + } + _recordingPath = path; + } + + @override + Future stopRecording() async { + final path = _recordingPath; + _recordingPath = null; + if (path == null) return null; + await _emu(['screenrecord', 'stop']); + final converted = await runFfmpeg(_exec, [ + '-y', + '-loglevel', + 'error', + '-i', + '$path.webm', + '-vf', + 'pad=ceil(iw/2)*2:ceil(ih/2)*2', + ...h264EncoderArgs, + '-pix_fmt', + 'yuv420p', + path, + ], timeout: const Duration(minutes: 10)); + if (!converted) return '$path.webm'; + await File('$path.webm').delete(); + return path; + } + + Future _emu(List args) => _exec.run( + adbPath, + arguments: ['-s', serial, 'emu', ...args], + priority: CommandPriority.interactive, + ); + + @override + Future stop() async { + if (_stopped) return; + await stopRecording(); + _stopped = true; + unawaited(_input?.close()); + await _channel.shutdown(); + await _statusController.close(); + } +} diff --git a/packages/simutil_adb/lib/src/emulator/emulator_input.dart b/packages/simutil_adb/lib/src/emulator/emulator_input.dart new file mode 100644 index 0000000..6fab7b6 --- /dev/null +++ b/packages/simutil_adb/lib/src/emulator/emulator_input.dart @@ -0,0 +1,61 @@ +import 'package:simutil_adb/src/emulator/generated/emulator_controller.pb.dart'; +import 'package:simutil_core/simutil_core.dart'; + +/// Builds the emulator gRPC `InputEvent`s for [DeviceSession] input. +abstract final class EmulatorInput { + /// Emulator `Touch.pressure` while down; zero lifts the finger. + static const pressure = 0x400; + + /// A one-finger touch at ([x], [y]) (0..1 of the screen as shown, rotated + /// by [rotation]) on a display of [width]x[height] pixels in its natural + /// (unrotated) orientation, where the emulator takes touch coordinates. + static InputEvent touch( + TouchPhase phase, + double x, + double y, { + required int width, + required int height, + Rotation_SkinRotation rotation = Rotation_SkinRotation.PORTRAIT, + }) { + final (u, v) = (x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)); + final (nx, ny) = switch (rotation) { + Rotation_SkinRotation.LANDSCAPE => (1 - v, u), + Rotation_SkinRotation.REVERSE_PORTRAIT => (1 - u, 1 - v), + Rotation_SkinRotation.REVERSE_LANDSCAPE => (v, 1 - u), + _ => (u, v), + }; + return InputEvent( + touchEvent: TouchEvent( + touches: [ + Touch( + x: (nx * (width - 1)).round(), + y: (ny * (height - 1)).round(), + pressure: phase == TouchPhase.up ? 0 : pressure, + ), + ], + ), + ); + } + + /// A W3C `KeyboardEvent.key` value ([key]), pressed and released. + static InputEvent key(String key) => InputEvent( + keyEvent: KeyboardEvent( + eventType: KeyboardEvent_KeyEventType.keypress, + key: key, + ), + ); + + /// Types [text]; the emulator drops characters outside printable ASCII. + static InputEvent text(String text) => + InputEvent(keyEvent: KeyboardEvent(text: text)); + + /// The [key] value the emulator maps to [button]. + static String buttonKey(DeviceButton button) => switch (button) { + DeviceButton.back => 'GoBack', + DeviceButton.home => 'GoHome', + DeviceButton.recents => 'AppSwitch', + DeviceButton.lock => 'Power', + DeviceButton.volumeUp => 'AudioVolumeUp', + DeviceButton.volumeDown => 'AudioVolumeDown', + }; +} diff --git a/packages/simutil_adb/lib/src/emulator/generated/emulator_controller.pb.dart b/packages/simutil_adb/lib/src/emulator/generated/emulator_controller.pb.dart new file mode 100644 index 0000000..8a6c9bc --- /dev/null +++ b/packages/simutil_adb/lib/src/emulator/generated/emulator_controller.pb.dart @@ -0,0 +1,864 @@ +// This is a generated file - do not edit. +// +// Generated from emulator_controller.proto. + +// @dart = 3.3 + +// ignore_for_file: annotate_overrides, camel_case_types, comment_references +// ignore_for_file: constant_identifier_names +// ignore_for_file: curly_braces_in_flow_control_structures +// ignore_for_file: deprecated_member_use_from_same_package, library_prefixes +// ignore_for_file: non_constant_identifier_names, prefer_relative_imports + +import 'dart:core' as $core; + +import 'package:fixnum/fixnum.dart' as $fixnum; +import 'package:protobuf/protobuf.dart' as $pb; + +import 'emulator_controller.pbenum.dart'; + +export 'package:protobuf/protobuf.dart' show GeneratedMessageGenericExtensions; + +export 'emulator_controller.pbenum.dart'; + +class Touch extends $pb.GeneratedMessage { + factory Touch({ + $core.int? x, + $core.int? y, + $core.int? identifier, + $core.int? pressure, + $core.int? touchMajor, + $core.int? touchMinor, + }) { + final result = Touch._(); + if (x != null) result.x = x; + if (y != null) result.y = y; + if (identifier != null) result.identifier = identifier; + if (pressure != null) result.pressure = pressure; + if (touchMajor != null) result.touchMajor = touchMajor; + if (touchMinor != null) result.touchMinor = touchMinor; + return result; + } + + Touch._(); + + factory Touch.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + Touch()..mergeFromBuffer(data, registry); + factory Touch.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + Touch()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'Touch', + package: const $pb.PackageName( + _omitMessageNames ? '' : 'android.emulation.control'), + createEmptyInstance: Touch.$_createMessage) + ..aI(1, _omitFieldNames ? '' : 'x') + ..aI(2, _omitFieldNames ? '' : 'y') + ..aI(3, _omitFieldNames ? '' : 'identifier') + ..aI(4, _omitFieldNames ? '' : 'pressure') + ..aI(5, _omitFieldNames ? '' : 'touchMajor') + ..aI(6, _omitFieldNames ? '' : 'touchMinor') + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + Touch clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + Touch copyWith(void Function(Touch) updates) => + super.copyWith((message) => updates(message as Touch)) as Touch; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + @$core.Deprecated('Use Touch() / Touch.new instead') + static Touch create() => Touch._(); + static $pb.GeneratedMessage $_createMessage() => Touch._(); + @$core.override + Touch createEmptyInstance() => Touch._(); + @$core.pragma('dart2js:noInline') + static Touch getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(Touch.$_createMessage); + static Touch? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get x => $_getIZ(0); + @$pb.TagNumber(1) + set x($core.int value) => $_setSignedInt32(0, value); + @$pb.TagNumber(1) + $core.bool hasX() => $_has(0); + @$pb.TagNumber(1) + void clearX() => $_clearField(1); + + @$pb.TagNumber(2) + $core.int get y => $_getIZ(1); + @$pb.TagNumber(2) + set y($core.int value) => $_setSignedInt32(1, value); + @$pb.TagNumber(2) + $core.bool hasY() => $_has(1); + @$pb.TagNumber(2) + void clearY() => $_clearField(2); + + @$pb.TagNumber(3) + $core.int get identifier => $_getIZ(2); + @$pb.TagNumber(3) + set identifier($core.int value) => $_setSignedInt32(2, value); + @$pb.TagNumber(3) + $core.bool hasIdentifier() => $_has(2); + @$pb.TagNumber(3) + void clearIdentifier() => $_clearField(3); + + @$pb.TagNumber(4) + $core.int get pressure => $_getIZ(3); + @$pb.TagNumber(4) + set pressure($core.int value) => $_setSignedInt32(3, value); + @$pb.TagNumber(4) + $core.bool hasPressure() => $_has(3); + @$pb.TagNumber(4) + void clearPressure() => $_clearField(4); + + @$pb.TagNumber(5) + $core.int get touchMajor => $_getIZ(4); + @$pb.TagNumber(5) + set touchMajor($core.int value) => $_setSignedInt32(4, value); + @$pb.TagNumber(5) + $core.bool hasTouchMajor() => $_has(4); + @$pb.TagNumber(5) + void clearTouchMajor() => $_clearField(5); + + @$pb.TagNumber(6) + $core.int get touchMinor => $_getIZ(5); + @$pb.TagNumber(6) + set touchMinor($core.int value) => $_setSignedInt32(5, value); + @$pb.TagNumber(6) + $core.bool hasTouchMinor() => $_has(5); + @$pb.TagNumber(6) + void clearTouchMinor() => $_clearField(6); +} + +class TouchEvent extends $pb.GeneratedMessage { + factory TouchEvent({ + $core.Iterable? touches, + $core.int? display, + }) { + final result = TouchEvent._(); + if (touches != null) result.touches.addAll(touches); + if (display != null) result.display = display; + return result; + } + + TouchEvent._(); + + factory TouchEvent.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + TouchEvent()..mergeFromBuffer(data, registry); + factory TouchEvent.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + TouchEvent()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'TouchEvent', + package: const $pb.PackageName( + _omitMessageNames ? '' : 'android.emulation.control'), + createEmptyInstance: TouchEvent.$_createMessage) + ..pPM(1, _omitFieldNames ? '' : 'touches', + subBuilder: Touch.$_createMessage) + ..aI(2, _omitFieldNames ? '' : 'display') + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + TouchEvent clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + TouchEvent copyWith(void Function(TouchEvent) updates) => + super.copyWith((message) => updates(message as TouchEvent)) as TouchEvent; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + @$core.Deprecated('Use TouchEvent() / TouchEvent.new instead') + static TouchEvent create() => TouchEvent._(); + static $pb.GeneratedMessage $_createMessage() => TouchEvent._(); + @$core.override + TouchEvent createEmptyInstance() => TouchEvent._(); + @$core.pragma('dart2js:noInline') + static TouchEvent getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(TouchEvent.$_createMessage); + static TouchEvent? _defaultInstance; + + @$pb.TagNumber(1) + $pb.PbList get touches => $_getList(0); + + @$pb.TagNumber(2) + $core.int get display => $_getIZ(1); + @$pb.TagNumber(2) + set display($core.int value) => $_setSignedInt32(1, value); + @$pb.TagNumber(2) + $core.bool hasDisplay() => $_has(1); + @$pb.TagNumber(2) + void clearDisplay() => $_clearField(2); +} + +class MouseEvent extends $pb.GeneratedMessage { + factory MouseEvent({ + $core.int? x, + $core.int? y, + $core.int? buttons, + $core.int? display, + }) { + final result = MouseEvent._(); + if (x != null) result.x = x; + if (y != null) result.y = y; + if (buttons != null) result.buttons = buttons; + if (display != null) result.display = display; + return result; + } + + MouseEvent._(); + + factory MouseEvent.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + MouseEvent()..mergeFromBuffer(data, registry); + factory MouseEvent.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + MouseEvent()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'MouseEvent', + package: const $pb.PackageName( + _omitMessageNames ? '' : 'android.emulation.control'), + createEmptyInstance: MouseEvent.$_createMessage) + ..aI(1, _omitFieldNames ? '' : 'x') + ..aI(2, _omitFieldNames ? '' : 'y') + ..aI(3, _omitFieldNames ? '' : 'buttons') + ..aI(4, _omitFieldNames ? '' : 'display') + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + MouseEvent clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + MouseEvent copyWith(void Function(MouseEvent) updates) => + super.copyWith((message) => updates(message as MouseEvent)) as MouseEvent; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + @$core.Deprecated('Use MouseEvent() / MouseEvent.new instead') + static MouseEvent create() => MouseEvent._(); + static $pb.GeneratedMessage $_createMessage() => MouseEvent._(); + @$core.override + MouseEvent createEmptyInstance() => MouseEvent._(); + @$core.pragma('dart2js:noInline') + static MouseEvent getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(MouseEvent.$_createMessage); + static MouseEvent? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get x => $_getIZ(0); + @$pb.TagNumber(1) + set x($core.int value) => $_setSignedInt32(0, value); + @$pb.TagNumber(1) + $core.bool hasX() => $_has(0); + @$pb.TagNumber(1) + void clearX() => $_clearField(1); + + @$pb.TagNumber(2) + $core.int get y => $_getIZ(1); + @$pb.TagNumber(2) + set y($core.int value) => $_setSignedInt32(1, value); + @$pb.TagNumber(2) + $core.bool hasY() => $_has(1); + @$pb.TagNumber(2) + void clearY() => $_clearField(2); + + @$pb.TagNumber(3) + $core.int get buttons => $_getIZ(2); + @$pb.TagNumber(3) + set buttons($core.int value) => $_setSignedInt32(2, value); + @$pb.TagNumber(3) + $core.bool hasButtons() => $_has(2); + @$pb.TagNumber(3) + void clearButtons() => $_clearField(3); + + @$pb.TagNumber(4) + $core.int get display => $_getIZ(3); + @$pb.TagNumber(4) + set display($core.int value) => $_setSignedInt32(3, value); + @$pb.TagNumber(4) + $core.bool hasDisplay() => $_has(3); + @$pb.TagNumber(4) + void clearDisplay() => $_clearField(4); +} + +class KeyboardEvent extends $pb.GeneratedMessage { + factory KeyboardEvent({ + KeyboardEvent_KeyCodeType? codeType, + KeyboardEvent_KeyEventType? eventType, + $core.int? keyCode, + $core.String? key, + $core.String? text, + }) { + final result = KeyboardEvent._(); + if (codeType != null) result.codeType = codeType; + if (eventType != null) result.eventType = eventType; + if (keyCode != null) result.keyCode = keyCode; + if (key != null) result.key = key; + if (text != null) result.text = text; + return result; + } + + KeyboardEvent._(); + + factory KeyboardEvent.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + KeyboardEvent()..mergeFromBuffer(data, registry); + factory KeyboardEvent.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + KeyboardEvent()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'KeyboardEvent', + package: const $pb.PackageName( + _omitMessageNames ? '' : 'android.emulation.control'), + createEmptyInstance: KeyboardEvent.$_createMessage) + ..aE(1, _omitFieldNames ? '' : 'codeType', + protoName: 'codeType', enumValues: KeyboardEvent_KeyCodeType.values) + ..aE(2, _omitFieldNames ? '' : 'eventType', + protoName: 'eventType', enumValues: KeyboardEvent_KeyEventType.values) + ..aI(3, _omitFieldNames ? '' : 'keyCode', protoName: 'keyCode') + ..aOS(4, _omitFieldNames ? '' : 'key') + ..aOS(5, _omitFieldNames ? '' : 'text') + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + KeyboardEvent clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + KeyboardEvent copyWith(void Function(KeyboardEvent) updates) => + super.copyWith((message) => updates(message as KeyboardEvent)) + as KeyboardEvent; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + @$core.Deprecated('Use KeyboardEvent() / KeyboardEvent.new instead') + static KeyboardEvent create() => KeyboardEvent._(); + static $pb.GeneratedMessage $_createMessage() => KeyboardEvent._(); + @$core.override + KeyboardEvent createEmptyInstance() => KeyboardEvent._(); + @$core.pragma('dart2js:noInline') + static KeyboardEvent getDefault() => + _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor( + KeyboardEvent.$_createMessage); + static KeyboardEvent? _defaultInstance; + + @$pb.TagNumber(1) + KeyboardEvent_KeyCodeType get codeType => $_getN(0); + @$pb.TagNumber(1) + set codeType(KeyboardEvent_KeyCodeType value) => $_setField(1, value); + @$pb.TagNumber(1) + $core.bool hasCodeType() => $_has(0); + @$pb.TagNumber(1) + void clearCodeType() => $_clearField(1); + + @$pb.TagNumber(2) + KeyboardEvent_KeyEventType get eventType => $_getN(1); + @$pb.TagNumber(2) + set eventType(KeyboardEvent_KeyEventType value) => $_setField(2, value); + @$pb.TagNumber(2) + $core.bool hasEventType() => $_has(1); + @$pb.TagNumber(2) + void clearEventType() => $_clearField(2); + + @$pb.TagNumber(3) + $core.int get keyCode => $_getIZ(2); + @$pb.TagNumber(3) + set keyCode($core.int value) => $_setSignedInt32(2, value); + @$pb.TagNumber(3) + $core.bool hasKeyCode() => $_has(2); + @$pb.TagNumber(3) + void clearKeyCode() => $_clearField(3); + + @$pb.TagNumber(4) + $core.String get key => $_getSZ(3); + @$pb.TagNumber(4) + set key($core.String value) => $_setString(3, value); + @$pb.TagNumber(4) + $core.bool hasKey() => $_has(3); + @$pb.TagNumber(4) + void clearKey() => $_clearField(4); + + @$pb.TagNumber(5) + $core.String get text => $_getSZ(4); + @$pb.TagNumber(5) + set text($core.String value) => $_setString(4, value); + @$pb.TagNumber(5) + $core.bool hasText() => $_has(4); + @$pb.TagNumber(5) + void clearText() => $_clearField(5); +} + +enum InputEvent_Type { keyEvent, touchEvent, mouseEvent, notSet } + +class InputEvent extends $pb.GeneratedMessage { + factory InputEvent({ + KeyboardEvent? keyEvent, + TouchEvent? touchEvent, + MouseEvent? mouseEvent, + }) { + final result = InputEvent._(); + if (keyEvent != null) result.keyEvent = keyEvent; + if (touchEvent != null) result.touchEvent = touchEvent; + if (mouseEvent != null) result.mouseEvent = mouseEvent; + return result; + } + + InputEvent._(); + + factory InputEvent.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + InputEvent()..mergeFromBuffer(data, registry); + factory InputEvent.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + InputEvent()..mergeFromJson(json, registry); + + static const $core.Map<$core.int, InputEvent_Type> _InputEvent_TypeByTag = { + 1: InputEvent_Type.keyEvent, + 2: InputEvent_Type.touchEvent, + 3: InputEvent_Type.mouseEvent, + 0: InputEvent_Type.notSet + }; + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'InputEvent', + package: const $pb.PackageName( + _omitMessageNames ? '' : 'android.emulation.control'), + createEmptyInstance: InputEvent.$_createMessage) + ..oo(0, [1, 2, 3]) + ..aOM(1, _omitFieldNames ? '' : 'keyEvent', + subBuilder: KeyboardEvent.$_createMessage) + ..aOM(2, _omitFieldNames ? '' : 'touchEvent', + subBuilder: TouchEvent.$_createMessage) + ..aOM(3, _omitFieldNames ? '' : 'mouseEvent', + subBuilder: MouseEvent.$_createMessage) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + InputEvent clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + InputEvent copyWith(void Function(InputEvent) updates) => + super.copyWith((message) => updates(message as InputEvent)) as InputEvent; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + @$core.Deprecated('Use InputEvent() / InputEvent.new instead') + static InputEvent create() => InputEvent._(); + static $pb.GeneratedMessage $_createMessage() => InputEvent._(); + @$core.override + InputEvent createEmptyInstance() => InputEvent._(); + @$core.pragma('dart2js:noInline') + static InputEvent getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(InputEvent.$_createMessage); + static InputEvent? _defaultInstance; + + @$pb.TagNumber(1) + @$pb.TagNumber(2) + @$pb.TagNumber(3) + InputEvent_Type whichType() => _InputEvent_TypeByTag[$_whichOneof(0)]!; + @$pb.TagNumber(1) + @$pb.TagNumber(2) + @$pb.TagNumber(3) + void clearType() => $_clearField($_whichOneof(0)); + + @$pb.TagNumber(1) + KeyboardEvent get keyEvent => $_getN(0); + @$pb.TagNumber(1) + set keyEvent(KeyboardEvent value) => $_setField(1, value); + @$pb.TagNumber(1) + $core.bool hasKeyEvent() => $_has(0); + @$pb.TagNumber(1) + void clearKeyEvent() => $_clearField(1); + @$pb.TagNumber(1) + KeyboardEvent ensureKeyEvent() => $_ensure(0); + + @$pb.TagNumber(2) + TouchEvent get touchEvent => $_getN(1); + @$pb.TagNumber(2) + set touchEvent(TouchEvent value) => $_setField(2, value); + @$pb.TagNumber(2) + $core.bool hasTouchEvent() => $_has(1); + @$pb.TagNumber(2) + void clearTouchEvent() => $_clearField(2); + @$pb.TagNumber(2) + TouchEvent ensureTouchEvent() => $_ensure(1); + + @$pb.TagNumber(3) + MouseEvent get mouseEvent => $_getN(2); + @$pb.TagNumber(3) + set mouseEvent(MouseEvent value) => $_setField(3, value); + @$pb.TagNumber(3) + $core.bool hasMouseEvent() => $_has(2); + @$pb.TagNumber(3) + void clearMouseEvent() => $_clearField(3); + @$pb.TagNumber(3) + MouseEvent ensureMouseEvent() => $_ensure(2); +} + +class ImageTransport extends $pb.GeneratedMessage { + factory ImageTransport({ + ImageTransport_TransportChannel? channel, + $core.String? handle, + }) { + final result = ImageTransport._(); + if (channel != null) result.channel = channel; + if (handle != null) result.handle = handle; + return result; + } + + ImageTransport._(); + + factory ImageTransport.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + ImageTransport()..mergeFromBuffer(data, registry); + factory ImageTransport.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + ImageTransport()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'ImageTransport', + package: const $pb.PackageName( + _omitMessageNames ? '' : 'android.emulation.control'), + createEmptyInstance: ImageTransport.$_createMessage) + ..aE(1, _omitFieldNames ? '' : 'channel', + enumValues: ImageTransport_TransportChannel.values) + ..aOS(2, _omitFieldNames ? '' : 'handle') + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + ImageTransport clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + ImageTransport copyWith(void Function(ImageTransport) updates) => + super.copyWith((message) => updates(message as ImageTransport)) + as ImageTransport; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + @$core.Deprecated('Use ImageTransport() / ImageTransport.new instead') + static ImageTransport create() => ImageTransport._(); + static $pb.GeneratedMessage $_createMessage() => ImageTransport._(); + @$core.override + ImageTransport createEmptyInstance() => ImageTransport._(); + @$core.pragma('dart2js:noInline') + static ImageTransport getDefault() => + _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor( + ImageTransport.$_createMessage); + static ImageTransport? _defaultInstance; + + @$pb.TagNumber(1) + ImageTransport_TransportChannel get channel => $_getN(0); + @$pb.TagNumber(1) + set channel(ImageTransport_TransportChannel value) => $_setField(1, value); + @$pb.TagNumber(1) + $core.bool hasChannel() => $_has(0); + @$pb.TagNumber(1) + void clearChannel() => $_clearField(1); + + @$pb.TagNumber(2) + $core.String get handle => $_getSZ(1); + @$pb.TagNumber(2) + set handle($core.String value) => $_setString(1, value); + @$pb.TagNumber(2) + $core.bool hasHandle() => $_has(1); + @$pb.TagNumber(2) + void clearHandle() => $_clearField(2); +} + +class ImageFormat extends $pb.GeneratedMessage { + factory ImageFormat({ + ImageFormat_ImgFormat? format, + Rotation? rotation, + $core.int? width, + $core.int? height, + $core.int? display, + ImageTransport? transport, + }) { + final result = ImageFormat._(); + if (format != null) result.format = format; + if (rotation != null) result.rotation = rotation; + if (width != null) result.width = width; + if (height != null) result.height = height; + if (display != null) result.display = display; + if (transport != null) result.transport = transport; + return result; + } + + ImageFormat._(); + + factory ImageFormat.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + ImageFormat()..mergeFromBuffer(data, registry); + factory ImageFormat.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + ImageFormat()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'ImageFormat', + package: const $pb.PackageName( + _omitMessageNames ? '' : 'android.emulation.control'), + createEmptyInstance: ImageFormat.$_createMessage) + ..aE(1, _omitFieldNames ? '' : 'format', + enumValues: ImageFormat_ImgFormat.values) + ..aOM(2, _omitFieldNames ? '' : 'rotation', + subBuilder: Rotation.$_createMessage) + ..aI(3, _omitFieldNames ? '' : 'width', fieldType: $pb.PbFieldType.OU3) + ..aI(4, _omitFieldNames ? '' : 'height', fieldType: $pb.PbFieldType.OU3) + ..aI(5, _omitFieldNames ? '' : 'display', fieldType: $pb.PbFieldType.OU3) + ..aOM(6, _omitFieldNames ? '' : 'transport', + subBuilder: ImageTransport.$_createMessage) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + ImageFormat clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + ImageFormat copyWith(void Function(ImageFormat) updates) => + super.copyWith((message) => updates(message as ImageFormat)) + as ImageFormat; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + @$core.Deprecated('Use ImageFormat() / ImageFormat.new instead') + static ImageFormat create() => ImageFormat._(); + static $pb.GeneratedMessage $_createMessage() => ImageFormat._(); + @$core.override + ImageFormat createEmptyInstance() => ImageFormat._(); + @$core.pragma('dart2js:noInline') + static ImageFormat getDefault() => + _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor( + ImageFormat.$_createMessage); + static ImageFormat? _defaultInstance; + + @$pb.TagNumber(1) + ImageFormat_ImgFormat get format => $_getN(0); + @$pb.TagNumber(1) + set format(ImageFormat_ImgFormat value) => $_setField(1, value); + @$pb.TagNumber(1) + $core.bool hasFormat() => $_has(0); + @$pb.TagNumber(1) + void clearFormat() => $_clearField(1); + + @$pb.TagNumber(2) + Rotation get rotation => $_getN(1); + @$pb.TagNumber(2) + set rotation(Rotation value) => $_setField(2, value); + @$pb.TagNumber(2) + $core.bool hasRotation() => $_has(1); + @$pb.TagNumber(2) + void clearRotation() => $_clearField(2); + @$pb.TagNumber(2) + Rotation ensureRotation() => $_ensure(1); + + @$pb.TagNumber(3) + $core.int get width => $_getIZ(2); + @$pb.TagNumber(3) + set width($core.int value) => $_setUnsignedInt32(2, value); + @$pb.TagNumber(3) + $core.bool hasWidth() => $_has(2); + @$pb.TagNumber(3) + void clearWidth() => $_clearField(3); + + @$pb.TagNumber(4) + $core.int get height => $_getIZ(3); + @$pb.TagNumber(4) + set height($core.int value) => $_setUnsignedInt32(3, value); + @$pb.TagNumber(4) + $core.bool hasHeight() => $_has(3); + @$pb.TagNumber(4) + void clearHeight() => $_clearField(4); + + @$pb.TagNumber(5) + $core.int get display => $_getIZ(4); + @$pb.TagNumber(5) + set display($core.int value) => $_setUnsignedInt32(4, value); + @$pb.TagNumber(5) + $core.bool hasDisplay() => $_has(4); + @$pb.TagNumber(5) + void clearDisplay() => $_clearField(5); + + @$pb.TagNumber(6) + ImageTransport get transport => $_getN(5); + @$pb.TagNumber(6) + set transport(ImageTransport value) => $_setField(6, value); + @$pb.TagNumber(6) + $core.bool hasTransport() => $_has(5); + @$pb.TagNumber(6) + void clearTransport() => $_clearField(6); + @$pb.TagNumber(6) + ImageTransport ensureTransport() => $_ensure(5); +} + +class Image extends $pb.GeneratedMessage { + factory Image({ + ImageFormat? format, + $core.List<$core.int>? image, + $core.int? seq, + $fixnum.Int64? timestampUs, + }) { + final result = Image._(); + if (format != null) result.format = format; + if (image != null) result.image = image; + if (seq != null) result.seq = seq; + if (timestampUs != null) result.timestampUs = timestampUs; + return result; + } + + Image._(); + + factory Image.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + Image()..mergeFromBuffer(data, registry); + factory Image.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + Image()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'Image', + package: const $pb.PackageName( + _omitMessageNames ? '' : 'android.emulation.control'), + createEmptyInstance: Image.$_createMessage) + ..aOM(1, _omitFieldNames ? '' : 'format', + subBuilder: ImageFormat.$_createMessage) + ..a<$core.List<$core.int>>( + 4, _omitFieldNames ? '' : 'image', $pb.PbFieldType.OY) + ..aI(5, _omitFieldNames ? '' : 'seq', fieldType: $pb.PbFieldType.OU3) + ..a<$fixnum.Int64>( + 6, _omitFieldNames ? '' : 'timestampUs', $pb.PbFieldType.OU6, + protoName: 'timestampUs', defaultOrMaker: $fixnum.Int64.ZERO) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + Image clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + Image copyWith(void Function(Image) updates) => + super.copyWith((message) => updates(message as Image)) as Image; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + @$core.Deprecated('Use Image() / Image.new instead') + static Image create() => Image._(); + static $pb.GeneratedMessage $_createMessage() => Image._(); + @$core.override + Image createEmptyInstance() => Image._(); + @$core.pragma('dart2js:noInline') + static Image getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(Image.$_createMessage); + static Image? _defaultInstance; + + @$pb.TagNumber(1) + ImageFormat get format => $_getN(0); + @$pb.TagNumber(1) + set format(ImageFormat value) => $_setField(1, value); + @$pb.TagNumber(1) + $core.bool hasFormat() => $_has(0); + @$pb.TagNumber(1) + void clearFormat() => $_clearField(1); + @$pb.TagNumber(1) + ImageFormat ensureFormat() => $_ensure(0); + + @$pb.TagNumber(4) + $core.List<$core.int> get image => $_getN(1); + @$pb.TagNumber(4) + set image($core.List<$core.int> value) => $_setBytes(1, value); + @$pb.TagNumber(4) + $core.bool hasImage() => $_has(1); + @$pb.TagNumber(4) + void clearImage() => $_clearField(4); + + @$pb.TagNumber(5) + $core.int get seq => $_getIZ(2); + @$pb.TagNumber(5) + set seq($core.int value) => $_setUnsignedInt32(2, value); + @$pb.TagNumber(5) + $core.bool hasSeq() => $_has(2); + @$pb.TagNumber(5) + void clearSeq() => $_clearField(5); + + @$pb.TagNumber(6) + $fixnum.Int64 get timestampUs => $_getI64(3); + @$pb.TagNumber(6) + set timestampUs($fixnum.Int64 value) => $_setInt64(3, value); + @$pb.TagNumber(6) + $core.bool hasTimestampUs() => $_has(3); + @$pb.TagNumber(6) + void clearTimestampUs() => $_clearField(6); +} + +class Rotation extends $pb.GeneratedMessage { + factory Rotation({ + Rotation_SkinRotation? rotation, + }) { + final result = Rotation._(); + if (rotation != null) result.rotation = rotation; + return result; + } + + Rotation._(); + + factory Rotation.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + Rotation()..mergeFromBuffer(data, registry); + factory Rotation.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + Rotation()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'Rotation', + package: const $pb.PackageName( + _omitMessageNames ? '' : 'android.emulation.control'), + createEmptyInstance: Rotation.$_createMessage) + ..aE(1, _omitFieldNames ? '' : 'rotation', + enumValues: Rotation_SkinRotation.values) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + Rotation clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + Rotation copyWith(void Function(Rotation) updates) => + super.copyWith((message) => updates(message as Rotation)) as Rotation; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + @$core.Deprecated('Use Rotation() / Rotation.new instead') + static Rotation create() => Rotation._(); + static $pb.GeneratedMessage $_createMessage() => Rotation._(); + @$core.override + Rotation createEmptyInstance() => Rotation._(); + @$core.pragma('dart2js:noInline') + static Rotation getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(Rotation.$_createMessage); + static Rotation? _defaultInstance; + + @$pb.TagNumber(1) + Rotation_SkinRotation get rotation => $_getN(0); + @$pb.TagNumber(1) + set rotation(Rotation_SkinRotation value) => $_setField(1, value); + @$pb.TagNumber(1) + $core.bool hasRotation() => $_has(0); + @$pb.TagNumber(1) + void clearRotation() => $_clearField(1); +} + +const $core.bool _omitFieldNames = + $core.bool.fromEnvironment('protobuf.omit_field_names'); +const $core.bool _omitMessageNames = + $core.bool.fromEnvironment('protobuf.omit_message_names'); diff --git a/packages/simutil_adb/lib/src/emulator/generated/emulator_controller.pbenum.dart b/packages/simutil_adb/lib/src/emulator/generated/emulator_controller.pbenum.dart new file mode 100644 index 0000000..a225a62 --- /dev/null +++ b/packages/simutil_adb/lib/src/emulator/generated/emulator_controller.pbenum.dart @@ -0,0 +1,140 @@ +// This is a generated file - do not edit. +// +// Generated from emulator_controller.proto. + +// @dart = 3.3 + +// ignore_for_file: annotate_overrides, camel_case_types, comment_references +// ignore_for_file: constant_identifier_names +// ignore_for_file: curly_braces_in_flow_control_structures +// ignore_for_file: deprecated_member_use_from_same_package, library_prefixes +// ignore_for_file: non_constant_identifier_names, prefer_relative_imports + +import 'dart:core' as $core; + +import 'package:protobuf/protobuf.dart' as $pb; + +class KeyboardEvent_KeyCodeType extends $pb.ProtobufEnum { + static const KeyboardEvent_KeyCodeType Usb = + KeyboardEvent_KeyCodeType._(0, _omitEnumNames ? '' : 'Usb'); + static const KeyboardEvent_KeyCodeType Evdev = + KeyboardEvent_KeyCodeType._(1, _omitEnumNames ? '' : 'Evdev'); + static const KeyboardEvent_KeyCodeType XKB = + KeyboardEvent_KeyCodeType._(2, _omitEnumNames ? '' : 'XKB'); + static const KeyboardEvent_KeyCodeType Win = + KeyboardEvent_KeyCodeType._(3, _omitEnumNames ? '' : 'Win'); + static const KeyboardEvent_KeyCodeType Mac = + KeyboardEvent_KeyCodeType._(4, _omitEnumNames ? '' : 'Mac'); + + static const $core.List values = + [ + Usb, + Evdev, + XKB, + Win, + Mac, + ]; + + static final $core.List _byValue = + $pb.ProtobufEnum.$_initByValueList(values, 4); + static KeyboardEvent_KeyCodeType? valueOf($core.int value) => + value < 0 || value >= _byValue.length ? null : _byValue[value]; + + const KeyboardEvent_KeyCodeType._(super.value, super.name); +} + +class KeyboardEvent_KeyEventType extends $pb.ProtobufEnum { + static const KeyboardEvent_KeyEventType keydown = + KeyboardEvent_KeyEventType._(0, _omitEnumNames ? '' : 'keydown'); + static const KeyboardEvent_KeyEventType keyup = + KeyboardEvent_KeyEventType._(1, _omitEnumNames ? '' : 'keyup'); + static const KeyboardEvent_KeyEventType keypress = + KeyboardEvent_KeyEventType._(2, _omitEnumNames ? '' : 'keypress'); + + static const $core.List values = + [ + keydown, + keyup, + keypress, + ]; + + static final $core.List _byValue = + $pb.ProtobufEnum.$_initByValueList(values, 2); + static KeyboardEvent_KeyEventType? valueOf($core.int value) => + value < 0 || value >= _byValue.length ? null : _byValue[value]; + + const KeyboardEvent_KeyEventType._(super.value, super.name); +} + +class ImageTransport_TransportChannel extends $pb.ProtobufEnum { + static const ImageTransport_TransportChannel TRANSPORT_CHANNEL_UNSPECIFIED = + ImageTransport_TransportChannel._( + 0, _omitEnumNames ? '' : 'TRANSPORT_CHANNEL_UNSPECIFIED'); + static const ImageTransport_TransportChannel MMAP = + ImageTransport_TransportChannel._(1, _omitEnumNames ? '' : 'MMAP'); + + static const $core.List values = + [ + TRANSPORT_CHANNEL_UNSPECIFIED, + MMAP, + ]; + + static final $core.List _byValue = + $pb.ProtobufEnum.$_initByValueList(values, 1); + static ImageTransport_TransportChannel? valueOf($core.int value) => + value < 0 || value >= _byValue.length ? null : _byValue[value]; + + const ImageTransport_TransportChannel._(super.value, super.name); +} + +class ImageFormat_ImgFormat extends $pb.ProtobufEnum { + static const ImageFormat_ImgFormat PNG = + ImageFormat_ImgFormat._(0, _omitEnumNames ? '' : 'PNG'); + static const ImageFormat_ImgFormat RGBA8888 = + ImageFormat_ImgFormat._(1, _omitEnumNames ? '' : 'RGBA8888'); + static const ImageFormat_ImgFormat RGB888 = + ImageFormat_ImgFormat._(2, _omitEnumNames ? '' : 'RGB888'); + + static const $core.List values = + [ + PNG, + RGBA8888, + RGB888, + ]; + + static final $core.List _byValue = + $pb.ProtobufEnum.$_initByValueList(values, 2); + static ImageFormat_ImgFormat? valueOf($core.int value) => + value < 0 || value >= _byValue.length ? null : _byValue[value]; + + const ImageFormat_ImgFormat._(super.value, super.name); +} + +class Rotation_SkinRotation extends $pb.ProtobufEnum { + static const Rotation_SkinRotation PORTRAIT = + Rotation_SkinRotation._(0, _omitEnumNames ? '' : 'PORTRAIT'); + static const Rotation_SkinRotation LANDSCAPE = + Rotation_SkinRotation._(1, _omitEnumNames ? '' : 'LANDSCAPE'); + static const Rotation_SkinRotation REVERSE_PORTRAIT = + Rotation_SkinRotation._(2, _omitEnumNames ? '' : 'REVERSE_PORTRAIT'); + static const Rotation_SkinRotation REVERSE_LANDSCAPE = + Rotation_SkinRotation._(3, _omitEnumNames ? '' : 'REVERSE_LANDSCAPE'); + + static const $core.List values = + [ + PORTRAIT, + LANDSCAPE, + REVERSE_PORTRAIT, + REVERSE_LANDSCAPE, + ]; + + static final $core.List _byValue = + $pb.ProtobufEnum.$_initByValueList(values, 3); + static Rotation_SkinRotation? valueOf($core.int value) => + value < 0 || value >= _byValue.length ? null : _byValue[value]; + + const Rotation_SkinRotation._(super.value, super.name); +} + +const $core.bool _omitEnumNames = + $core.bool.fromEnvironment('protobuf.omit_enum_names'); diff --git a/packages/simutil_adb/lib/src/emulator/generated/emulator_controller.pbgrpc.dart b/packages/simutil_adb/lib/src/emulator/generated/emulator_controller.pbgrpc.dart new file mode 100644 index 0000000..87441b7 --- /dev/null +++ b/packages/simutil_adb/lib/src/emulator/generated/emulator_controller.pbgrpc.dart @@ -0,0 +1,123 @@ +// This is a generated file - do not edit. +// +// Generated from emulator_controller.proto. + +// @dart = 3.3 + +// ignore_for_file: annotate_overrides, camel_case_types, comment_references +// ignore_for_file: constant_identifier_names +// ignore_for_file: curly_braces_in_flow_control_structures +// ignore_for_file: deprecated_member_use_from_same_package, library_prefixes +// ignore_for_file: non_constant_identifier_names, prefer_relative_imports + +import 'dart:async' as $async; +import 'dart:core' as $core; + +import 'package:grpc/service_api.dart' as $grpc; +import 'package:protobuf/protobuf.dart' as $pb; +import 'package:protobuf/well_known_types/google/protobuf/empty.pb.dart' as $1; + +import 'emulator_controller.pb.dart' as $0; + +export 'emulator_controller.pb.dart'; + +@$pb.GrpcServiceName('android.emulation.control.EmulatorController') +class EmulatorControllerClient extends $grpc.Client { + /// The hostname for this service. + static const $core.String defaultHost = ''; + + /// OAuth scopes needed for the client. + static const $core.List<$core.String> oauthScopes = [ + '', + ]; + + EmulatorControllerClient(super.channel, {super.options, super.interceptors}); + + $grpc.ResponseFuture<$1.Empty> streamInputEvent( + $async.Stream<$0.InputEvent> request, { + $grpc.CallOptions? options, + }) { + return $createStreamingCall(_$streamInputEvent, request, options: options) + .single; + } + + $grpc.ResponseFuture<$0.Image> getScreenshot( + $0.ImageFormat request, { + $grpc.CallOptions? options, + }) { + return $createUnaryCall(_$getScreenshot, request, options: options); + } + + $grpc.ResponseStream<$0.Image> streamScreenshot( + $0.ImageFormat request, { + $grpc.CallOptions? options, + }) { + return $createStreamingCall( + _$streamScreenshot, $async.Stream.fromIterable([request]), + options: options); + } + + // method descriptors + + static final _$streamInputEvent = $grpc.ClientMethod<$0.InputEvent, $1.Empty>( + '/android.emulation.control.EmulatorController/streamInputEvent', + ($0.InputEvent value) => value.writeToBuffer(), + $1.Empty.fromBuffer); + static final _$getScreenshot = $grpc.ClientMethod<$0.ImageFormat, $0.Image>( + '/android.emulation.control.EmulatorController/getScreenshot', + ($0.ImageFormat value) => value.writeToBuffer(), + $0.Image.fromBuffer); + static final _$streamScreenshot = + $grpc.ClientMethod<$0.ImageFormat, $0.Image>( + '/android.emulation.control.EmulatorController/streamScreenshot', + ($0.ImageFormat value) => value.writeToBuffer(), + $0.Image.fromBuffer); +} + +@$pb.GrpcServiceName('android.emulation.control.EmulatorController') +abstract class EmulatorControllerServiceBase extends $grpc.Service { + $core.String get $name => 'android.emulation.control.EmulatorController'; + + EmulatorControllerServiceBase() { + $addMethod($grpc.ServiceMethod<$0.InputEvent, $1.Empty>( + 'streamInputEvent', + streamInputEvent, + true, + false, + ($core.List<$core.int> value) => $0.InputEvent.fromBuffer(value), + ($1.Empty value) => value.writeToBuffer())); + $addMethod($grpc.ServiceMethod<$0.ImageFormat, $0.Image>( + 'getScreenshot', + getScreenshot_Pre, + false, + false, + ($core.List<$core.int> value) => $0.ImageFormat.fromBuffer(value), + ($0.Image value) => value.writeToBuffer())); + $addMethod($grpc.ServiceMethod<$0.ImageFormat, $0.Image>( + 'streamScreenshot', + streamScreenshot_Pre, + false, + true, + ($core.List<$core.int> value) => $0.ImageFormat.fromBuffer(value), + ($0.Image value) => value.writeToBuffer())); + } + + $async.Future<$1.Empty> streamInputEvent( + $grpc.ServiceCall call, $async.Stream<$0.InputEvent> request); + + $async.Future<$0.Image> getScreenshot_Pre( + $grpc.ServiceCall $call, $async.Future<$0.ImageFormat> $request) async { + return getScreenshot($call, await $request); + } + + $async.Future<$0.Image> getScreenshot( + $grpc.ServiceCall call, $0.ImageFormat request); + + $async.Stream<$0.Image> streamScreenshot_Pre( + $grpc.ServiceCall $call, $async.Future<$0.ImageFormat> $request) async* { + yield* streamScreenshot($call, await $request); + } + + $async.Stream<$0.Image> streamScreenshot( + $grpc.ServiceCall call, $0.ImageFormat request); +} diff --git a/packages/simutil_adb/lib/src/ffmpeg.dart b/packages/simutil_adb/lib/src/ffmpeg.dart new file mode 100644 index 0000000..b3bcb76 --- /dev/null +++ b/packages/simutil_adb/lib/src/ffmpeg.dart @@ -0,0 +1,37 @@ +import 'dart:io'; + +import 'package:simutil_core/simutil_core.dart'; + +/// GUI apps on macOS do not inherit the shell PATH. +const _ffmpegCandidates = [ + 'ffmpeg', + '/opt/homebrew/bin/ffmpeg', + '/usr/local/bin/ffmpeg', + '/usr/bin/ffmpeg', +]; + +/// Encoder arguments for H.264: hardware on macOS, libx264 elsewhere. +final h264EncoderArgs = Platform.isMacOS + ? const ['-c:v', 'h264_videotoolbox', '-b:v', '8M'] + : const ['-c:v', 'libx264', '-preset', 'veryfast', '-crf', '23']; + +/// Runs ffmpeg with [arguments]; false when it is missing or failed. +Future runFfmpeg( + CommandExec exec, + List arguments, { + Duration? timeout, +}) async { + for (final ffmpeg in _ffmpegCandidates) { + try { + final result = await exec.run( + ffmpeg, + arguments: arguments, + timeout: timeout, + ); + if (result.success) return true; + } catch (_) { + continue; + } + } + return false; +} diff --git a/packages/simutil_adb/lib/src/scrcpy/scrcpy_session.dart b/packages/simutil_adb/lib/src/scrcpy/scrcpy_session.dart index 7e6fa38..0793077 100644 --- a/packages/simutil_adb/lib/src/scrcpy/scrcpy_session.dart +++ b/packages/simutil_adb/lib/src/scrcpy/scrcpy_session.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'dart:math'; import 'dart:typed_data'; +import 'package:simutil_adb/src/ffmpeg.dart'; import 'package:simutil_adb/src/scrcpy/scrcpy_install.dart'; import 'package:simutil_adb/src/scrcpy/scrcpy_protocol.dart'; import 'package:simutil_adb/src/scrcpy/scrcpy_video_worker.dart'; @@ -373,31 +374,19 @@ class _ScrcpySession implements ScrcpySession { _recordingPath = null; if (path == null) return null; await _video?.record(null); - for (final ffmpeg in _ffmpegCandidates) { - final CommandResult remux; - try { - remux = await _exec.run( - ffmpeg, - arguments: [ - '-y', - '-loglevel', - 'error', - '-i', - '$path.ts', - '-c', - 'copy', - path, - ], - ); - } catch (_) { - continue; - } - if (remux.success) { - await File('$path.ts').delete(); - return path; - } - } - return '$path.ts'; + final remuxed = await runFfmpeg(_exec, [ + '-y', + '-loglevel', + 'error', + '-i', + '$path.ts', + '-c', + 'copy', + path, + ]); + if (!remuxed) return '$path.ts'; + await File('$path.ts').delete(); + return path; } @override @@ -428,12 +417,4 @@ class _ScrcpySession implements ScrcpySession { timeout: timeout, priority: CommandPriority.interactive, ); - - /// GUI apps on macOS do not inherit the shell PATH. - static const _ffmpegCandidates = [ - 'ffmpeg', - '/opt/homebrew/bin/ffmpeg', - '/usr/local/bin/ffmpeg', - '/usr/bin/ffmpeg', - ]; } diff --git a/packages/simutil_adb/protos/emulator_controller.proto b/packages/simutil_adb/protos/emulator_controller.proto new file mode 100644 index 0000000..cdc8486 --- /dev/null +++ b/packages/simutil_adb/protos/emulator_controller.proto @@ -0,0 +1,124 @@ +// Subset of the Android emulator's gRPC API that SimUtil uses, copied from +// $ANDROID_HOME/emulator/lib/emulator_controller.proto (emulator 37.2) with +// unused services, messages and fields removed. Field numbers and names must +// match the original. Regenerate the Dart code with +// `dart run melos run protoc` (needs protoc and protoc_plugin). +// +// Copyright (C) 2018 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +syntax = "proto3"; + +package android.emulation.control; +import "google/protobuf/empty.proto"; + +service EmulatorController { + rpc streamInputEvent(stream InputEvent) returns (google.protobuf.Empty) {} + rpc getScreenshot(ImageFormat) returns (Image) {} + rpc streamScreenshot(ImageFormat) returns (stream Image) {} +} + +message Touch { + int32 x = 1; + int32 y = 2; + int32 identifier = 3; + int32 pressure = 4; + int32 touch_major = 5; + int32 touch_minor = 6; +} + +message TouchEvent { + repeated Touch touches = 1; + int32 display = 2; +} + +message MouseEvent { + int32 x = 1; + int32 y = 2; + int32 buttons = 3; + int32 display = 4; +} + +message KeyboardEvent { + enum KeyCodeType { + Usb = 0; + Evdev = 1; + XKB = 2; + Win = 3; + Mac = 4; + } + + enum KeyEventType { + keydown = 0; + keyup = 1; + keypress = 2; + } + + KeyCodeType codeType = 1; + KeyEventType eventType = 2; + int32 keyCode = 3; + string key = 4; + string text = 5; +} + +message InputEvent { + oneof type { + KeyboardEvent key_event = 1; + TouchEvent touch_event = 2; + MouseEvent mouse_event = 3; + } +} + +message ImageTransport { + enum TransportChannel { + TRANSPORT_CHANNEL_UNSPECIFIED = 0; + MMAP = 1; + } + + TransportChannel channel = 1; + string handle = 2; +} + +message ImageFormat { + enum ImgFormat { + PNG = 0; + RGBA8888 = 1; + RGB888 = 2; + } + + ImgFormat format = 1; + Rotation rotation = 2; + uint32 width = 3; + uint32 height = 4; + uint32 display = 5; + ImageTransport transport = 6; +} + +message Image { + ImageFormat format = 1; + bytes image = 4; + uint32 seq = 5; + uint64 timestampUs = 6; +} + +message Rotation { + enum SkinRotation { + PORTRAIT = 0; + LANDSCAPE = 1; + REVERSE_PORTRAIT = 2; + REVERSE_LANDSCAPE = 3; + } + + SkinRotation rotation = 1; +} diff --git a/packages/simutil_adb/pubspec.yaml b/packages/simutil_adb/pubspec.yaml index 7085dc4..cb70ba7 100644 --- a/packages/simutil_adb/pubspec.yaml +++ b/packages/simutil_adb/pubspec.yaml @@ -12,7 +12,9 @@ resolution: workspace dependencies: ffi: ^2.1.0 + grpc: ^5.1.0 multicast_dns: ^0.3.3 + protobuf: ^6.1.0 simutil_core: ^1.3.0 dev_dependencies: diff --git a/packages/simutil_adb/test/emulator/emulator_endpoint_test.dart b/packages/simutil_adb/test/emulator/emulator_endpoint_test.dart new file mode 100644 index 0000000..eb1bdef --- /dev/null +++ b/packages/simutil_adb/test/emulator/emulator_endpoint_test.dart @@ -0,0 +1,120 @@ +import 'dart:io'; + +import 'package:simutil_adb/simutil_adb.dart'; +import 'package:test/test.dart'; + +const _ini = ''' +avd.id=Pixel_7_Pro +port.serial=5554 +port.adb=5555 +grpc.port=8554 +grpc.token=secret +'''; + +void main() { + group('EmulatorEndpoint.parse', () { + test('reads port and token for the matching serial', () { + final endpoint = EmulatorEndpoint.parse(_ini, serialPort: 5554)!; + expect(endpoint.port, 8554); + expect(endpoint.token, 'secret'); + }); + + test('ignores another emulator', () { + expect(EmulatorEndpoint.parse(_ini, serialPort: 5556), isNull); + }); + + test('null without grpc.port (-no-grpc)', () { + expect( + EmulatorEndpoint.parse('port.serial=5554\n', serialPort: 5554), + isNull, + ); + }); + + test('no token when the emulator writes none', () { + final endpoint = EmulatorEndpoint.parse( + 'port.serial=5554\r\ngrpc.port=8554\r\n', + serialPort: 5554, + )!; + expect(endpoint.port, 8554); + expect(endpoint.token, isNull); + }); + }); + + group('EmulatorEndpoint.find', () { + late Directory dir; + setUp(() => dir = Directory.systemTemp.createTempSync('avd-running-')); + tearDown(() => dir.deleteSync(recursive: true)); + + test('matches the serial and prefers the newest file', () async { + File('${dir.path}/pid_1.ini') + ..writeAsStringSync(_ini.replaceFirst('8554', '9000')) + ..setLastModifiedSync(DateTime(2020)); + File('${dir.path}/pid_2.ini').writeAsStringSync(_ini); + File('${dir.path}/pid_3.ini').writeAsStringSync( + _ini.replaceFirst('5554', '5556').replaceFirst('8554', '8556'), + ); + File('${dir.path}/other.txt').writeAsStringSync(_ini); + + expect( + (await EmulatorEndpoint.find( + 'emulator-5554', + directory: dir.path, + ))?.port, + 8554, + ); + expect( + (await EmulatorEndpoint.find( + 'emulator-5556', + directory: dir.path, + ))?.port, + 8556, + ); + }); + + test('null for devices, unknown emulators and missing dirs', () async { + File('${dir.path}/pid_2.ini').writeAsStringSync(_ini); + expect( + await EmulatorEndpoint.find('R5CT1234', directory: dir.path), + isNull, + ); + expect( + await EmulatorEndpoint.find('emulator-5558', directory: dir.path), + isNull, + ); + expect( + await EmulatorEndpoint.find( + 'emulator-5554', + directory: '${dir.path}/missing', + ), + isNull, + ); + }); + }); + + test('discoveryDirectory per platform', () { + expect( + EmulatorEndpoint.discoveryDirectory({ + 'HOME': '/Users/me', + }, operatingSystem: 'macos'), + '/Users/me/Library/Caches/TemporaryItems/avd/running', + ); + expect( + EmulatorEndpoint.discoveryDirectory({ + 'LOCALAPPDATA': r'C:\Users\me\AppData\Local', + }, operatingSystem: 'windows'), + r'C:\Users\me\AppData\Local\Temp\avd\running', + ); + expect( + EmulatorEndpoint.discoveryDirectory({ + 'XDG_RUNTIME_DIR': '/run/user/1000', + }, operatingSystem: 'linux'), + '/run/user/1000/avd/running', + ); + expect( + EmulatorEndpoint.discoveryDirectory({ + 'USER': 'me', + }, operatingSystem: 'linux'), + '/tmp/android-me/avd/running', + ); + }); +} diff --git a/packages/simutil_adb/test/emulator/emulator_input_test.dart b/packages/simutil_adb/test/emulator/emulator_input_test.dart new file mode 100644 index 0000000..80556a1 --- /dev/null +++ b/packages/simutil_adb/test/emulator/emulator_input_test.dart @@ -0,0 +1,64 @@ +import 'package:simutil_adb/src/emulator/emulator_input.dart'; +import 'package:simutil_adb/src/emulator/generated/emulator_controller.pb.dart'; +import 'package:simutil_core/simutil_core.dart'; +import 'package:test/test.dart'; + +void main() { + Touch touch( + TouchPhase phase, + double x, + double y, [ + Rotation_SkinRotation rotation = Rotation_SkinRotation.PORTRAIT, + ]) => EmulatorInput.touch( + phase, + x, + y, + width: 1001, + height: 2001, + rotation: rotation, + ).touchEvent.touches.single; + + test('touch maps to display pixels with pressure until up', () { + final down = touch(TouchPhase.down, 0.25, 0.75); + expect( + (down.x, down.y, down.pressure), + (250, 1500, EmulatorInput.pressure), + ); + expect(touch(TouchPhase.move, 0.5, 0.5).pressure, EmulatorInput.pressure); + expect(touch(TouchPhase.up, 0.5, 0.5).pressure, 0); + final clamped = touch(TouchPhase.down, -1, 2); + expect((clamped.x, clamped.y), (0, 2000)); + }); + + // Checked on an emulator with pointer_location: (0.25, 0.75) of the + // rotated frame lands there in every rotation. + test('touch undoes the screen rotation', () { + Touch at(Rotation_SkinRotation r) => touch(TouchPhase.down, 0.25, 0.75, r); + final landscape = at(Rotation_SkinRotation.LANDSCAPE); + expect((landscape.x, landscape.y), (250, 500)); + final reversePortrait = at(Rotation_SkinRotation.REVERSE_PORTRAIT); + expect((reversePortrait.x, reversePortrait.y), (750, 500)); + final reverseLandscape = at(Rotation_SkinRotation.REVERSE_LANDSCAPE); + expect((reverseLandscape.x, reverseLandscape.y), (750, 1500)); + }); + + test('buttons and keys are W3C key presses', () { + final home = EmulatorInput.key( + EmulatorInput.buttonKey(DeviceButton.home), + ).keyEvent; + expect(home.key, 'GoHome'); + expect(home.eventType, KeyboardEvent_KeyEventType.keypress); + expect(DeviceButton.values.map(EmulatorInput.buttonKey), [ + 'GoBack', + 'GoHome', + 'AppSwitch', + 'Power', + 'AudioVolumeUp', + 'AudioVolumeDown', + ]); + }); + + test('text goes in KeyboardEvent.text', () { + expect(EmulatorInput.text('hi there').keyEvent.text, 'hi there'); + }); +} diff --git a/packages/simutil_h264/CHANGELOG.md b/packages/simutil_h264/CHANGELOG.md index c8e2ca1..274c195 100644 --- a/packages/simutil_h264/CHANGELOG.md +++ b/packages/simutil_h264/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.2.0 + +- `SharedFrameTexture` (macOS) shows packed RGB888 frames that another + process writes into a shared file it maps, such as the Android emulator's + gRPC MMAP transport, without a video codec. + ## 0.1.0 - Initial release: `H264Decoder` decodes Annex-B H.264 access units into a diff --git a/packages/simutil_h264/lib/simutil_h264.dart b/packages/simutil_h264/lib/simutil_h264.dart index 3e4e488..8b6a9da 100644 --- a/packages/simutil_h264/lib/simutil_h264.dart +++ b/packages/simutil_h264/lib/simutil_h264.dart @@ -53,3 +53,58 @@ class H264Decoder { return bytes.buffer.asByteData()..setInt64(0, textureId, Endian.little); } } + +/// A texture showing packed RGB888 frames that another process writes into +/// the shared file at [path] (e.g. the Android emulator's gRPC MMAP +/// transport). macOS only for now. +class SharedFrameTexture { + SharedFrameTexture._(this.textureId, this._path); + + static const _channel = MethodChannel('simutil/h264'); + static const _presentChannel = 'simutil/h264/present'; + + /// Id for a `Texture` widget. + final int textureId; + + String _path; + + /// The owner-only file the writer fills; changes on [resize]. + String get path => _path; + + /// Creates a texture with a shared file of [capacity] bytes. + static Future create(int capacity) async { + final reply = (await _channel.invokeMapMethod( + 'sharedCreate', + {'capacity': capacity}, + ))!; + return SharedFrameTexture._(reply['id']! as int, reply['path']! as String); + } + + /// Replaces the shared file with one of [capacity] bytes at a new [path]; + /// the old file is deleted, so the writer must move first or stop. + Future resize(int capacity) async => + _path = (await _channel.invokeMethod('sharedResize', { + 'id': textureId, + 'capacity': capacity, + }))!; + + /// Shows the [width]x[height] frame now in the file. + void present(int width, int height) { + final message = ByteData(16) + ..setInt64(0, textureId, Endian.little) + ..setInt32(8, width, Endian.little) + ..setInt32(12, height, Endian.little); + ServicesBinding.instance.defaultBinaryMessenger.send( + _presentChannel, + message, + ); + } + + /// Frames shown on the texture so far. + Future framesShown() async => + await _channel.invokeMethod('frames', {'id': textureId}) ?? 0; + + /// Unmaps and deletes the file and releases the texture. + Future dispose() => + _channel.invokeMethod('dispose', {'id': textureId}); +} diff --git a/packages/simutil_h264/macos/simutil_h264/Sources/simutil_h264/SharedFrames.swift b/packages/simutil_h264/macos/simutil_h264/Sources/simutil_h264/SharedFrames.swift new file mode 100644 index 0000000..1a5539f --- /dev/null +++ b/packages/simutil_h264/macos/simutil_h264/Sources/simutil_h264/SharedFrames.swift @@ -0,0 +1,133 @@ +// Packed RGB888 frames that another process writes into a file we map (the +// Android emulator's gRPC MMAP transport), converted to BGRA for a texture. + +import Accelerate +import CoreVideo +import Foundation + +/// A shared frame file plus the latest converted frame. +final class SharedFrames: NSObject { + private let queue = DispatchQueue(label: "simutil.frames", qos: .userInteractive) + /// Guards `latest`, `fresh`, `shown` and `queued`; held only for swaps. + private let lock = NSLock() + private var latest: CVPixelBuffer? + private var fresh = false + private var shown = 0 + private var queued: (Int, Int)? + // Queue only. + private var file: (path: String, map: UnsafeMutableRawPointer, capacity: Int)? + private var pool: CVPixelBufferPool? + private var poolSize = (0, 0) + + /// Called on the frame queue after each converted frame. + var onFrame: (() -> Void)? + + /// Frames handed to the texture so far. + var framesShown: Int { + lock.lock() + defer { lock.unlock() } + return shown + } + + @objc func copyPixelBuffer() -> Unmanaged? { + lock.lock() + defer { lock.unlock() } + if fresh { + fresh = false + shown += 1 + } + return latest.map { Unmanaged.passRetained($0) } + } + + /// Replaces the shared file with a new owner-only one of `capacity` bytes; + /// returns its path, or nil when it cannot be created. The writer must move + /// to the new path: the old file is unmapped and deleted. + func resize(_ capacity: Int) -> String? { + queue.sync { + unmap() + let path = NSTemporaryDirectory() + "simutil-frames-\(UUID().uuidString)" + let fd = open(path, O_RDWR | O_CREAT | O_EXCL, 0o600) + guard fd >= 0 else { return nil } + defer { close(fd) } + guard ftruncate(fd, off_t(capacity)) == 0, + let map = mmap(nil, capacity, PROT_READ, MAP_SHARED, fd, 0), map != MAP_FAILED + else { + unlink(path) + return nil + } + file = (path, map, capacity) + return path + } + } + + /// A new `width`x`height` frame is in the file. Frames arriving while one + /// is converted collapse into one: the file only holds the newest anyway. + func present(width: Int, height: Int) { + guard width > 0, height > 0 else { return } + lock.lock() + let busy = queued != nil + queued = (width, height) + lock.unlock() + if busy { return } + queue.async { [self] in + lock.lock() + let size = queued + queued = nil + lock.unlock() + if let (width, height) = size, let buffer = convert(width, height) { + lock.lock() + latest = buffer + fresh = true + lock.unlock() + onFrame?() + } + } + } + + func stop() { + queue.sync { + unmap() + onFrame = nil + } + } + + private func unmap() { + guard let file else { return } + munmap(file.map, file.capacity) + unlink(file.path) + self.file = nil + } + + private func convert(_ width: Int, _ height: Int) -> CVPixelBuffer? { + guard let file, width * height * 3 <= file.capacity else { return nil } + if pool == nil || poolSize != (width, height) { + pool = nil + poolSize = (width, height) + let attributes: [String: Any] = [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, + kCVPixelBufferWidthKey as String: width, + kCVPixelBufferHeightKey as String: height, + kCVPixelBufferIOSurfacePropertiesKey as String: [:], + kCVPixelBufferMetalCompatibilityKey as String: true, + ] + CVPixelBufferPoolCreate(nil, nil, attributes as CFDictionary, &pool) + } + guard let pool else { return nil } + var out: CVPixelBuffer? + CVPixelBufferPoolCreatePixelBuffer(nil, pool, &out) + guard let out else { return nil } + CVPixelBufferLockBaseAddress(out, []) + defer { CVPixelBufferUnlockBaseAddress(out, []) } + var src = vImage_Buffer( + data: file.map, height: vImagePixelCount(height), width: vImagePixelCount(width), + rowBytes: width * 3) + var dst = vImage_Buffer( + data: CVPixelBufferGetBaseAddress(out), height: vImagePixelCount(height), + width: vImagePixelCount(width), rowBytes: CVPixelBufferGetBytesPerRow(out)) + // ponytail: the writer may already be filling the next frame, so a fast + // scroll can tear; double-buffering needs one file per frame upstream. + let error = vImageConvert_RGB888toBGRA8888( + &src, nil, 255, &dst, false, vImage_Flags(kvImageNoFlags)) + return error == kvImageNoError ? out : nil + } +} diff --git a/packages/simutil_h264/macos/simutil_h264/Sources/simutil_h264/SimutilH264Plugin.swift b/packages/simutil_h264/macos/simutil_h264/Sources/simutil_h264/SimutilH264Plugin.swift index 914865c..d3fdf29 100644 --- a/packages/simutil_h264/macos/simutil_h264/Sources/simutil_h264/SimutilH264Plugin.swift +++ b/packages/simutil_h264/macos/simutil_h264/Sources/simutil_h264/SimutilH264Plugin.swift @@ -3,6 +3,10 @@ import FlutterMacOS extension H264Stream: FlutterTexture {} +extension SharedFrames: FlutterTexture {} + +/// Shared-frame textures by id; main thread only. +private var sharedFrames: [Int64: SharedFrames] = [:] /// Decoders by texture id. Global so `decodeFunction` reaches them from any /// thread; every access holds `registryLock`, which also serializes @@ -31,6 +35,11 @@ private let decodeFunction: @convention(c) (Int64, UnsafePointer?, Int64) /// dispose({id}), decodeFunction → address of `decodeFunction`. Access units arrive on the binary channel /// `simutil/h264/decode` as `int64 LE id` + Annex-B bytes; the reply is /// `[1]` when frames were dropped and the sender should send a key frame. +/// +/// Shared RGB888 frames: sharedCreate({capacity}) → {id, path}, +/// sharedResize({id, capacity}) → path, and frames / dispose as above. A new +/// frame is announced on `simutil/h264/present` as `int64 LE id`, `int32 LE +/// width`, `int32 LE height`. public final class SimutilH264Plugin: NSObject, FlutterPlugin { private let textures: FlutterTextureRegistry @@ -45,6 +54,17 @@ public final class SimutilH264Plugin: NSObject, FlutterPlugin { registrar.messenger.setMessageHandlerOnChannel("simutil/h264/decode") { message, reply in reply(decodeMessage(message) ? Data([1]) : nil) } + registrar.messenger.setMessageHandlerOnChannel("simutil/h264/present") { message, reply in + if let message, message.count >= 16 { + message.withUnsafeBytes { + let id = Int64(littleEndian: $0.loadUnaligned(as: Int64.self)) + let width = Int32(littleEndian: $0.loadUnaligned(fromByteOffset: 8, as: Int32.self)) + let height = Int32(littleEndian: $0.loadUnaligned(fromByteOffset: 12, as: Int32.self)) + sharedFrames[id]?.present(width: Int(width), height: Int(height)) + } + } + reply(nil) + } } private static func decodeMessage(_ message: Data?) -> Bool { @@ -60,26 +80,29 @@ public final class SimutilH264Plugin: NSObject, FlutterPlugin { case "create": let decoder = H264Stream() let textureId = textures.register(decoder) - // One main-thread hop per batch of decoded frames, not per frame. - let pending = NSLock() - var scheduled = false - decoder.onFrame = { [weak self] in - pending.lock() - defer { pending.unlock() } - if scheduled { return } - scheduled = true - DispatchQueue.main.async { - pending.lock() - scheduled = false - pending.unlock() - self?.textures.textureFrameAvailable(textureId) - } - } + decoder.onFrame = coalesced { [weak self] in self?.textures.textureFrameAvailable(textureId) } registryLock.lock() registry[textureId] = decoder registryLock.unlock() result(textureId) + case "sharedCreate": + let frames = SharedFrames() + guard let path = frames.resize((args["capacity"] as? NSNumber)?.intValue ?? 0) else { + return result(FlutterError(code: "io", message: "Cannot create a frame file", details: nil)) + } + let textureId = textures.register(frames) + frames.onFrame = coalesced { [weak self] in self?.textures.textureFrameAvailable(textureId) } + sharedFrames[textureId] = frames + result(["id": textureId, "path": path]) + case "sharedResize": + let capacity = (args["capacity"] as? NSNumber)?.intValue ?? 0 + if let path = sharedFrames[id]?.resize(capacity) { + result(path) + } else { + result(FlutterError(code: "io", message: "Cannot create a frame file", details: nil)) + } case "frames": + if let frames = sharedFrames[id] { return result(frames.framesShown) } registryLock.lock() let decoder = registry[id] registryLock.unlock() @@ -87,6 +110,11 @@ public final class SimutilH264Plugin: NSObject, FlutterPlugin { case "decodeFunction": result(Int64(Int(bitPattern: unsafeBitCast(decodeFunction, to: UnsafeRawPointer.self)))) case "dispose": + if let frames = sharedFrames.removeValue(forKey: id) { + frames.stop() + textures.unregisterTexture(id) + return result(nil) + } registryLock.lock() let removed = registry.removeValue(forKey: id) registryLock.unlock() @@ -100,3 +128,22 @@ public final class SimutilH264Plugin: NSObject, FlutterPlugin { } } } + +/// Runs `body` on the main thread once per batch of calls from other +/// threads, not once per call (one hop per batch of frames). +private func coalesced(_ body: @escaping () -> Void) -> () -> Void { + let pending = NSLock() + var scheduled = false + return { + pending.lock() + defer { pending.unlock() } + if scheduled { return } + scheduled = true + DispatchQueue.main.async { + pending.lock() + scheduled = false + pending.unlock() + body() + } + } +} diff --git a/packages/simutil_h264/pubspec.yaml b/packages/simutil_h264/pubspec.yaml index a914d2e..0f21ff6 100644 --- a/packages/simutil_h264/pubspec.yaml +++ b/packages/simutil_h264/pubspec.yaml @@ -1,6 +1,6 @@ name: simutil_h264 description: Native H.264 decoding into Flutter textures for the SimUtil desktop app. -version: 0.1.0 +version: 0.2.0 homepage: https://github.com/dungngminh/simutil repository: https://github.com/dungngminh/simutil/tree/main/packages/simutil_h264 resolution: workspace diff --git a/pubspec.lock b/pubspec.lock index 22e0310..92a4691 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -369,6 +369,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.3+6" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: transitive description: flutter @@ -416,6 +424,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + google_cloud: + dependency: transitive + description: + name: google_cloud + sha256: b385e20726ef5315d302c5933bfb728103116c5be2d3d17094b01a82da538c1f + url: "https://pub.dev" + source: hosted + version: "0.5.0" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" + url: "https://pub.dev" + source: hosted + version: "0.3.3+1" + googleapis_auth: + dependency: transitive + description: + name: googleapis_auth + sha256: e15d48204e2f3c852dc1091a49705a163a8ba5897823f9abe1c0795e512357c4 + url: "https://pub.dev" + source: hosted + version: "2.3.5" graphs: dependency: transitive description: @@ -424,6 +456,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + grpc: + dependency: transitive + description: + name: grpc + sha256: "0cde49bc17f6b1e96c374ad1b5d389816cc8ade2015f24294eaa76193de87090" + url: "https://pub.dev" + source: hosted + version: "5.2.0" hooks: dependency: transitive description: @@ -448,6 +488,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.6.0" + http2: + dependency: transitive + description: + name: http2 + sha256: "382d3aefc5bd6dc68c6b892d7664f29b5beb3251611ae946a98d35158a82bbfa" + url: "https://pub.dev" + source: hosted + version: "2.3.1" http_multi_server: dependency: transitive description: @@ -760,6 +808,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + protobuf: + dependency: transitive + description: + name: protobuf + sha256: "13b6717366aa7ec3792dd62c19c7d33a785708224365f2db23c5ca01f1c1a0f2" + url: "https://pub.dev" + source: hosted + version: "6.1.0" pub_semver: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a678c31..6121d3d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -58,6 +58,13 @@ melos: dart run packages/simutil/tool/generate_version.dart && dart format packages/simutil/lib/src/version.dart packages/simutil_shared/lib/src/changelog_entries.dart + protoc: + description: Regenerate simutil_adb's emulator gRPC stubs (needs protoc and `dart pub global activate protoc_plugin`) + run: >- + protoc --dart_out=grpc:packages/simutil_adb/lib/src/emulator/generated + -Ipackages/simutil_adb/protos emulator_controller.proto && + rm packages/simutil_adb/lib/src/emulator/generated/*.pbjson.dart + compile: description: Compile the simutil executable to ./simutil run: dart compile exe packages/simutil/bin/simutil.dart -o simutil