Unverified Commit cdb01187 authored by Danny Tuppeny's avatar Danny Tuppeny Committed by GitHub

Add --create option to `flutter emulators` command (#18235)

* Add --create option to flutter emulators

* Tweaks to error message

* Simplify emulator search logic

* Make name optional

* Add a note about this option being used with --create

* Tweaks to help information

* Switch to processManager for easier testing

* Don't crash on missing files or missing properties in Android Emulator

* Move name suffixing into emulator manager

This allows it to be tested in the EmulatorManager tests and also used by daemon later if desired.

* Pass the context's android SDK through so it can be mocked by tests

* Misc fixes

* Add tests around emulator creation

Process calls are mocked to avoid needing a real SDK (and to be fast). Full integration tests may be useful, but may require ensuring all build environments etc. are set up correctly.

* Simplify avdManagerPath

Previous changes were to emulatorPath!

* Fix lint errors

* Fix incorrect file exgtension for Windows

* Fix an issue where no system images would crash

reduce throws on an empty collection.

* Fix "null" appearing in error messages

The name we attempted to use will now always be returned, even in the case of failure.

* Add additional info to missing-system-image failure message

On Windows after installing Andriod Studio I didn't have any of these and got this message. Installing with sdkmanager fixed the issue.

* Fix thrown errors

runResult had a toString() but we moved to ProcessResult when switching to ProcessManager to this ended up throwing "Instance of ProcessResult".

* Fix package import

* Fix more package imports

* Move mock implementation into Mock class

There seemed to be issues using Lists in args with Mockito that I couldn't figure out (docs say to use typed() but I couldn't make this compile with these lists still)..

* Rename method that's ambigious now we have create

* Handle where there's no avd path

* Add another toList() :(

* Remove comment that was rewritten

* Fix forbidden import

* Make optional arg more obviously optional

* Reformat doc

* Note that we create a pixel device in help text

* Make this a named arg
parent c9c1068c
......@@ -9,7 +9,8 @@ import 'package:meta/meta.dart';
import '../android/android_sdk.dart';
import '../android/android_workflow.dart';
import '../base/file_system.dart';
import '../base/process.dart';
import '../base/io.dart';
import '../base/process_manager.dart';
import '../emulator.dart';
import 'android_sdk.dart';
......@@ -31,21 +32,23 @@ class AndroidEmulator extends Emulator {
Map<String, String> _properties;
@override
String get name => _properties['hw.device.name'];
String get name => _prop('hw.device.name');
@override
String get manufacturer => _properties['hw.device.manufacturer'];
String get manufacturer => _prop('hw.device.manufacturer');
@override
String get label => _properties['avd.ini.displayname'];
String _prop(String name) => _properties != null ? _properties[name] : null;
@override
Future<void> launch() async {
final Future<void> launchResult =
runAsync(<String>[getEmulatorPath(), '-avd', id])
.then((RunResult runResult) {
processManager.run(<String>[getEmulatorPath(), '-avd', id])
.then((ProcessResult runResult) {
if (runResult.exitCode != 0) {
throw '$runResult';
throw '${runResult.stdout}\n${runResult.stderr}'.trimRight();
}
});
// emulator continues running on a successful launch so if we
......@@ -65,10 +68,12 @@ List<AndroidEmulator> getEmulatorAvds() {
return <AndroidEmulator>[];
}
final String listAvdsOutput = runSync(<String>[emulatorPath, '-list-avds']);
final String listAvdsOutput = processManager.runSync(<String>[emulatorPath, '-list-avds']).stdout;
final List<AndroidEmulator> emulators = <AndroidEmulator>[];
extractEmulatorAvdInfo(listAvdsOutput, emulators);
if (listAvdsOutput != null) {
extractEmulatorAvdInfo(listAvdsOutput, emulators);
}
return emulators;
}
......@@ -76,21 +81,26 @@ List<AndroidEmulator> getEmulatorAvds() {
/// of emulators by reading information from the relevant ini files.
void extractEmulatorAvdInfo(String text, List<AndroidEmulator> emulators) {
for (String id in text.trim().split('\n').where((String l) => l != '')) {
emulators.add(_createEmulator(id));
emulators.add(_loadEmulatorInfo(id));
}
}
AndroidEmulator _createEmulator(String id) {
AndroidEmulator _loadEmulatorInfo(String id) {
id = id.trim();
final File iniFile = fs.file(fs.path.join(getAvdPath(), '$id.ini'));
final Map<String, String> ini = parseIniLines(iniFile.readAsLinesSync());
if (ini['path'] != null) {
final File configFile = fs.file(fs.path.join(ini['path'], 'config.ini'));
if (configFile.existsSync()) {
final Map<String, String> properties =
parseIniLines(configFile.readAsLinesSync());
return new AndroidEmulator(id, properties);
final String avdPath = getAvdPath();
if (avdPath != null) {
final File iniFile = fs.file(fs.path.join(avdPath, '$id.ini'));
if (iniFile.existsSync()) {
final Map<String, String> ini = parseIniLines(iniFile.readAsLinesSync());
if (ini['path'] != null) {
final File configFile =
fs.file(fs.path.join(ini['path'], 'config.ini'));
if (configFile.existsSync()) {
final Map<String, String> properties =
parseIniLines(configFile.readAsLinesSync());
return new AndroidEmulator(id, properties);
}
}
}
}
......
......@@ -64,16 +64,8 @@ String getAdbPath([AndroidSdk existingSdk]) {
/// will work for those users who have Android Tools installed but
/// not the full SDK.
String getEmulatorPath([AndroidSdk existingSdk]) {
if (existingSdk?.emulatorPath != null)
return existingSdk.emulatorPath;
final AndroidSdk sdk = AndroidSdk.locateAndroidSdk();
if (sdk?.latestVersion == null) {
return os.which('emulator')?.path;
} else {
return sdk.emulatorPath;
}
return existingSdk?.emulatorPath ??
AndroidSdk.locateAndroidSdk()?.emulatorPath;
}
/// Locate the path for storing AVD emulator images. Returns null if none found.
......@@ -104,6 +96,15 @@ String getAvdPath() {
);
}
/// Locate 'avdmanager'. Prefer to use one from an Android SDK, if we can locate that.
/// This should be used over accessing androidSdk.avdManagerPath directly because it
/// will work for those users who have Android Tools installed but
/// not the full SDK.
String getAvdManagerPath([AndroidSdk existingSdk]) {
return existingSdk?.avdManagerPath ??
AndroidSdk.locateAndroidSdk()?.avdManagerPath;
}
class AndroidNdkSearchError {
AndroidNdkSearchError(this.reason);
......@@ -314,6 +315,8 @@ class AndroidSdk {
String get emulatorPath => getEmulatorPath();
String get avdManagerPath => getAvdManagerPath();
/// Validate the Android SDK. This returns an empty list if there are no
/// issues; otherwise, it returns a list of issues found.
List<String> validateSdkWellFormed() {
......@@ -343,6 +346,14 @@ class AndroidSdk {
return null;
}
String getAvdManagerPath() {
final String binaryName = platform.isWindows ? 'avdmanager.bat' : 'avdmanager';
final String path = fs.path.join(directory, 'tools', 'bin', binaryName);
if (fs.file(path).existsSync())
return path;
return null;
}
void _init() {
Iterable<Directory> platforms = <Directory>[]; // android-22, ...
......
......@@ -16,13 +16,18 @@ class EmulatorsCommand extends FlutterCommand {
EmulatorsCommand() {
argParser.addOption('launch',
help: 'The full or partial ID of the emulator to launch.');
argParser.addFlag('create',
help: 'Creates a new Android emulator based on a Pixel device.',
negatable: false);
argParser.addOption('name',
help: 'Used with flag --create. Specifies a name for the emulator being created.');
}
@override
final String name = 'emulators';
@override
final String description = 'List and launch available emulators.';
final String description = 'List, launch and create emulators.';
@override
final List<String> aliases = <String>['emulator'];
......@@ -40,6 +45,8 @@ class EmulatorsCommand extends FlutterCommand {
if (argResults.wasParsed('launch')) {
await _launchEmulator(argResults['launch']);
} else if (argResults.wasParsed('create')) {
await _createEmulator(name: argResults['name']);
} else {
final String searchText =
argResults.rest != null && argResults.rest.isNotEmpty
......@@ -70,17 +77,27 @@ class EmulatorsCommand extends FlutterCommand {
}
}
Future<Null> _createEmulator({String name}) async {
final CreateEmulatorResult createResult =
await emulatorManager.createEmulator(name: name);
if (createResult.success) {
printStatus("Emulator '${createResult.emulatorName}' created successfully.");
} else {
printStatus("Failed to create emulator '${createResult.emulatorName}'.\n");
printStatus(createResult.error.trim());
_printAdditionalInfo();
}
}
Future<void> _listEmulators(String searchText) async {
final List<Emulator> emulators = searchText == null
? await emulatorManager.getAllAvailableEmulators()
: await emulatorManager.getEmulatorsMatching(searchText);
if (emulators.isEmpty) {
printStatus('No emulators available.\n\n'
// TODO(dantup): Change these when we support creation
// 'You may need to create images using "flutter emulators --create"\n'
'You may need to create one using Android Studio '
'or visit https://flutter.io/setup/ for troubleshooting tips.');
printStatus('No emulators available.');
_printAdditionalInfo(showCreateInstruction: true);
} else {
_printEmulatorList(
emulators,
......@@ -92,7 +109,28 @@ class EmulatorsCommand extends FlutterCommand {
void _printEmulatorList(List<Emulator> emulators, String message) {
printStatus('$message\n');
Emulator.printEmulators(emulators);
printStatus(
"\nTo run an emulator, run 'flutter emulators --launch <emulator id>'.");
_printAdditionalInfo(showCreateInstruction: true, showRunInstruction: true);
}
void _printAdditionalInfo({ bool showRunInstruction = false,
bool showCreateInstruction = false }) {
printStatus('');
if (showRunInstruction) {
printStatus(
"To run an emulator, run 'flutter emulators --launch <emulator id>'.");
}
if (showCreateInstruction) {
printStatus(
"To create a new emulator, run 'flutter emulators --create [--name xyz]'.");
}
if (showRunInstruction || showCreateInstruction) {
printStatus('');
}
// TODO(dantup): Update this link to flutter.io if/when we have a better page.
// That page can then link out to these places if required.
printStatus('You can find more information on managing emulators at the links below:\n'
' https://developer.android.com/studio/run/managing-avds\n'
' https://developer.android.com/studio/command-line/avdmanager');
}
}
......@@ -6,7 +6,10 @@ import 'dart:async';
import 'dart:math' as math;
import 'android/android_emulator.dart';
import 'android/android_sdk.dart';
import 'base/context.dart';
import 'base/io.dart' show ProcessResult;
import 'base/process_manager.dart';
import 'globals.dart';
import 'ios/ios_emulators.dart';
......@@ -34,8 +37,8 @@ class EmulatorManager {
emulator.id?.toLowerCase()?.startsWith(searchText) == true ||
emulator.name?.toLowerCase()?.startsWith(searchText) == true;
final Emulator exactMatch = emulators.firstWhere(
exactlyMatchesEmulatorId, orElse: () => null);
final Emulator exactMatch =
emulators.firstWhere(exactlyMatchesEmulatorId, orElse: () => null);
if (exactMatch != null) {
return <Emulator>[exactMatch];
}
......@@ -57,6 +60,133 @@ class EmulatorManager {
return emulators;
}
/// Return the list of all available emulators.
Future<CreateEmulatorResult> createEmulator({String name}) async {
if (name == null || name == '') {
const String autoName = 'flutter_emulator';
// Don't use getEmulatorsMatching here, as it will only return one
// if there's an exact match and we need all those with this prefix
// so we can keep adding suffixes until we miss.
final List<Emulator> all = await getAllAvailableEmulators();
final Set<String> takenNames = all
.map((Emulator e) => e.id)
.where((String id) => id.startsWith(autoName))
.toSet();
int suffix = 1;
name = autoName;
while (takenNames.contains(name)) {
name = '${autoName}_${++suffix}';
}
}
final String device = await _getPreferredAvailableDevice();
if (device == null)
return new CreateEmulatorResult(name,
success: false, error: 'No device definitions are available');
final String sdkId = await _getPreferredSdkId();
if (sdkId == null)
return new CreateEmulatorResult(name,
success: false,
error:
'No suitable Android AVD system images are available. You may need to install these'
' using sdkmanager, for example:\n'
' sdkmanager "system-images;android-27;google_apis_playstore;x86"');
// Cleans up error output from avdmanager to make it more suitable to show
// to flutter users. Specifically:
// - Removes lines that say "null" (!)
// - Removes lines that tell the user to use '--force' to overwrite emulators
String cleanError(String error) {
return (error ?? '')
.split('\n')
.where((String l) => l.trim() != 'null')
.where((String l) =>
l.trim() != 'Use --force if you want to replace it.')
.join('\n');
}
final List<String> args = <String>[
getAvdManagerPath(androidSdk),
'create',
'avd',
'-n', name,
'-k', sdkId,
'-d', device
];
final ProcessResult runResult = processManager.runSync(args);
return new CreateEmulatorResult(
name,
success: runResult.exitCode == 0,
output: runResult.stdout,
error: cleanError(runResult.stderr),
);
}
static const List<String> preferredDevices = const <String>[
'pixel',
'pixel_xl',
];
Future<String> _getPreferredAvailableDevice() async {
final List<String> args = <String>[
getAvdManagerPath(androidSdk),
'list',
'device',
'-c'
];
final ProcessResult runResult = processManager.runSync(args);
if (runResult.exitCode != 0)
return null;
final List<String> availableDevices = runResult.stdout
.split('\n')
.where((String l) => preferredDevices.contains(l.trim()))
.toList();
return preferredDevices.firstWhere(
(String d) => availableDevices.contains(d),
orElse: () => null,
);
}
RegExp androidApiVersion = new RegExp(r';android-(\d+);');
Future<String> _getPreferredSdkId() async {
// It seems that to get the available list of images, we need to send a
// request to create without the image and it'll provide us a list :-(
final List<String> args = <String>[
getAvdManagerPath(androidSdk),
'create',
'avd',
'-n', 'temp',
];
final ProcessResult runResult = processManager.runSync(args);
// Get the list of IDs that match our criteria
final List<String> availableIDs = runResult.stderr
.split('\n')
.where((String l) => androidApiVersion.hasMatch(l))
.where((String l) => l.contains('system-images'))
.where((String l) => l.contains('google_apis_playstore'))
.toList();
final List<int> availableApiVersions = availableIDs
.map((String id) => androidApiVersion.firstMatch(id).group(1))
.map((String apiVersion) => int.parse(apiVersion))
.toList();
// Get the highest Android API version or whats left
final int apiVersion = availableApiVersions.isNotEmpty
? availableApiVersions.reduce(math.max)
: -1; // Don't match below
// We're out of preferences, we just have to return the first one with the high
// API version.
return availableIDs.firstWhere(
(String id) => id.contains(';android-$apiVersion;'),
orElse: () => null,
);
}
/// Whether we're capable of listing any emulators given the current environment configuration.
bool get canListAnything {
return _platformDiscoverers.any((EmulatorDiscovery discoverer) => discoverer.canListAnything);
......@@ -124,11 +254,13 @@ abstract class Emulator {
// Join columns into lines of text
final RegExp whiteSpaceAndDots = new RegExp(r'[•\s]+$');
return table.map((List<String> row) {
return indices
.map((int i) => row[i].padRight(widths[i]))
.join(' • ') + ' • ${row.last}';
})
return table
.map((List<String> row) {
return indices
.map((int i) => row[i].padRight(widths[i]))
.join(' • ') +
' • ${row.last}';
})
.map((String line) => line.replaceAll(whiteSpaceAndDots, ''))
.toList();
}
......@@ -137,3 +269,12 @@ abstract class Emulator {
descriptions(emulators).forEach(printStatus);
}
}
class CreateEmulatorResult {
final bool success;
final String emulatorName;
final String output;
final String error;
CreateEmulatorResult(this.emulatorName, {this.success, this.output, this.error});
}
......@@ -71,6 +71,8 @@ List<IOSEmulator> getEmulators() {
}
String getSimulatorPath() {
if (xcode.xcodeSelectPath == null)
return null;
final List<String> searchPaths = <String>[
fs.path.join(xcode.xcodeSelectPath, 'Applications', 'Simulator.app'),
];
......
......@@ -3,31 +3,61 @@
// found in the LICENSE file.
import 'dart:async';
import 'dart:convert';
import 'package:collection/collection.dart' show ListEquality;
import 'package:flutter_tools/src/android/android_sdk.dart';
import 'package:flutter_tools/src/base/config.dart';
import 'package:flutter_tools/src/base/io.dart';
import 'package:flutter_tools/src/emulator.dart';
import 'package:mockito/mockito.dart';
import 'package:process/process.dart';
import 'package:test/test.dart';
import 'src/context.dart';
import 'src/mocks.dart';
void main() {
MockProcessManager mockProcessManager;
MockConfig mockConfig;
MockAndroidSdk mockSdk;
setUp(() {
mockProcessManager = new MockProcessManager();
mockConfig = new MockConfig();
mockSdk = new MockAndroidSdk();
when(mockSdk.avdManagerPath).thenReturn('avdmanager');
when(mockSdk.emulatorPath).thenReturn('emulator');
});
group('EmulatorManager', () {
testUsingContext('getEmulators', () async {
// Test that EmulatorManager.getEmulators() doesn't throw.
final EmulatorManager emulatorManager = new EmulatorManager();
final List<Emulator> emulators = await emulatorManager.getAllAvailableEmulators();
final List<Emulator> emulators =
await emulatorManager.getAllAvailableEmulators();
expect(emulators, isList);
});
testUsingContext('getEmulatorsById', () async {
final _MockEmulator emulator1 = new _MockEmulator('Nexus_5', 'Nexus 5', 'Google', '');
final _MockEmulator emulator2 = new _MockEmulator('Nexus_5X_API_27_x86', 'Nexus 5X', 'Google', '');
final _MockEmulator emulator3 = new _MockEmulator('iOS Simulator', 'iOS Simulator', 'Apple', '');
final List<Emulator> emulators = <Emulator>[emulator1, emulator2, emulator3];
final EmulatorManager emulatorManager = new TestEmulatorManager(emulators);
final _MockEmulator emulator1 =
new _MockEmulator('Nexus_5', 'Nexus 5', 'Google', '');
final _MockEmulator emulator2 =
new _MockEmulator('Nexus_5X_API_27_x86', 'Nexus 5X', 'Google', '');
final _MockEmulator emulator3 =
new _MockEmulator('iOS Simulator', 'iOS Simulator', 'Apple', '');
final List<Emulator> emulators = <Emulator>[
emulator1,
emulator2,
emulator3
];
final TestEmulatorManager testEmulatorManager =
new TestEmulatorManager(emulators);
Future<Null> expectEmulator(String id, List<Emulator> expected) async {
expect(await emulatorManager.getEmulatorsMatching(id), expected);
expect(await testEmulatorManager.getEmulatorsMatching(id), expected);
}
expectEmulator('Nexus_5', <Emulator>[emulator1]);
expectEmulator('Nexus_5X', <Emulator>[emulator2]);
expectEmulator('Nexus_5X_API_27_x86', <Emulator>[emulator2]);
......@@ -35,6 +65,61 @@ void main() {
expectEmulator('iOS Simulator', <Emulator>[emulator3]);
expectEmulator('ios', <Emulator>[emulator3]);
});
testUsingContext('create emulator with an empty name does not fail',
() async {
final CreateEmulatorResult res = await emulatorManager.createEmulator();
expect(res.success, equals(true));
}, overrides: <Type, Generator>{
ProcessManager: () => mockProcessManager,
Config: () => mockConfig,
AndroidSdk: () => mockSdk,
});
testUsingContext('create emulator with a unique name does not throw',
() async {
final CreateEmulatorResult res =
await emulatorManager.createEmulator(name: 'test');
expect(res.success, equals(true));
}, overrides: <Type, Generator>{
ProcessManager: () => mockProcessManager,
Config: () => mockConfig,
AndroidSdk: () => mockSdk,
});
testUsingContext('create emulator with an existing name errors', () async {
final CreateEmulatorResult res =
await emulatorManager.createEmulator(name: 'existing-avd-1');
expect(res.success, equals(false));
}, overrides: <Type, Generator>{
ProcessManager: () => mockProcessManager,
Config: () => mockConfig,
AndroidSdk: () => mockSdk,
});
testUsingContext(
'create emulator without a name but when default exists adds a suffix',
() async {
// First will get default name.
CreateEmulatorResult res = await emulatorManager.createEmulator();
expect(res.success, equals(true));
final String defaultName = res.emulatorName;
// Second...
res = await emulatorManager.createEmulator();
expect(res.success, equals(true));
expect(res.emulatorName, equals('${defaultName}_2'));
// Third...
res = await emulatorManager.createEmulator();
expect(res.success, equals(true));
expect(res.emulatorName, equals('${defaultName}_3'));
}, overrides: <Type, Generator>{
ProcessManager: () => mockProcessManager,
Config: () => mockConfig,
AndroidSdk: () => mockSdk,
});
});
}
......@@ -50,14 +135,15 @@ class TestEmulatorManager extends EmulatorManager {
}
class _MockEmulator extends Emulator {
_MockEmulator(String id, this.name, this.manufacturer, this.label) : super(id, true);
_MockEmulator(String id, this.name, this.manufacturer, this.label)
: super(id, true);
@override
final String name;
@override
final String manufacturer;
@override
final String label;
......@@ -66,3 +152,81 @@ class _MockEmulator extends Emulator {
throw new UnimplementedError('Not implemented in Mock');
}
}
class MockConfig extends Mock implements Config {}
class MockProcessManager extends Mock implements ProcessManager {
/// We have to send a command that fails in order to get the list of valid
/// system images paths. This is an example of the output to use in the mock.
static const String mockCreateFailureOutput =
'Error: Package path (-k) not specified. Valid system image paths are:\n'
'system-images;android-27;google_apis;x86\n'
'system-images;android-P;google_apis;x86\n'
'system-images;android-27;google_apis_playstore;x86\n'
'null\n'; // Yep, these really end with null (on dantup's machine at least)
static const ListEquality<String> _equality = const ListEquality<String>();
final List<String> _existingAvds = <String>['existing-avd-1'];
@override
ProcessResult runSync(
List<dynamic> command, {
String workingDirectory,
Map<String, String> environment,
bool includeParentEnvironment = true,
bool runInShell = false,
Encoding stdoutEncoding,
Encoding stderrEncoding
}) {
final String program = command[0];
final List<String> args = command.sublist(1);
switch (command[0]) {
case '/usr/bin/xcode-select':
throw new ProcessException(program, args);
break;
case 'emulator':
return _handleEmulator(args);
case 'avdmanager':
return _handleAvdManager(args);
}
throw new StateError('Unexpected process call: $command');
}
ProcessResult _handleEmulator(List<String> args) {
if (_equality.equals(args, <String>['-list-avds'])) {
return new ProcessResult(101, 0, '${_existingAvds.join('\n')}\n', '');
}
throw new ProcessException('emulator', args);
}
ProcessResult _handleAvdManager(List<String> args) {
if (_equality.equals(args, <String>['list', 'device', '-c'])) {
return new ProcessResult(101, 0, 'test\ntest2\npixel\npixel-xl\n', '');
}
if (_equality.equals(args, <String>['create', 'avd', '-n', 'temp'])) {
return new ProcessResult(101, 1, '', mockCreateFailureOutput);
}
if (args.length == 8 &&
_equality.equals(args,
<String>['create', 'avd', '-n', args[3], '-k', args[5], '-d', args[7]])) {
// In order to support testing auto generation of names we need to support
// tracking any created emulators and reject when they already exist so this
// mock will compare the name of the AVD being created with the fake existing
// list and either reject if it exists, or add it to the list and return success.
final String name = args[3];
// Error if this AVD already existed
if (_existingAvds.contains(name)) {
return new ProcessResult(
101,
1,
'',
"Error: Android Virtual Device '$name' already exists.\n"
'Use --force if you want to replace it.');
} else {
_existingAvds.add(name);
return new ProcessResult(101, 0, '', '');
}
}
throw new ProcessException('emulator', args);
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment