Skip to content
Open
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
11 changes: 11 additions & 0 deletions app/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ 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.
- 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

### Added
Expand Down
4 changes: 2 additions & 2 deletions app/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ 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_core: ^1.4.0
simutil_h264: ^0.2.0
simutil_shared: ^1.1.0
window_manager: ^0.5.0
Expand Down
3 changes: 3 additions & 0 deletions packages/simutil/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ 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.
- 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
Expand Down
20 changes: 13 additions & 7 deletions packages/simutil/lib/src/cli/commands/launch_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -81,13 +82,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 DeviceLaunchException catch (e) {
logger.err('Failed to launch ${device.name} (${device.id}): $e');
return 1;
}

logger.success('Launched ${device.name} (${device.id})');
return 0;
Expand Down
4 changes: 2 additions & 2 deletions packages/simutil/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ 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_core: ^1.4.0
simutil_plugins: ^1.0.0
simutil_shared: ^1.1.0

Expand Down
23 changes: 23 additions & 0 deletions packages/simutil_adb/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
# 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), and when the emulator binary cannot
be started; failures used to be ignored. `EmulatorLaunchException`
extends `DeviceLaunchException` from `simutil_core` (now `^1.4.0`).
- `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

- `EmulatorGrpcSession` streams and controls an Android emulator over its
Expand Down
1 change: 1 addition & 0 deletions packages/simutil_adb/lib/simutil_adb.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
148 changes: 135 additions & 13 deletions packages/simutil_adb/lib/src/android_device_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,42 @@ 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<Process> Function(
String executable,
List<String> 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], [isMacOS], [startProcess]
/// and [launchGracePeriod] are test seams.
AndroidDeviceService(
this._exec, {
String? androidHomeOverride,
Map<String, String>? 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;
_isWindows = isWindows ?? Platform.isWindows,
_isMacOS = isMacOS ?? Platform.isMacOS,
_startProcess = startProcess ?? Process.start,
_launchGracePeriod = launchGracePeriod;

static const Duration _deviceListTimeout = Duration(seconds: 15);
static const Duration _wirelessTimeout = Duration(seconds: 15);
Expand All @@ -34,6 +51,9 @@ class AndroidDeviceService implements DeviceService {
final Map<String, String>? _environment;
final bool Function(String path)? _fileExists;
final bool _isWindows;
final bool _isMacOS;
final EmulatorProcessStarter _startProcess;
final Duration _launchGracePeriod;

/// SDK binaries are `adb.exe` / `emulator.exe` on Windows.
String get _exe => _isWindows ? '.exe' : '';
Expand Down Expand Up @@ -187,35 +207,137 @@ 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), 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<void> launchDevice({
required String deviceId,
List<String> 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();
await _exec.run(
emulatorPath,
arguments: launchArgs,
priority: CommandPriority.interactive,
);
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
// grace period means the emulator exited.
final output = StringBuffer();
final closed = [Completer<void>(), Completer<void>()];
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'];

/// 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<bool> 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<AdbConnectResult> connectDevice(String host) async {
Expand Down
11 changes: 11 additions & 0 deletions packages/simutil_adb/lib/src/models/emulator_launch_exception.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import 'package:simutil_core/simutil_core.dart';

/// 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);
}
4 changes: 2 additions & 2 deletions packages/simutil_adb/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
Loading