From f3be1d9d95d667a1dab71028f76c8224e04441af Mon Sep 17 00:00:00 2001 From: Danny Tuppeny Date: Wed, 26 Jun 2019 16:39:23 +0100 Subject: [PATCH] Add `emulatorID` field to devices in daemon (#34794) * Add emulatorId to Android and iOS emulator devices * Update docs * Review tweaks * Add tests for AndroidConsole for getting avd names * Remove unused import * Remove duplicated header * Fix imports --- packages/flutter_tools/doc/daemon.md | 5 +- .../lib/src/android/android_console.dart | 76 +++++++++++ .../lib/src/android/android_device.dart | 50 ++++++++ .../lib/src/commands/daemon.dart | 3 +- packages/flutter_tools/lib/src/device.dart | 8 ++ .../lib/src/fuchsia/fuchsia_device.dart | 3 + .../flutter_tools/lib/src/ios/devices.dart | 3 + .../lib/src/ios/ios_emulators.dart | 3 +- .../flutter_tools/lib/src/ios/simulators.dart | 4 + .../lib/src/linux/linux_device.dart | 3 + .../lib/src/macos/macos_device.dart | 3 + .../lib/src/tester/flutter_tester.dart | 3 + .../flutter_tools/lib/src/web/web_device.dart | 3 + .../lib/src/windows/windows_device.dart | 3 + .../test/android/android_device_test.dart | 120 ++++++++++++++++++ 15 files changed, 287 insertions(+), 3 deletions(-) create mode 100644 packages/flutter_tools/lib/src/android/android_console.dart diff --git a/packages/flutter_tools/doc/daemon.md b/packages/flutter_tools/doc/daemon.md index 4658a6eea0..b2627512a6 100644 --- a/packages/flutter_tools/doc/daemon.md +++ b/packages/flutter_tools/doc/daemon.md @@ -157,7 +157,7 @@ This is sent when an app is stopped or detached from. The `params` field will be #### device.getDevices -Return a list of all connected devices. The `params` field will be a List; each item is a map with the fields `id`, `name`, `platform`, `category`, `platformType`, `ephemeral`, and `emulator` (a boolean). +Return a list of all connected devices. The `params` field will be a List; each item is a map with the fields `id`, `name`, `platform`, `category`, `platformType`, `ephemeral`, `emulator` (a boolean) and `emulatorId`. `category` is string description of the kind of workflow the device supports. The current categories are "mobile", "web" and "desktop", or null if none. @@ -167,6 +167,8 @@ supports. The current catgetories are "android", "ios", "linux", "macos", `ephemeral` is a boolean which indicates where the device needs to be manually connected to a development machine. For example, a physical Android device is ephemeral, but the "web" device (that is always present) is not. +`emulatorId` is an string ID that matches the ID from `getEmulators` to allow clients to match running devices to the emulators that started them (for example to hide emulators that are already running). This field is not guaranteed to be populated even if a device was spawned from an emulator as it may require a successful connection to the device to retrieve it. In the case of a failed connection or the device is not an emulator, this field will be null. + #### device.enable Turn on device polling. This will poll for newly connected devices, and fire `device.added` and `device.removed` events. @@ -258,6 +260,7 @@ See the [source](https://github.com/flutter/flutter/blob/master/packages/flutter ## Changelog +- 0.5.3: Added `emulatorId` field to device. - 0.5.2: Added `platformType` and `category` fields to emulator. - 0.5.1: Added `platformType`, `ephemeral`, and `category` fields to device. - 0.5.0: Added `daemon.getSupportedPlatforms` command diff --git a/packages/flutter_tools/lib/src/android/android_console.dart b/packages/flutter_tools/lib/src/android/android_console.dart new file mode 100644 index 0000000000..a292b57e8a --- /dev/null +++ b/packages/flutter_tools/lib/src/android/android_console.dart @@ -0,0 +1,76 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:async/async.dart'; +import '../base/context.dart'; +import '../base/io.dart'; +import '../convert.dart'; + +/// Default factory that creates a real Android console connection. +final AndroidConsoleSocketFactory _kAndroidConsoleSocketFactory = (String host, int port) => Socket.connect( host, port); + +/// Currently active implementation of the AndroidConsoleFactory. +/// +/// The default implementation will create real connections to a device. +/// Override this in tests with an implementation that returns mock responses. +AndroidConsoleSocketFactory get androidConsoleSocketFactory => context.get() ?? _kAndroidConsoleSocketFactory; + +typedef AndroidConsoleSocketFactory = Future Function(String host, int port); + +/// Creates a console connection to an Android emulator that can be used to run +/// commands such as "avd name" which are not available to ADB. +/// +/// See documentation at +/// https://developer.android.com/studio/run/emulator-console +class AndroidConsole { + AndroidConsole(this._socket); + + Socket _socket; + StreamQueue _queue; + + Future connect() async { + assert(_socket != null); + assert(_queue == null); + + _queue = StreamQueue(_socket.asyncMap(ascii.decode)); + + // Discard any initial connection text. + await _readResponse(); + } + + Future getAvdName() async { + _write('avd name\n'); + return _readResponse(); + } + + void destroy() { + if (_socket != null) { + _socket.destroy(); + _socket = null; + _queue = null; + } + } + + Future _readResponse() async { + final StringBuffer output = StringBuffer(); + while (true) { + final String text = await _queue.next; + final String trimmedText = text.trim(); + if (trimmedText == 'OK') + break; + if (trimmedText.endsWith('\nOK')) { + output.write(trimmedText.substring(0, trimmedText.length - 3)); + break; + } + output.write(text); + } + return output.toString().trim(); + } + + void _write(String text) { + _socket.add(ascii.encode(text)); + } +} diff --git a/packages/flutter_tools/lib/src/android/android_device.dart b/packages/flutter_tools/lib/src/android/android_device.dart index 2bba099b3e..4a5397b3e0 100644 --- a/packages/flutter_tools/lib/src/android/android_device.dart +++ b/packages/flutter_tools/lib/src/android/android_device.dart @@ -26,6 +26,7 @@ import '../protocol_discovery.dart'; import 'adb.dart'; import 'android.dart'; +import 'android_console.dart'; import 'android_sdk.dart'; enum _HardwareType { emulator, physical } @@ -134,6 +135,55 @@ class AndroidDevice extends Device { return _isLocalEmulator; } + /// The unique identifier for the emulator that corresponds to this device, or + /// null if it is not an emulator. + /// + /// The ID returned matches that in the output of `flutter emulators`. Fetching + /// this name may require connecting to the device and if an error occurs null + /// will be returned. + @override + Future get emulatorId async { + if (!(await isLocalEmulator)) + return null; + + // Emulators always have IDs in the format emulator-(port) where port is the + // Android Console port number. + final RegExp emulatorPortRegex = RegExp(r'emulator-(\d+)'); + + final Match portMatch = emulatorPortRegex.firstMatch(id); + if (portMatch == null || portMatch.groupCount < 1) { + return null; + } + + const String host = 'localhost'; + final int port = int.parse(portMatch.group(1)); + printTrace('Fetching avd name for $name via Android console on $host:$port'); + + try { + final Socket socket = await androidConsoleSocketFactory(host, port); + final AndroidConsole console = AndroidConsole(socket); + + try { + await console + .connect() + .timeout(timeoutConfiguration.fastOperation, + onTimeout: () => throw TimeoutException('Connection timed out')); + + return await console + .getAvdName() + .timeout(timeoutConfiguration.fastOperation, + onTimeout: () => throw TimeoutException('"avd name" timed out')); + } finally { + console.destroy(); + } + } catch (e) { + printTrace('Failed to fetch avd name for emulator at $host:$port: $e'); + // If we fail to connect to the device, we should not fail so just return + // an empty name. This data is best-effort. + return null; + } + } + @override Future get targetPlatform async { if (_platform == null) { diff --git a/packages/flutter_tools/lib/src/commands/daemon.dart b/packages/flutter_tools/lib/src/commands/daemon.dart index b55acea292..079582a4f1 100644 --- a/packages/flutter_tools/lib/src/commands/daemon.dart +++ b/packages/flutter_tools/lib/src/commands/daemon.dart @@ -26,7 +26,7 @@ import '../run_hot.dart'; import '../runner/flutter_command.dart'; import '../vmservice.dart'; -const String protocolVersion = '0.5.2'; +const String protocolVersion = '0.5.3'; /// A server process command. This command will start up a long-lived server. /// It reads JSON-RPC based commands from stdin, executes them, and returns @@ -777,6 +777,7 @@ Future> _deviceToMap(Device device) async { 'category': device.category?.toString(), 'platformType': device.platformType?.toString(), 'ephemeral': device.ephemeral, + 'emulatorId': await device.emulatorId, }; } diff --git a/packages/flutter_tools/lib/src/device.dart b/packages/flutter_tools/lib/src/device.dart index 14dd869fc8..ee18fdc442 100644 --- a/packages/flutter_tools/lib/src/device.dart +++ b/packages/flutter_tools/lib/src/device.dart @@ -262,6 +262,14 @@ abstract class Device { /// Whether it is an emulated device running on localhost. Future get isLocalEmulator; + /// The unique identifier for the emulator that corresponds to this device, or + /// null if it is not an emulator. + /// + /// The ID returned matches that in the output of `flutter emulators`. Fetching + /// this name may require connecting to the device and if an error occurs null + /// will be returned. + Future get emulatorId; + /// Whether the device is a simulator on a platform which supports hardware rendering. Future get supportsHardwareRendering async { assert(await isLocalEmulator); diff --git a/packages/flutter_tools/lib/src/fuchsia/fuchsia_device.dart b/packages/flutter_tools/lib/src/fuchsia/fuchsia_device.dart index d6513f6429..75bc368ebc 100644 --- a/packages/flutter_tools/lib/src/fuchsia/fuchsia_device.dart +++ b/packages/flutter_tools/lib/src/fuchsia/fuchsia_device.dart @@ -197,6 +197,9 @@ class FuchsiaDevice extends Device { @override Future get isLocalEmulator async => false; + @override + Future get emulatorId async => null; + @override bool get supportsStartPaused => false; diff --git a/packages/flutter_tools/lib/src/ios/devices.dart b/packages/flutter_tools/lib/src/ios/devices.dart index 6c8d297925..3ac80c57f4 100644 --- a/packages/flutter_tools/lib/src/ios/devices.dart +++ b/packages/flutter_tools/lib/src/ios/devices.dart @@ -145,6 +145,9 @@ class IOSDevice extends Device { @override Future get isLocalEmulator async => false; + @override + Future get emulatorId async => null; + @override bool get supportsStartPaused => false; diff --git a/packages/flutter_tools/lib/src/ios/ios_emulators.dart b/packages/flutter_tools/lib/src/ios/ios_emulators.dart index 785e58f7b8..eec22496e5 100644 --- a/packages/flutter_tools/lib/src/ios/ios_emulators.dart +++ b/packages/flutter_tools/lib/src/ios/ios_emulators.dart @@ -11,6 +11,7 @@ import '../emulator.dart'; import '../globals.dart'; import '../macos/xcode.dart'; import 'ios_workflow.dart'; +import 'simulators.dart'; class IOSEmulators extends EmulatorDiscovery { @override @@ -71,5 +72,5 @@ List getEmulators() { return []; } - return [IOSEmulator('apple_ios_simulator')]; + return [IOSEmulator(iosSimulatorId)]; } diff --git a/packages/flutter_tools/lib/src/ios/simulators.dart b/packages/flutter_tools/lib/src/ios/simulators.dart index 06116b59a3..9ff8e7b57a 100644 --- a/packages/flutter_tools/lib/src/ios/simulators.dart +++ b/packages/flutter_tools/lib/src/ios/simulators.dart @@ -27,6 +27,7 @@ import 'mac.dart'; import 'plist_utils.dart'; const String _xcrunPath = '/usr/bin/xcrun'; +const String iosSimulatorId = 'apple_ios_simulator'; class IOSSimulators extends PollingDeviceDiscovery { IOSSimulators() : super('iOS simulators'); @@ -230,6 +231,9 @@ class IOSSimulator extends Device { @override Future get isLocalEmulator async => true; + @override + Future get emulatorId async => iosSimulatorId; + @override bool get supportsHotReload => true; diff --git a/packages/flutter_tools/lib/src/linux/linux_device.dart b/packages/flutter_tools/lib/src/linux/linux_device.dart index 6f4c49b324..3746aa5c46 100644 --- a/packages/flutter_tools/lib/src/linux/linux_device.dart +++ b/packages/flutter_tools/lib/src/linux/linux_device.dart @@ -53,6 +53,9 @@ class LinuxDevice extends Device { @override Future get isLocalEmulator async => false; + @override + Future get emulatorId async => null; + @override bool isSupported() => true; diff --git a/packages/flutter_tools/lib/src/macos/macos_device.dart b/packages/flutter_tools/lib/src/macos/macos_device.dart index a082bf07bd..27ca0bb981 100644 --- a/packages/flutter_tools/lib/src/macos/macos_device.dart +++ b/packages/flutter_tools/lib/src/macos/macos_device.dart @@ -54,6 +54,9 @@ class MacOSDevice extends Device { @override Future get isLocalEmulator async => false; + @override + Future get emulatorId async => null; + @override bool isSupported() => true; diff --git a/packages/flutter_tools/lib/src/tester/flutter_tester.dart b/packages/flutter_tools/lib/src/tester/flutter_tester.dart index a7abcf1455..6187a6d8a3 100644 --- a/packages/flutter_tools/lib/src/tester/flutter_tester.dart +++ b/packages/flutter_tools/lib/src/tester/flutter_tester.dart @@ -55,6 +55,9 @@ class FlutterTesterDevice extends Device { @override Future get isLocalEmulator async => false; + @override + Future get emulatorId async => null; + @override String get name => 'Flutter test device'; diff --git a/packages/flutter_tools/lib/src/web/web_device.dart b/packages/flutter_tools/lib/src/web/web_device.dart index ae8ca4e4bd..ecd8f4126a 100644 --- a/packages/flutter_tools/lib/src/web/web_device.dart +++ b/packages/flutter_tools/lib/src/web/web_device.dart @@ -70,6 +70,9 @@ class ChromeDevice extends Device { @override Future get isLocalEmulator async => false; + @override + Future get emulatorId async => null; + @override bool isSupported() => flutterWebEnabled && canFindChrome(); diff --git a/packages/flutter_tools/lib/src/windows/windows_device.dart b/packages/flutter_tools/lib/src/windows/windows_device.dart index 86d80ee227..d55df6d27d 100644 --- a/packages/flutter_tools/lib/src/windows/windows_device.dart +++ b/packages/flutter_tools/lib/src/windows/windows_device.dart @@ -55,6 +55,9 @@ class WindowsDevice extends Device { @override Future get isLocalEmulator async => false; + @override + Future get emulatorId async => null; + @override bool isSupported() => true; diff --git a/packages/flutter_tools/test/android/android_device_test.dart b/packages/flutter_tools/test/android/android_device_test.dart index c6058761b6..198b90850c 100644 --- a/packages/flutter_tools/test/android/android_device_test.dart +++ b/packages/flutter_tools/test/android/android_device_test.dart @@ -3,8 +3,10 @@ // found in the LICENSE file. import 'dart:async'; +import 'dart:convert'; import 'package:file/memory.dart'; +import 'package:flutter_tools/src/android/android_console.dart'; import 'package:flutter_tools/src/android/android_device.dart'; import 'package:flutter_tools/src/android/android_sdk.dart'; import 'package:flutter_tools/src/base/config.dart'; @@ -278,6 +280,83 @@ flutter: FileSystem: () => MemoryFileSystem(), }); + group('emulatorId', () { + final ProcessManager mockProcessManager = MockProcessManager(); + const String dummyEmulatorId = 'dummyEmulatorId'; + final Future Function(String host, int port) unresponsiveSocket = + (String host, int port) async => MockUnresponsiveAndroidConsoleSocket(); + final Future Function(String host, int port) workingSocket = + (String host, int port) async => MockWorkingAndroidConsoleSocket(dummyEmulatorId); + String hardware; + bool socketWasCreated; + + setUp(() { + hardware = 'goldfish'; // Known emulator + socketWasCreated = false; + when(mockProcessManager.run(argThat(contains('getprop')), + stderrEncoding: anyNamed('stderrEncoding'), + stdoutEncoding: anyNamed('stdoutEncoding'))).thenAnswer((_) { + final StringBuffer buf = StringBuffer() + ..writeln('[ro.hardware]: [$hardware]'); + final ProcessResult result = ProcessResult(1, 0, buf.toString(), ''); + return Future.value(result); + }); + }); + + testUsingContext('returns correct ID for responsive emulator', () async { + final AndroidDevice device = AndroidDevice('emulator-5555'); + expect(await device.emulatorId, equals(dummyEmulatorId)); + }, overrides: { + AndroidConsoleSocketFactory: () => workingSocket, + ProcessManager: () => mockProcessManager, + }); + + testUsingContext('does not create socket for non-emulator devices', () async { + hardware = 'samsungexynos7420'; + + // Still use an emulator-looking ID so we can be sure the failure is due + // to the isLocalEmulator field and not because the ID doesn't contain a + // port. + final AndroidDevice device = AndroidDevice('emulator-5555'); + expect(await device.emulatorId, isNull); + expect(socketWasCreated, isFalse); + }, overrides: { + AndroidConsoleSocketFactory: () => (String host, int port) async { + socketWasCreated = true; + throw 'Socket was created for non-emulator'; + }, + ProcessManager: () => mockProcessManager, + }); + + testUsingContext('does not create socket for emulators with no port', () async { + final AndroidDevice device = AndroidDevice('emulator-noport'); + expect(await device.emulatorId, isNull); + expect(socketWasCreated, isFalse); + }, overrides: { + AndroidConsoleSocketFactory: () => (String host, int port) async { + socketWasCreated = true; + throw 'Socket was created for emulator without port in ID'; + }, + ProcessManager: () => mockProcessManager, + }); + + testUsingContext('returns null for connection error', () async { + final AndroidDevice device = AndroidDevice('emulator-5555'); + expect(await device.emulatorId, isNull); + }, overrides: { + AndroidConsoleSocketFactory: () => (String host, int port) => throw 'Fake socket error', + ProcessManager: () => mockProcessManager, + }); + + testUsingContext('returns null for unresponsive device', () async { + final AndroidDevice device = AndroidDevice('emulator-5555'); + expect(await device.emulatorId, isNull); + }, overrides: { + AndroidConsoleSocketFactory: () => unresponsiveSocket, + ProcessManager: () => mockProcessManager, + }); + }); + group('portForwarder', () { final ProcessManager mockProcessManager = MockProcessManager(); final AndroidDevice device = AndroidDevice('1234'); @@ -480,3 +559,44 @@ const String kAdbShellGetprop = ''' [wlan.driver.status]: [unloaded] [xmpp.auto-presence]: [true] '''; + +/// A mock Android Console that presents a connection banner and responds to +/// "avd name" requests with the supplied name. +class MockWorkingAndroidConsoleSocket extends Mock implements Socket { + MockWorkingAndroidConsoleSocket(this.avdName) { + _controller.add('Android Console: Welcome!\n'); + // Include OK in the same packet here. In the response to "avd name" + // it's sent alone to ensure both are handled. + _controller.add('Android Console: Some intro text\nOK\n'); + } + + final String avdName; + final StreamController _controller = StreamController(); + + @override + Stream asyncMap(FutureOr convert(List event)) => _controller.stream as Stream; + + @override + void add(List data) { + final String text = ascii.decode(data); + if (text == 'avd name\n') { + _controller.add('$avdName\n'); + // Include OK in its own packet here. In welcome banner it's included + // as part of the previous text to ensure both are handled. + _controller.add('OK\n'); + } else { + throw 'Unexpected command $text'; + } + } +} + +/// An Android console socket that drops all input and returns no output. +class MockUnresponsiveAndroidConsoleSocket extends Mock implements Socket { + final StreamController _controller = StreamController(); + + @override + Stream asyncMap(FutureOr convert(List event)) => _controller.stream as Stream; + + @override + void add(List data) {} +}