From 5ebc0cbb1880e7e37c7f01c3d1da78e9588b7627 Mon Sep 17 00:00:00 2001 From: Michael Krohn Date: Fri, 6 Nov 2026 14:53:47 +0100 Subject: [PATCH 1/8] fix: return from emulator launch once it is up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit launchDevice ran the emulator through CommandExec.run, which waits for the process to exit: `simutil launch` never returned, the TUI stayed on "Launching…", every running emulator held a CommandQueue slot until it quit, and a failed start was reported as success. Start the emulator in its own process group, wait a short grace period for an early exit and throw EmulatorLaunchException with its FATAL / ERROR lines; otherwise return and let it run. simutil_adb 1.5.0. --- app/CHANGELOG.md | 7 + app/pubspec.yaml | 2 +- packages/simutil/CHANGELOG.md | 2 + .../lib/src/cli/commands/launch_command.dart | 19 ++- packages/simutil/pubspec.yaml | 2 +- packages/simutil_adb/CHANGELOG.md | 13 ++ packages/simutil_adb/lib/simutil_adb.dart | 1 + .../lib/src/android_device_service.dart | 82 ++++++++- .../src/models/emulator_launch_exception.dart | 15 ++ packages/simutil_adb/pubspec.yaml | 2 +- .../services/android_device_service_test.dart | 155 +++++++++++++++--- 11 files changed, 266 insertions(+), 34 deletions(-) create mode 100644 packages/simutil_adb/lib/src/models/emulator_launch_exception.dart diff --git a/app/CHANGELOG.md b/app/CHANGELOG.md index bd20639..f4e6873 100644 --- a/app/CHANGELOG.md +++ b/app/CHANGELOG.md @@ -25,6 +25,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A stream's title wraps to two lines before it is cut. - Settings changed in a device window last until it closes. +### Fixed + +- Running several Android emulators no longer stalls SimUtil: each one + used to block a slot for device commands until it was shut down. +- An Android emulator that fails to start (for example because it is + already running) shows its error. + ## [0.5.0] - 2026-10-23 ### Added diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 7b40e42..6e12916 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -16,7 +16,7 @@ dependencies: bloc_signals_flutter: ^1.4.0 equatable: ^2.0.7 get_it: ^9.3.0 - simutil_adb: ^1.4.0 + simutil_adb: ^1.5.0 simutil_apple: ^1.4.0 simutil_core: ^1.3.0 simutil_h264: ^0.2.0 diff --git a/packages/simutil/CHANGELOG.md b/packages/simutil/CHANGELOG.md index 6610ad0..4425187 100644 --- a/packages/simutil/CHANGELOG.md +++ b/packages/simutil/CHANGELOG.md @@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Starting an Android emulator no longer hangs: `simutil launch` returns as soon as the emulator is up, the TUI shows "launched" right away, and pressing Ctrl+C afterwards no longer closes the emulator. +- If an Android emulator fails to start (for example because it is already running), SimUtil shows the emulator's error instead of reporting success. - Headless Android emulators (`simutil launch --headless`) use your computer's GPU, so they run much faster. ## [1.2.0] - 2026-10-17 diff --git a/packages/simutil/lib/src/cli/commands/launch_command.dart b/packages/simutil/lib/src/cli/commands/launch_command.dart index e9f6142..b66e049 100644 --- a/packages/simutil/lib/src/cli/commands/launch_command.dart +++ b/packages/simutil/lib/src/cli/commands/launch_command.dart @@ -81,13 +81,18 @@ class LaunchCommand extends SimutilCommand { osHint: osHintFromFlags(argResults!), ); - await _services.launchDevice( - device, - cold: argResults!['cold'] == true, - noAudio: argResults!['no-audio'] == true, - headless: argResults!['headless'] == true, - cameraArgs: cameraArgs, - ); + try { + await _services.launchDevice( + device, + cold: argResults!['cold'] == true, + noAudio: argResults!['no-audio'] == true, + headless: argResults!['headless'] == true, + cameraArgs: cameraArgs, + ); + } on EmulatorLaunchException catch (e) { + logger.err('Failed to launch ${device.name} (${device.id}): $e'); + return 1; + } logger.success('Launched ${device.name} (${device.id})'); return 0; diff --git a/packages/simutil/pubspec.yaml b/packages/simutil/pubspec.yaml index 2c41c9a..69d7141 100644 --- a/packages/simutil/pubspec.yaml +++ b/packages/simutil/pubspec.yaml @@ -15,7 +15,7 @@ dependencies: ascii_qr: ^1.0.1 mason_logger: ^0.3.6 nocterm: ^0.9.0 - simutil_adb: ^1.4.0 + simutil_adb: ^1.5.0 simutil_apple: ^1.3.0 simutil_core: ^1.3.0 simutil_plugins: ^1.0.0 diff --git a/packages/simutil_adb/CHANGELOG.md b/packages/simutil_adb/CHANGELOG.md index f15eb6b..792333d 100644 --- a/packages/simutil_adb/CHANGELOG.md +++ b/packages/simutil_adb/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 1.5.0 + +- `AndroidDeviceService.launchDevice` returns once the emulator is up + instead of when it exits. It used to wait for the emulator to quit and + held a `CommandExec` slot the whole time, so a few running emulators + stalled every other command. The emulator now starts in its own process + group (`ProcessStartMode.detachedWithStdio`) and outlives the caller. +- `launchDevice` throws an `EmulatorLaunchException` with the emulator's + `FATAL` / `ERROR` output when it exits within the launch grace period + (e.g. the AVD is already running); failures used to be ignored. +- `AndroidDeviceService` takes `startProcess` (`EmulatorProcessStarter`) + and `launchGracePeriod` test seams. + ## 1.4.0 - `EmulatorGrpcSession` streams and controls an Android emulator over its diff --git a/packages/simutil_adb/lib/simutil_adb.dart b/packages/simutil_adb/lib/simutil_adb.dart index 1e3c66d..7c1e257 100644 --- a/packages/simutil_adb/lib/simutil_adb.dart +++ b/packages/simutil_adb/lib/simutil_adb.dart @@ -24,6 +24,7 @@ export 'src/scrcpy/scrcpy_session.dart'; export 'src/models/adb_connect_result.dart'; export 'src/models/android_quick_launch_option.dart'; export 'src/models/emulator_camera.dart'; +export 'src/models/emulator_launch_exception.dart'; export 'src/models/wifi_pairing_device.dart'; export 'src/models/wireless_connect_request.dart'; export 'src/models/wireless_pairing_info.dart'; diff --git a/packages/simutil_adb/lib/src/android_device_service.dart b/packages/simutil_adb/lib/src/android_device_service.dart index a733d61..5a73130 100644 --- a/packages/simutil_adb/lib/src/android_device_service.dart +++ b/packages/simutil_adb/lib/src/android_device_service.dart @@ -4,25 +4,40 @@ import 'dart:io'; import 'package:simutil_adb/src/adb_device_watch.dart'; import 'package:simutil_adb/src/models/adb_connect_result.dart'; +import 'package:simutil_adb/src/models/emulator_launch_exception.dart'; import 'package:simutil_adb/src/models/wireless_pairing_info.dart'; import 'package:simutil_core/simutil_core.dart'; +/// Starts the emulator process for `AndroidDeviceService.launchDevice`; +/// `Process.start` by default. +typedef EmulatorProcessStarter = + Future Function( + String executable, + List arguments, { + ProcessStartMode mode, + }); + /// Android emulators and hardware devices via `adb` and the SDK emulator. class AndroidDeviceService implements DeviceService { /// Creates a service using [CommandExec] for all shell work. /// /// [androidHomeOverride] wins over `ANDROID_HOME` / `ANDROID_SDK_ROOT`. - /// [environment], [fileExists] and [isWindows] are test seams. + /// [environment], [fileExists], [isWindows], [startProcess] and + /// [launchGracePeriod] are test seams. AndroidDeviceService( this._exec, { String? androidHomeOverride, Map? environment, bool Function(String path)? fileExists, bool? isWindows, + EmulatorProcessStarter? startProcess, + Duration launchGracePeriod = const Duration(seconds: 5), }) : _androidHomeOverride = androidHomeOverride, _environment = environment, _fileExists = fileExists, - _isWindows = isWindows ?? Platform.isWindows; + _isWindows = isWindows ?? Platform.isWindows, + _startProcess = startProcess ?? Process.start, + _launchGracePeriod = launchGracePeriod; static const Duration _deviceListTimeout = Duration(seconds: 15); static const Duration _wirelessTimeout = Duration(seconds: 15); @@ -34,6 +49,8 @@ class AndroidDeviceService implements DeviceService { final Map? _environment; final bool Function(String path)? _fileExists; final bool _isWindows; + final EmulatorProcessStarter _startProcess; + final Duration _launchGracePeriod; /// SDK binaries are `adb.exe` / `emulator.exe` on Windows. String get _exe => _isWindows ? '.exe' : ''; @@ -187,6 +204,14 @@ class AndroidDeviceService implements DeviceService { } } + /// Starts the emulator and returns once it is up, without waiting for it + /// to exit. + /// + /// The emulator runs in its own process group (Ctrl+C on the caller does + /// not stop it) and outlives the caller, so it does not go through + /// [CommandExec], which waits for exit. If it exits within the launch + /// grace period (5 s), throws an [EmulatorLaunchException] with its error + /// output. @override Future launchDevice({ required String deviceId, @@ -201,13 +226,60 @@ class AndroidDeviceService implements DeviceService { if (headless && !additionalArgs.contains('-gpu')) ...headlessGpuArgs, ...additionalArgs, }.toList(); - await _exec.run( + final process = await _startProcess( emulatorPath, - arguments: launchArgs, - priority: CommandPriority.interactive, + launchArgs, + mode: ProcessStartMode.detachedWithStdio, ); + unawaited(process.stdin.close().catchError((_) {})); + + // A detached process has no exit code: both pipes closing within the + // grace period means the emulator exited. + final output = StringBuffer(); + final closed = [Completer(), Completer()]; + final subscriptions = [ + for (final (i, stream) in [process.stdout, process.stderr].indexed) + stream + .transform(systemEncoding.decoder) + .listen( + output.write, + onError: (_) {}, + onDone: closed[i].complete, + cancelOnError: false, + ), + ]; + final exited = await Future.wait([ + for (final c in closed) c.future, + ]).then((_) => true).timeout(_launchGracePeriod, onTimeout: () => false); + // Let go of the pipes so this process can exit; the emulator does not + // need a reader and keeps running. + for (final subscription in subscriptions) { + await subscription.cancel(); + } + if (exited) { + throw EmulatorLaunchException(deviceId, launchError(output.toString())); + } } + /// The error to show for an emulator that exited at launch: its `FATAL` / + /// `ERROR` lines without the level prefix, else the last lines it printed. + static String launchError(String output) { + final lines = output + .split('\n') + .map((l) => l.trim()) + .where((l) => l.isNotEmpty) + .toList(); + final errors = [ + for (final line in lines) + if (_launchErrorLine.firstMatch(line) case final m?) m.group(1)!.trim(), + ]; + if (errors.isNotEmpty) return errors.join('\n'); + if (lines.isEmpty) return 'The emulator exited right after starting'; + return lines.skip(lines.length > 5 ? lines.length - 5 : 0).join('\n'); + } + + static final RegExp _launchErrorLine = RegExp(r'^(?:FATAL|ERROR)\s*\|(.*)$'); + /// Emulator flags added by `headless`: no window, audio or boot animation, /// so many emulators fit at once while something else shows the screen. static const headlessArgs = ['-no-window', '-no-audio', '-no-boot-anim']; diff --git a/packages/simutil_adb/lib/src/models/emulator_launch_exception.dart b/packages/simutil_adb/lib/src/models/emulator_launch_exception.dart new file mode 100644 index 0000000..477eb21 --- /dev/null +++ b/packages/simutil_adb/lib/src/models/emulator_launch_exception.dart @@ -0,0 +1,15 @@ +/// Thrown by `AndroidDeviceService.launchDevice` when the emulator exits +/// right after it was started (e.g. the AVD is already running or locked). +class EmulatorLaunchException implements Exception { + /// Creates an exception for [avd] with the emulator's own [message]. + const EmulatorLaunchException(this.avd, this.message); + + /// The AVD that failed to start. + final String avd; + + /// The emulator's error output (its `FATAL` / `ERROR` lines when present). + final String message; + + @override + String toString() => message; +} diff --git a/packages/simutil_adb/pubspec.yaml b/packages/simutil_adb/pubspec.yaml index 5cc6c25..48ecefe 100644 --- a/packages/simutil_adb/pubspec.yaml +++ b/packages/simutil_adb/pubspec.yaml @@ -1,6 +1,6 @@ name: simutil_adb description: Android emulator and device APIs for SimUtil via adb. -version: 1.4.0 +version: 1.5.0 homepage: https://github.com/dungngminh/simutil repository: https://github.com/dungngminh/simutil/tree/main/packages/simutil_adb issue_tracker: https://github.com/dungngminh/simutil/issues diff --git a/packages/simutil_adb/test/services/android_device_service_test.dart b/packages/simutil_adb/test/services/android_device_service_test.dart index 0499b68..de420a2 100644 --- a/packages/simutil_adb/test/services/android_device_service_test.dart +++ b/packages/simutil_adb/test/services/android_device_service_test.dart @@ -1,7 +1,10 @@ +import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:simutil_core/simutil_core.dart'; import 'package:simutil_adb/src/android_device_service.dart'; +import 'package:simutil_adb/src/models/emulator_launch_exception.dart'; import 'package:test/test.dart'; import 'package:simutil_core/testing.dart'; @@ -394,23 +397,74 @@ void main() { }); group('launchDevice', () { - test('prefixes the avd id with @ and appends extra args', () async { - final exec = FakeCommandExec((_, _) => FakeCommandExec.ok()); + late List<({String executable, List args, ProcessStartMode mode})> + starts; + late _FakeProcess process; - await service( - exec, - ).launchDevice(deviceId: 'Pixel_7', additionalArgs: ['-no-audio']); + setUp(() { + starts = []; + process = _FakeProcess(); + }); + + tearDown(() => process.close()); + + AndroidDeviceService launcher() => AndroidDeviceService( + FakeCommandExec((_, _) => null), + androidHomeOverride: sdkDir.path, + isWindows: false, + launchGracePeriod: const Duration(milliseconds: 50), + startProcess: (executable, arguments, {mode = ProcessStartMode.normal}) { + starts.add((executable: executable, args: arguments, mode: mode)); + return Future.value(process); + }, + ); - expect(exec.calls.single.command, emulatorPath); - expect(exec.calls.single.arguments, ['@Pixel_7', '-no-audio']); + test('starts the emulator detached, prefixing the avd id with @', () async { + await launcher().launchDevice( + deviceId: 'Pixel_7', + additionalArgs: ['-no-audio'], + ); + + expect(starts.single.executable, emulatorPath); + expect(starts.single.args, ['@Pixel_7', '-no-audio']); + expect(starts.single.mode, ProcessStartMode.detachedWithStdio); + }); + + test('returns while the emulator keeps running', () async { + await launcher() + .launchDevice(deviceId: 'Pixel_7') + .timeout(const Duration(seconds: 2)); + + expect(process.stdoutCancelled, isTrue); + expect(process.stderrCancelled, isTrue); + }); + + test('throws the FATAL lines when the emulator exits at once', () async { + process + ..writeStdout( + 'INFO | Android emulator version 37.2.12.0\n' + 'FATAL | Running multiple emulators with the same AVD.\n', + ) + ..close(); + + await expectLater( + launcher().launchDevice(deviceId: 'Pixel_7'), + throwsA( + isA() + .having((e) => e.avd, 'avd', 'Pixel_7') + .having( + (e) => e.message, + 'message', + 'Running multiple emulators with the same AVD.', + ), + ), + ); }); test('headless adds the no-window flags', () async { - final exec = FakeCommandExec((_, _) => FakeCommandExec.ok()); - - await service(exec).launchDevice(deviceId: 'Pixel_7', headless: true); + await launcher().launchDevice(deviceId: 'Pixel_7', headless: true); - expect(exec.calls.single.arguments, [ + expect(starts.single.args, [ '@Pixel_7', ...AndroidDeviceService.headlessArgs, ...AndroidDeviceService.headlessGpuArgs, @@ -418,15 +472,13 @@ void main() { }); test('headless keeps the gpu mode the caller passes', () async { - final exec = FakeCommandExec((_, _) => FakeCommandExec.ok()); - - await service(exec).launchDevice( + await launcher().launchDevice( deviceId: 'Pixel_7', headless: true, additionalArgs: ['-gpu', 'swiftshader_indirect'], ); - expect(exec.calls.single.arguments, [ + expect(starts.single.args, [ '@Pixel_7', ...AndroidDeviceService.headlessArgs, '-gpu', @@ -435,15 +487,13 @@ void main() { }); test('headless does not repeat flags the caller passes', () async { - final exec = FakeCommandExec((_, _) => FakeCommandExec.ok()); - - await service(exec).launchDevice( + await launcher().launchDevice( deviceId: 'Pixel_7', headless: true, additionalArgs: ['-no-audio', '-no-snapshot-load'], ); - expect(exec.calls.single.arguments, [ + expect(starts.single.args, [ '@Pixel_7', ...AndroidDeviceService.headlessArgs, ...AndroidDeviceService.headlessGpuArgs, @@ -452,6 +502,32 @@ void main() { }); }); + group('launchError', () { + test('strips the level prefix from ERROR and FATAL lines', () { + expect( + AndroidDeviceService.launchError( + 'INFO | booting\nERROR | bad gpu\nFATAL | no avd\n', + ), + 'bad gpu\nno avd', + ); + }); + + test('falls back to the last lines printed', () { + final output = [for (var i = 1; i <= 7; i++) 'line $i'].join('\n'); + expect( + AndroidDeviceService.launchError(output), + 'line 3\nline 4\nline 5\nline 6\nline 7', + ); + }); + + test('has a message for an exit without output', () { + expect( + AndroidDeviceService.launchError(''), + 'The emulator exited right after starting', + ); + }); + }); + group('deleteSimulator', () { late Directory avdHome; setUp(() => avdHome = Directory('${sdkDir.path}/avd')..createSync()); @@ -629,3 +705,44 @@ List of web cameras connected to the computer: ); }); } + +/// A [Process] whose output the test writes and closes. +class _FakeProcess implements Process { + final _stdout = StreamController>(); + final _stderr = StreamController>(); + bool stdoutCancelled = false; + bool stderrCancelled = false; + + void writeStdout(String text) => _stdout.add(utf8.encode(text)); + + /// Closes both pipes, like an exited process. + void close() { + if (!_stdout.isClosed) unawaited(_stdout.close()); + if (!_stderr.isClosed) unawaited(_stderr.close()); + } + + @override + Stream> get stdout { + _stdout.onCancel = () => stdoutCancelled = true; + return _stdout.stream; + } + + @override + Stream> get stderr { + _stderr.onCancel = () => stderrCancelled = true; + return _stderr.stream; + } + + @override + final IOSink stdin = IOSink(StreamController>().sink); + + @override + int get pid => 4242; + + @override + Future get exitCode => + throw StateError('exitCode is not available for detached processes'); + + @override + bool kill([ProcessSignal signal = ProcessSignal.sigterm]) => false; +} From b14f38de7680aa53f0815bbf25dcfaa2149a5e22 Mon Sep 17 00:00:00 2001 From: Michael Krohn Date: Fri, 6 Nov 2026 15:03:18 +0100 Subject: [PATCH 2/8] fix: report emulator start failures as EmulatorLaunchException --- packages/simutil_adb/CHANGELOG.md | 3 ++- .../lib/src/android_device_service.dart | 22 +++++++++++----- .../services/android_device_service_test.dart | 26 +++++++++++++++++++ 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/packages/simutil_adb/CHANGELOG.md b/packages/simutil_adb/CHANGELOG.md index 792333d..0c40ba3 100644 --- a/packages/simutil_adb/CHANGELOG.md +++ b/packages/simutil_adb/CHANGELOG.md @@ -9,7 +9,8 @@ group (`ProcessStartMode.detachedWithStdio`) and outlives the caller. - `launchDevice` throws an `EmulatorLaunchException` with the emulator's `FATAL` / `ERROR` output when it exits within the launch grace period - (e.g. the AVD is already running); failures used to be ignored. + (e.g. the AVD is already running), and when the emulator binary cannot + be started; failures used to be ignored. - `AndroidDeviceService` takes `startProcess` (`EmulatorProcessStarter`) and `launchGracePeriod` test seams. diff --git a/packages/simutil_adb/lib/src/android_device_service.dart b/packages/simutil_adb/lib/src/android_device_service.dart index 5a73130..0704452 100644 --- a/packages/simutil_adb/lib/src/android_device_service.dart +++ b/packages/simutil_adb/lib/src/android_device_service.dart @@ -210,8 +210,8 @@ class AndroidDeviceService implements DeviceService { /// The emulator runs in its own process group (Ctrl+C on the caller does /// not stop it) and outlives the caller, so it does not go through /// [CommandExec], which waits for exit. If it exits within the launch - /// grace period (5 s), throws an [EmulatorLaunchException] with its error - /// output. + /// grace period (5 s), or cannot be started at all, throws an + /// [EmulatorLaunchException] with its error output. @override Future launchDevice({ required String deviceId, @@ -226,11 +226,19 @@ class AndroidDeviceService implements DeviceService { if (headless && !additionalArgs.contains('-gpu')) ...headlessGpuArgs, ...additionalArgs, }.toList(); - final process = await _startProcess( - emulatorPath, - launchArgs, - mode: ProcessStartMode.detachedWithStdio, - ); + final Process process; + try { + process = await _startProcess( + emulatorPath, + launchArgs, + mode: ProcessStartMode.detachedWithStdio, + ); + } on ProcessException catch (e) { + throw EmulatorLaunchException( + deviceId, + 'Could not start ${e.executable}: ${e.message}', + ); + } unawaited(process.stdin.close().catchError((_) {})); // A detached process has no exit code: both pipes closing within the diff --git a/packages/simutil_adb/test/services/android_device_service_test.dart b/packages/simutil_adb/test/services/android_device_service_test.dart index de420a2..3e726f6 100644 --- a/packages/simutil_adb/test/services/android_device_service_test.dart +++ b/packages/simutil_adb/test/services/android_device_service_test.dart @@ -461,6 +461,32 @@ void main() { ); }); + test('throws when the emulator binary cannot be started', () async { + final svc = AndroidDeviceService( + FakeCommandExec((_, _) => null), + androidHomeOverride: sdkDir.path, + isWindows: false, + startProcess: + (executable, arguments, {mode = ProcessStartMode.normal}) => + throw ProcessException( + executable, + arguments, + 'Permission denied', + ), + ); + + await expectLater( + svc.launchDevice(deviceId: 'Pixel_7'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Could not start $emulatorPath: Permission denied', + ), + ), + ); + }); + test('headless adds the no-window flags', () async { await launcher().launchDevice(deviceId: 'Pixel_7', headless: true); From f43fbc589afd8fafc0fdf8de5f19e4064d2a9ddd Mon Sep 17 00:00:00 2001 From: Michael Krohn Date: Sat, 7 Nov 2026 11:53:52 +0100 Subject: [PATCH 3/8] feat(core): add DeviceLaunchException for failed device launches --- packages/simutil_core/CHANGELOG.md | 6 +++++ packages/simutil_core/lib/simutil_core.dart | 1 + .../simutil_core/lib/src/device_service.dart | 4 +++ .../src/models/device_launch_exception.dart | 17 ++++++++++++ packages/simutil_core/pubspec.yaml | 2 +- .../models/device_launch_exception_test.dart | 26 +++++++++++++++++++ 6 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 packages/simutil_core/lib/src/models/device_launch_exception.dart create mode 100644 packages/simutil_core/test/models/device_launch_exception_test.dart diff --git a/packages/simutil_core/CHANGELOG.md b/packages/simutil_core/CHANGELOG.md index 9ff6218..5c7f93f 100644 --- a/packages/simutil_core/CHANGELOG.md +++ b/packages/simutil_core/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.4.0 + +- `DeviceLaunchException`: the common type for a simulator or emulator + that does not start (`deviceId`, `message`); platform packages extend + it, and `DeviceService.launchDevice` documents throwing it. + ## 1.3.0 - `DeviceAutomation.install(device, path)` installs an app package on a diff --git a/packages/simutil_core/lib/simutil_core.dart b/packages/simutil_core/lib/simutil_core.dart index d373298..26d83a3 100644 --- a/packages/simutil_core/lib/simutil_core.dart +++ b/packages/simutil_core/lib/simutil_core.dart @@ -16,6 +16,7 @@ export 'src/command_queue.dart'; export 'src/device_service.dart'; export 'src/isolate_runner.dart'; export 'src/models/device.dart'; +export 'src/models/device_launch_exception.dart'; export 'src/models/device_os.dart'; export 'src/models/device_state.dart'; export 'src/models/device_type.dart'; diff --git a/packages/simutil_core/lib/src/device_service.dart b/packages/simutil_core/lib/src/device_service.dart index e259f29..15c4750 100644 --- a/packages/simutil_core/lib/src/device_service.dart +++ b/packages/simutil_core/lib/src/device_service.dart @@ -1,4 +1,5 @@ import 'package:simutil_core/src/models/device.dart'; +import 'package:simutil_core/src/models/device_launch_exception.dart'; /// Discovers, launches, and shuts down devices for one platform. abstract class DeviceService { @@ -22,6 +23,9 @@ abstract class DeviceService { /// /// [headless] starts it without its own window (no Android emulator /// window, no Simulator app), for UIs that stream the screen themselves. + /// + /// Throws a [DeviceLaunchException] (or a platform subtype) when the + /// device does not start. Future launchDevice({ required String deviceId, List additionalArgs = const [], diff --git a/packages/simutil_core/lib/src/models/device_launch_exception.dart b/packages/simutil_core/lib/src/models/device_launch_exception.dart new file mode 100644 index 0000000..ded4571 --- /dev/null +++ b/packages/simutil_core/lib/src/models/device_launch_exception.dart @@ -0,0 +1,17 @@ +/// Thrown by `DeviceService.launchDevice` when a simulator or emulator does +/// not start. Each platform extends it (Android's `EmulatorLaunchException`, +/// Apple's `SimulatorLaunchException`), so callers can catch launch failures +/// of any device with this one type. +class DeviceLaunchException implements Exception { + /// Creates an exception for [deviceId] with the tool's own [message]. + const DeviceLaunchException(this.deviceId, this.message); + + /// The device that failed to start (an AVD name, a simulator UDID, …). + final String deviceId; + + /// Why it did not start, as the platform tool reported it. + final String message; + + @override + String toString() => message; +} diff --git a/packages/simutil_core/pubspec.yaml b/packages/simutil_core/pubspec.yaml index 9d1bd37..142ca60 100644 --- a/packages/simutil_core/pubspec.yaml +++ b/packages/simutil_core/pubspec.yaml @@ -1,6 +1,6 @@ name: simutil_core description: Core models and process execution for SimUtil. -version: 1.3.0 +version: 1.4.0 homepage: https://github.com/dungngminh/simutil repository: https://github.com/dungngminh/simutil/tree/main/packages/simutil_core issue_tracker: https://github.com/dungngminh/simutil/issues diff --git a/packages/simutil_core/test/models/device_launch_exception_test.dart b/packages/simutil_core/test/models/device_launch_exception_test.dart new file mode 100644 index 0000000..d706744 --- /dev/null +++ b/packages/simutil_core/test/models/device_launch_exception_test.dart @@ -0,0 +1,26 @@ +import 'package:simutil_core/simutil_core.dart'; +import 'package:test/test.dart'; + +class _PlatformLaunchException extends DeviceLaunchException { + const _PlatformLaunchException(super.deviceId, super.message); +} + +void main() { + test('shows the tool message and keeps the device id', () { + const e = DeviceLaunchException('Pixel_7', 'AVD is already running'); + + expect(e.deviceId, 'Pixel_7'); + expect('$e', 'AVD is already running'); + }); + + test('platform exceptions are caught as DeviceLaunchException', () { + Object? caught; + try { + throw const _PlatformLaunchException('udid', 'boot failed'); + } on DeviceLaunchException catch (e) { + caught = e; + } + + expect(caught, isA<_PlatformLaunchException>()); + }); +} From 708efce36bbc436e2cf6e13dda5c916223989155 Mon Sep 17 00:00:00 2001 From: Michael Krohn Date: Sat, 7 Nov 2026 12:00:58 +0100 Subject: [PATCH 4/8] refactor(adb): extend DeviceLaunchException from core --- app/pubspec.yaml | 2 +- .../lib/src/cli/commands/launch_command.dart | 3 ++- packages/simutil/pubspec.yaml | 2 +- packages/simutil_adb/CHANGELOG.md | 3 ++- .../src/models/emulator_launch_exception.dart | 22 ++++++++----------- packages/simutil_adb/pubspec.yaml | 2 +- .../services/android_device_service_test.dart | 2 +- 7 files changed, 17 insertions(+), 19 deletions(-) diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 6e12916..09f1a1b 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -18,7 +18,7 @@ dependencies: get_it: ^9.3.0 simutil_adb: ^1.5.0 simutil_apple: ^1.4.0 - simutil_core: ^1.3.0 + simutil_core: ^1.4.0 simutil_h264: ^0.2.0 simutil_shared: ^1.1.0 window_manager: ^0.5.0 diff --git a/packages/simutil/lib/src/cli/commands/launch_command.dart b/packages/simutil/lib/src/cli/commands/launch_command.dart index b66e049..b91b4b3 100644 --- a/packages/simutil/lib/src/cli/commands/launch_command.dart +++ b/packages/simutil/lib/src/cli/commands/launch_command.dart @@ -4,6 +4,7 @@ import 'package:simutil/src/cli/cli_device_services.dart'; import 'package:simutil/src/cli/cli_flags.dart'; import 'package:simutil/src/cli/commands/simutil_command.dart'; import 'package:simutil_adb/simutil_adb.dart'; +import 'package:simutil_core/simutil_core.dart'; /// Boots an emulator or simulator by device id. class LaunchCommand extends SimutilCommand { @@ -89,7 +90,7 @@ class LaunchCommand extends SimutilCommand { headless: argResults!['headless'] == true, cameraArgs: cameraArgs, ); - } on EmulatorLaunchException catch (e) { + } on DeviceLaunchException catch (e) { logger.err('Failed to launch ${device.name} (${device.id}): $e'); return 1; } diff --git a/packages/simutil/pubspec.yaml b/packages/simutil/pubspec.yaml index 69d7141..99184ab 100644 --- a/packages/simutil/pubspec.yaml +++ b/packages/simutil/pubspec.yaml @@ -17,7 +17,7 @@ dependencies: nocterm: ^0.9.0 simutil_adb: ^1.5.0 simutil_apple: ^1.3.0 - simutil_core: ^1.3.0 + simutil_core: ^1.4.0 simutil_plugins: ^1.0.0 simutil_shared: ^1.1.0 diff --git a/packages/simutil_adb/CHANGELOG.md b/packages/simutil_adb/CHANGELOG.md index 0c40ba3..9cbe9cb 100644 --- a/packages/simutil_adb/CHANGELOG.md +++ b/packages/simutil_adb/CHANGELOG.md @@ -10,7 +10,8 @@ - `launchDevice` throws an `EmulatorLaunchException` with the emulator's `FATAL` / `ERROR` output when it exits within the launch grace period (e.g. the AVD is already running), and when the emulator binary cannot - be started; failures used to be ignored. + be started; failures used to be ignored. `EmulatorLaunchException` + extends `DeviceLaunchException` from `simutil_core` (now `^1.4.0`). - `AndroidDeviceService` takes `startProcess` (`EmulatorProcessStarter`) and `launchGracePeriod` test seams. diff --git a/packages/simutil_adb/lib/src/models/emulator_launch_exception.dart b/packages/simutil_adb/lib/src/models/emulator_launch_exception.dart index 477eb21..6524aed 100644 --- a/packages/simutil_adb/lib/src/models/emulator_launch_exception.dart +++ b/packages/simutil_adb/lib/src/models/emulator_launch_exception.dart @@ -1,15 +1,11 @@ -/// Thrown by `AndroidDeviceService.launchDevice` when the emulator exits -/// right after it was started (e.g. the AVD is already running or locked). -class EmulatorLaunchException implements Exception { - /// Creates an exception for [avd] with the emulator's own [message]. - const EmulatorLaunchException(this.avd, this.message); - - /// The AVD that failed to start. - final String avd; +import 'package:simutil_core/simutil_core.dart'; - /// The emulator's error output (its `FATAL` / `ERROR` lines when present). - final String message; - - @override - String toString() => message; +/// Thrown by `AndroidDeviceService.launchDevice` when the emulator exits +/// right after it was started (e.g. the AVD is already running or locked) +/// or cannot be started at all. [deviceId] is the AVD name; [message] is +/// the emulator's error output (its `FATAL` / `ERROR` lines when present). +class EmulatorLaunchException extends DeviceLaunchException { + /// Creates an exception for the AVD [deviceId] with the emulator's own + /// [message]. + const EmulatorLaunchException(super.deviceId, super.message); } diff --git a/packages/simutil_adb/pubspec.yaml b/packages/simutil_adb/pubspec.yaml index 48ecefe..df19436 100644 --- a/packages/simutil_adb/pubspec.yaml +++ b/packages/simutil_adb/pubspec.yaml @@ -16,7 +16,7 @@ dependencies: grpc: ^5.1.0 multicast_dns: ^0.3.3 protobuf: ^6.1.0 - simutil_core: ^1.3.0 + simutil_core: ^1.4.0 dev_dependencies: lints: ^6.0.0 diff --git a/packages/simutil_adb/test/services/android_device_service_test.dart b/packages/simutil_adb/test/services/android_device_service_test.dart index 3e726f6..72ea67d 100644 --- a/packages/simutil_adb/test/services/android_device_service_test.dart +++ b/packages/simutil_adb/test/services/android_device_service_test.dart @@ -451,7 +451,7 @@ void main() { launcher().launchDevice(deviceId: 'Pixel_7'), throwsA( isA() - .having((e) => e.avd, 'avd', 'Pixel_7') + .having((e) => e.deviceId, 'deviceId', 'Pixel_7') .having( (e) => e.message, 'message', From 3cc7a65581db138dabfc3acaf75d1db8e1816106 Mon Sep 17 00:00:00 2001 From: Michael Krohn Date: Sat, 7 Nov 2026 12:07:28 +0100 Subject: [PATCH 5/8] docs(core): describe DeviceLaunchException without naming subtypes --- packages/simutil_core/lib/src/device_service.dart | 5 +++-- .../simutil_core/lib/src/models/device_launch_exception.dart | 5 ++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/simutil_core/lib/src/device_service.dart b/packages/simutil_core/lib/src/device_service.dart index 15c4750..0288727 100644 --- a/packages/simutil_core/lib/src/device_service.dart +++ b/packages/simutil_core/lib/src/device_service.dart @@ -24,8 +24,9 @@ abstract class DeviceService { /// [headless] starts it without its own window (no Android emulator /// window, no Simulator app), for UIs that stream the screen themselves. /// - /// Throws a [DeviceLaunchException] (or a platform subtype) when the - /// device does not start. + /// Implementations throw a [DeviceLaunchException] (or their platform + /// subtype) when the device does not start, rather than returning + /// normally. Future launchDevice({ required String deviceId, List additionalArgs = const [], diff --git a/packages/simutil_core/lib/src/models/device_launch_exception.dart b/packages/simutil_core/lib/src/models/device_launch_exception.dart index ded4571..e7f68be 100644 --- a/packages/simutil_core/lib/src/models/device_launch_exception.dart +++ b/packages/simutil_core/lib/src/models/device_launch_exception.dart @@ -1,7 +1,6 @@ /// Thrown by `DeviceService.launchDevice` when a simulator or emulator does -/// not start. Each platform extends it (Android's `EmulatorLaunchException`, -/// Apple's `SimulatorLaunchException`), so callers can catch launch failures -/// of any device with this one type. +/// not start. Platform packages extend it with their own exception, so +/// callers can catch launch failures of any device with this one type. class DeviceLaunchException implements Exception { /// Creates an exception for [deviceId] with the tool's own [message]. const DeviceLaunchException(this.deviceId, this.message); From 39419a5d72ba6483a235277608ac58ced5d2f86f Mon Sep 17 00:00:00 2001 From: Michael Krohn Date: Mon, 9 Nov 2026 12:15:13 +0100 Subject: [PATCH 6/8] docs(core): state what launchDevice failures cover --- packages/simutil_core/lib/src/device_service.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/simutil_core/lib/src/device_service.dart b/packages/simutil_core/lib/src/device_service.dart index 0288727..318bbdc 100644 --- a/packages/simutil_core/lib/src/device_service.dart +++ b/packages/simutil_core/lib/src/device_service.dart @@ -25,8 +25,10 @@ abstract class DeviceService { /// window, no Simulator app), for UIs that stream the screen themselves. /// /// Implementations throw a [DeviceLaunchException] (or their platform - /// subtype) when the device does not start, rather than returning - /// normally. + /// subtype) when the launch itself fails, rather than returning normally. + /// Completing does not mean the device has finished booting: a process + /// that starts and fails later (e.g. an emulator that crashes while + /// booting) is not reported here. Future launchDevice({ required String deviceId, List additionalArgs = const [], From 36642e0bf2e5c4a018da1fa5f4b9fbe5a0399d5b Mon Sep 17 00:00:00 2001 From: Michael Krohn Date: Wed, 11 Nov 2026 08:55:13 +0100 Subject: [PATCH 7/8] docs(app): move changelog entries under Unreleased --- app/CHANGELOG.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/app/CHANGELOG.md b/app/CHANGELOG.md index f4e6873..4261e3a 100644 --- a/app/CHANGELOG.md +++ b/app/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Running several Android emulators no longer stalls SimUtil: each one + used to block a slot for device commands until it was shut down. +- An Android emulator that fails to start (for example because it is + already running) shows its error. + ## [0.6.0] - 2026-11-11 ### Added @@ -25,13 +32,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A stream's title wraps to two lines before it is cut. - Settings changed in a device window last until it closes. -### Fixed - -- Running several Android emulators no longer stalls SimUtil: each one - used to block a slot for device commands until it was shut down. -- An Android emulator that fails to start (for example because it is - already running) shows its error. - ## [0.5.0] - 2026-10-23 ### Added From 6a24db738348107b8016d5b60d2a5e1859f02ee0 Mon Sep 17 00:00:00 2001 From: Michael Krohn Date: Fri, 6 Nov 2026 15:08:45 +0100 Subject: [PATCH 8/8] fix: use the host gpu for windowed emulator launches on macOS With -gpu auto, API 37+ AVDs on macOS render in software and hang, then crash, about 15 s after boot. Headless launches already used -gpu host, so switching between windowed and headless launches also changed the renderer and invalidated the quick-boot snapshot. Add -gpu host on macOS for windowed launches too, and for headless ones everywhere as before, unless the caller passes -gpu or the AVD's config.ini sets its own hw.gpu.mode (or disables the GPU). headlessGpuArgs becomes hostGpuArgs. Fixes #82 --- app/CHANGELOG.md | 4 ++ packages/simutil/CHANGELOG.md | 1 + packages/simutil_adb/CHANGELOG.md | 12 +++- .../lib/src/android_device_service.dart | 58 ++++++++++++--- .../services/android_device_service_test.dart | 70 ++++++++++++++++++- 5 files changed, 132 insertions(+), 13 deletions(-) diff --git a/app/CHANGELOG.md b/app/CHANGELOG.md index 4261e3a..76aaa1c 100644 --- a/app/CHANGELOG.md +++ b/app/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 used to block a slot for device commands until it was shut down. - An Android emulator that fails to start (for example because it is already running) shows its error. +- Android emulators started with a window use your Mac's GPU, like + headless ones: emulators with API 37 or newer no longer crash shortly + after starting, and switching between window and headless no longer + forces a slow cold boot. A GPU mode set in Android Studio is kept. ## [0.6.0] - 2026-11-11 diff --git a/packages/simutil/CHANGELOG.md b/packages/simutil/CHANGELOG.md index 4425187..a68b7d2 100644 --- a/packages/simutil/CHANGELOG.md +++ b/packages/simutil/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Starting an Android emulator no longer hangs: `simutil launch` returns as soon as the emulator is up, the TUI shows "launched" right away, and pressing Ctrl+C afterwards no longer closes the emulator. - If an Android emulator fails to start (for example because it is already running), SimUtil shows the emulator's error instead of reporting success. +- On a Mac, Android emulators started with a window use your GPU too: emulators with API 37 or newer no longer crash shortly after starting, and switching between window and headless no longer forces a slow cold boot. A GPU mode set for the emulator in Android Studio is kept. - Headless Android emulators (`simutil launch --headless`) use your computer's GPU, so they run much faster. ## [1.2.0] - 2026-10-17 diff --git a/packages/simutil_adb/CHANGELOG.md b/packages/simutil_adb/CHANGELOG.md index 9cbe9cb..e7e7f6d 100644 --- a/packages/simutil_adb/CHANGELOG.md +++ b/packages/simutil_adb/CHANGELOG.md @@ -12,8 +12,16 @@ (e.g. the AVD is already running), and when the emulator binary cannot be started; failures used to be ignored. `EmulatorLaunchException` extends `DeviceLaunchException` from `simutil_core` (now `^1.4.0`). -- `AndroidDeviceService` takes `startProcess` (`EmulatorProcessStarter`) - and `launchGracePeriod` test seams. +- `launchDevice` adds `-gpu host` on macOS for windowed launches too, not + only headless ones: with `-gpu auto`, API 37+ AVDs render in software + and crash shortly after boot, and switching renderers between windowed + and headless launches invalidated the quick-boot snapshot. Launches keep + the AVD's own `hw.gpu.mode` when it is not `auto` + (`AndroidDeviceService.usesAutoGpu`), and the caller's `-gpu` as before. +- `AndroidDeviceService.hostGpuArgs` replaces `headlessGpuArgs`, which is + deprecated. +- `AndroidDeviceService` takes `startProcess` (`EmulatorProcessStarter`), + `launchGracePeriod` and `isMacOS` test seams. ## 1.4.0 diff --git a/packages/simutil_adb/lib/src/android_device_service.dart b/packages/simutil_adb/lib/src/android_device_service.dart index 0704452..056e899 100644 --- a/packages/simutil_adb/lib/src/android_device_service.dart +++ b/packages/simutil_adb/lib/src/android_device_service.dart @@ -22,20 +22,22 @@ class AndroidDeviceService implements DeviceService { /// Creates a service using [CommandExec] for all shell work. /// /// [androidHomeOverride] wins over `ANDROID_HOME` / `ANDROID_SDK_ROOT`. - /// [environment], [fileExists], [isWindows], [startProcess] and - /// [launchGracePeriod] are test seams. + /// [environment], [fileExists], [isWindows], [isMacOS], [startProcess] + /// and [launchGracePeriod] are test seams. AndroidDeviceService( this._exec, { String? androidHomeOverride, Map? environment, bool Function(String path)? fileExists, bool? isWindows, + bool? isMacOS, EmulatorProcessStarter? startProcess, Duration launchGracePeriod = const Duration(seconds: 5), }) : _androidHomeOverride = androidHomeOverride, _environment = environment, _fileExists = fileExists, _isWindows = isWindows ?? Platform.isWindows, + _isMacOS = isMacOS ?? Platform.isMacOS, _startProcess = startProcess ?? Process.start, _launchGracePeriod = launchGracePeriod; @@ -49,6 +51,7 @@ class AndroidDeviceService implements DeviceService { final Map? _environment; final bool Function(String path)? _fileExists; final bool _isWindows; + final bool _isMacOS; final EmulatorProcessStarter _startProcess; final Duration _launchGracePeriod; @@ -212,18 +215,26 @@ class AndroidDeviceService implements DeviceService { /// [CommandExec], which waits for exit. If it exits within the launch /// grace period (5 s), or cannot be started at all, throws an /// [EmulatorLaunchException] with its error output. + /// + /// Adds [hostGpuArgs] when the launch is headless or on macOS, unless the + /// caller passes `-gpu` or the AVD sets its own GPU mode (see + /// [usesAutoGpu]). @override Future launchDevice({ required String deviceId, List additionalArgs = const [], bool headless = false, }) async { + final hostGpu = + (headless || _isMacOS) && + // The caller's own `-gpu` wins; a set would merge the two values. + !additionalArgs.contains('-gpu') && + await usesAutoGpu(deviceId); // A set: `-no-audio` may come from both headless and the caller. final launchArgs = { '@$deviceId', if (headless) ...headlessArgs, - // The caller's own `-gpu` wins; a set would merge the two values. - if (headless && !additionalArgs.contains('-gpu')) ...headlessGpuArgs, + if (hostGpu) ...hostGpuArgs, ...additionalArgs, }.toList(); final Process process; @@ -292,10 +303,41 @@ class AndroidDeviceService implements DeviceService { /// so many emulators fit at once while something else shows the screen. static const headlessArgs = ['-no-window', '-no-audio', '-no-boot-anim']; - /// GPU flags added by `headless` unless the caller passes `-gpu`: without a - /// window `-gpu auto` picks software rendering (lavapipe), which streams - /// at ~5 fps; the host GPU streams at ~55. - static const headlessGpuArgs = ['-gpu', 'host']; + /// GPU flags [launchDevice] adds to headless launches and to every launch + /// on macOS. `-gpu auto` picks software rendering without a window + /// (lavapipe, ~5 fps instead of ~55 when streamed) and, on macOS, for + /// API 37+ AVDs, which then hang and crash shortly after boot. One mode + /// for windowed and headless launches also keeps the quick-boot snapshot + /// valid: a renderer change forces a cold boot. + static const hostGpuArgs = ['-gpu', 'host']; + + /// Former name of [hostGpuArgs]. + @Deprecated('Use hostGpuArgs') + static const headlessGpuArgs = hostGpuArgs; + + /// Whether the AVD [name] leaves the GPU mode to the emulator: + /// `hw.gpu.mode` in its `config.ini` is `auto` or missing, and + /// `hw.gpu.enabled` is not `no`. Also `true` when the config cannot be + /// read. A mode set in Android Studio (e.g. `swiftshader_indirect`) is + /// kept. + Future usesAutoGpu(String name) async { + final ini = File('$avdHome/$name.ini'); + try { + if (!ini.existsSync()) return true; + final config = File('${(await _avdDir(ini, name)).path}/config.ini'); + if (!config.existsSync()) return true; + final values = { + for (final line in await config.readAsLines()) + if (line.split('=') case [final key, final value]) + key.trim(): value.trim(), + }; + final mode = values['hw.gpu.mode']; + return values['hw.gpu.enabled'] != 'no' && + (mode == null || mode == 'auto'); + } on FileSystemException { + return true; + } + } /// Runs `adb connect [host]`. Future connectDevice(String host) async { diff --git a/packages/simutil_adb/test/services/android_device_service_test.dart b/packages/simutil_adb/test/services/android_device_service_test.dart index 72ea67d..3a0da11 100644 --- a/packages/simutil_adb/test/services/android_device_service_test.dart +++ b/packages/simutil_adb/test/services/android_device_service_test.dart @@ -408,10 +408,24 @@ void main() { tearDown(() => process.close()); - AndroidDeviceService launcher() => AndroidDeviceService( + /// Writes an AVD [name] whose `config.ini` holds [config] lines. + void avd(String name, List config) { + final dir = Directory('${sdkDir.path}/avd/$name.avd') + ..createSync(recursive: true); + File('${dir.path}/config.ini').writeAsStringSync(config.join('\n')); + File( + '${sdkDir.path}/avd/$name.ini', + ).writeAsStringSync('path=${dir.path}\n'); + } + + AndroidDeviceService launcher({ + bool isMacOS = false, + }) => AndroidDeviceService( FakeCommandExec((_, _) => null), androidHomeOverride: sdkDir.path, + environment: {'ANDROID_AVD_HOME': '${sdkDir.path}/avd'}, isWindows: false, + isMacOS: isMacOS, launchGracePeriod: const Duration(milliseconds: 50), startProcess: (executable, arguments, {mode = ProcessStartMode.normal}) { starts.add((executable: executable, args: arguments, mode: mode)); @@ -493,10 +507,60 @@ void main() { expect(starts.single.args, [ '@Pixel_7', ...AndroidDeviceService.headlessArgs, - ...AndroidDeviceService.headlessGpuArgs, + ...AndroidDeviceService.hostGpuArgs, + ]); + }); + + test('windowed launches use the host gpu on macOS', () async { + await launcher(isMacOS: true).launchDevice(deviceId: 'Pixel_7'); + + expect(starts.single.args, [ + '@Pixel_7', + ...AndroidDeviceService.hostGpuArgs, + ]); + }); + + test('windowed launches keep -gpu auto off macOS', () async { + await launcher().launchDevice(deviceId: 'Pixel_7'); + + expect(starts.single.args, ['@Pixel_7']); + }); + + test('an explicit hw.gpu.mode = auto still gets the host gpu', () async { + avd('Pixel_7', ['hw.gpu.enabled=yes', 'hw.gpu.mode = auto']); + + await launcher(isMacOS: true).launchDevice(deviceId: 'Pixel_7'); + + expect(starts.single.args, [ + '@Pixel_7', + ...AndroidDeviceService.hostGpuArgs, + ]); + }); + + test('keeps the gpu mode the AVD sets', () async { + avd('Pixel_7', [ + 'hw.gpu.enabled=yes', + 'hw.gpu.mode=swiftshader_indirect', + ]); + + await launcher( + isMacOS: true, + ).launchDevice(deviceId: 'Pixel_7', headless: true); + + expect(starts.single.args, [ + '@Pixel_7', + ...AndroidDeviceService.headlessArgs, ]); }); + test('adds no gpu mode when the AVD disables the gpu', () async { + avd('Pixel_7', ['hw.gpu.enabled=no']); + + await launcher(isMacOS: true).launchDevice(deviceId: 'Pixel_7'); + + expect(starts.single.args, ['@Pixel_7']); + }); + test('headless keeps the gpu mode the caller passes', () async { await launcher().launchDevice( deviceId: 'Pixel_7', @@ -522,7 +586,7 @@ void main() { expect(starts.single.args, [ '@Pixel_7', ...AndroidDeviceService.headlessArgs, - ...AndroidDeviceService.headlessGpuArgs, + ...AndroidDeviceService.hostGpuArgs, '-no-snapshot-load', ]); });