Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions app/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
111 changes: 111 additions & 0 deletions app/lib/src/stream/android/emulator_video_view.dart
Original file line number Diff line number Diff line change
@@ -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<EmulatorVideoView> createState() => _EmulatorVideoViewState();
}

class _EmulatorVideoViewState extends State<EmulatorVideoView> {
SharedFrameTexture? _texture;
StreamSubscription<EmulatorFrame>? _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<void> _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)),
};
},
);
}
}
37 changes: 20 additions & 17 deletions app/lib/src/stream/device_keyboard.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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, (int, String)>{
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, (int, String, String)>{
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,
Expand All @@ -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:
Expand Down
27 changes: 25 additions & 2 deletions app/lib/src/stream/streams_cubit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,9 @@ class StreamsCubit extends CubitSignal<StreamsState> {
}
}

/// 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,
Expand All @@ -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(
Expand Down Expand Up @@ -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?> _emulatorEndpoint(String serial) async {
for (var attempt = 0; attempt < 10; attempt++) {
if (await EmulatorEndpoint.find(serial) case final endpoint?) {
return endpoint;
}
await Future<void>.delayed(const Duration(milliseconds: 500));
}
return null;
}

/// Version of `assets/scrcpy-server` (Genymobile/scrcpy release, Apache-2.0).
const _bundledScrcpyVersion = '4.0';

Expand Down
2 changes: 2 additions & 0 deletions app/lib/src/ui/shared/session_view.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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(),
Expand Down
2 changes: 1 addition & 1 deletion app/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions app/test/device_keyboard_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@ class _Scrcpy implements ScrcpySession {
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}

class _Emulator implements EmulatorGrpcSession {
final sent = <String>[];

@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,
Expand Down Expand Up @@ -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', '<Enter>', '<ArrowUp>']);
});
}
6 changes: 3 additions & 3 deletions docs/ai/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand All @@ -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).
Expand Down
6 changes: 6 additions & 0 deletions packages/simutil_adb/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<pid>.ini`).
- `emulatorCameraArgs(back:, front:)` builds `-camera-back` /
`-camera-front` emulator args from `webcam<N>`, `image:<path>`,
`video:<path>`, `virtualscene`, `emulated` or `none`; one webcam cannot
Expand Down
4 changes: 4 additions & 0 deletions packages/simutil_adb/analysis_options.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
include: ../../analysis_options.yaml

analyzer:
exclude:
- lib/src/emulator/generated/**

linter:
rules:
public_member_api_docs: true
Expand Down
5 changes: 4 additions & 1 deletion packages/simutil_adb/lib/simutil_adb.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down
Loading
Loading