diff --git a/packages/flutter_tools/lib/src/commands/daemon.dart b/packages/flutter_tools/lib/src/commands/daemon.dart index c7168b68cd2..3fe357a8e26 100644 --- a/packages/flutter_tools/lib/src/commands/daemon.dart +++ b/packages/flutter_tools/lib/src/commands/daemon.dart @@ -6,21 +6,14 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import '../android/adb.dart'; import '../base/logging.dart'; +import '../device.dart'; import '../runner/flutter_command.dart'; import 'start.dart'; import 'stop.dart'; -const String protocolVersion = '0.0.1'; - -/// A @domain annotation. -const String domain = 'domain'; - -/// A domain @command annotation. -const String command = 'command'; - -// TODO: Create a `device` domain in order to list devices and fire events when -// devices are added or removed. +const String protocolVersion = '0.0.2'; /// 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 @@ -32,7 +25,6 @@ class DaemonCommand extends FlutterCommand { final String name = 'daemon'; final String description = 'Run a persistent, JSON-RPC based server to communicate with devices.'; - @override Future runInProject() async { print('Starting device daemon...'); @@ -48,90 +40,101 @@ class DaemonCommand extends FlutterCommand { await downloadApplicationPackagesAndConnectToDevices(); Daemon daemon = new Daemon(commandStream, (Map command) { - stdout.writeln('[${JSON.encode(command)}]'); + stdout.writeln('[${JSON.encode(command, toEncodable: _jsonEncodeObject)}]'); }, daemonCommand: this); return await daemon.onExit; } + + dynamic _jsonEncodeObject(dynamic object) { + if (object is Device) + return _deviceToMap(object); + + return object; + } } -typedef void DispatchComand(Map command); +typedef void DispatchComand(Map command); typedef Future CommandHandler(dynamic args); class Daemon { - final DispatchComand sendCommand; - final DaemonCommand daemonCommand; - - final Completer _onExitCompleter = new Completer(); - final Map _domains = {}; - Daemon(Stream commandStream, this.sendCommand, {this.daemonCommand}) { // Set up domains. _registerDomain(new DaemonDomain(this)); _registerDomain(new AppDomain(this)); + _registerDomain(new DeviceDomain(this)); // Start listening. commandStream.listen( - (Map command) => _handleCommand(command), + (Map request) => _handleRequest(request), onDone: () => _onExitCompleter.complete(0) ); } + final DispatchComand sendCommand; + final DaemonCommand daemonCommand; + + final Completer _onExitCompleter = new Completer(); + final Map _domainMap = {}; + void _registerDomain(Domain domain) { - _domains[domain.name] = domain; + _domainMap[domain.name] = domain; } Future get onExit => _onExitCompleter.future; - void _handleCommand(Map command) { - // {id, event, params} - var id = command['id']; + void _handleRequest(Map request) { + // {id, method, params} + + // [id] is an opaque type to us. + dynamic id = request['id']; if (id == null) { - logging.severe('no id for command: $command'); + logging.severe('no id for request: $request'); return; } try { - String event = command['event']; - if (event.indexOf('.') == -1) - throw 'command not understood: $event'; + String method = request['method']; + if (method.indexOf('.') == -1) + throw 'method not understood: $method'; - String prefix = event.substring(0, event.indexOf('.')); - String name = event.substring(event.indexOf('.') + 1); - if (_domains[prefix] == null) - throw 'no domain for command: $command'; + String prefix = method.substring(0, method.indexOf('.')); + String name = method.substring(method.indexOf('.') + 1); + if (_domainMap[prefix] == null) + throw 'no domain for method: $method'; - _domains[prefix].handleEvent(name, id, command['params']); + _domainMap[prefix].handleCommand(name, id, request['params']); } catch (error, trace) { _send({'id': id, 'error': _toJsonable(error)}); - logging.warning('error handling ${command['event']}', error, trace); + logging.warning('error handling $request', error, trace); } } void _send(Map map) => sendCommand(map); void shutdown() { + _domainMap.values.forEach((Domain domain) => domain.dispose()); if (!_onExitCompleter.isCompleted) _onExitCompleter.complete(0); } } abstract class Domain { + Domain(this.daemon, this.name); + final Daemon daemon; final String name; final Map _handlers = {}; - Domain(this.daemon, this.name); - void registerHandler(String name, CommandHandler handler) { _handlers[name] = handler; } String toString() => name; - void handleEvent(String name, dynamic id, dynamic args) { + void handleCommand(String name, dynamic id, dynamic args) { new Future.sync(() { if (_handlers.containsKey(name)) return _handlers[name](args); @@ -148,24 +151,30 @@ abstract class Domain { }); } + void sendEvent(String name, [dynamic args]) { + Map map = { 'method': name }; + if (args != null) + map['params'] = _toJsonable(args); + _send(map); + } + void _send(Map map) => daemon._send(map); + + void dispose() { } } /// This domain responds to methods like [version] and [shutdown]. -@domain class DaemonDomain extends Domain { DaemonDomain(Daemon daemon) : super(daemon, 'daemon') { registerHandler('version', version); registerHandler('shutdown', shutdown); } - @command - Future version(dynamic args) { + Future version(dynamic args) { return new Future.value(protocolVersion); } - @command - Future shutdown(dynamic args) { + Future shutdown(dynamic args) { Timer.run(() => daemon.shutdown()); return new Future.value(); } @@ -175,14 +184,12 @@ class DaemonDomain extends Domain { /// /// It'll be extended to fire events for when applications start, stop, and /// log data. -@domain class AppDomain extends Domain { AppDomain(Daemon daemon) : super(daemon, 'app') { registerHandler('start', start); registerHandler('stopAll', stopAll); } - @command Future start(dynamic args) { // TODO: Add the ability to pass args: target, http, checked StartCommand startComand = new StartCommand(); @@ -190,7 +197,6 @@ class AppDomain extends Domain { return startComand.runInProject().then((_) => null); } - @command Future stopAll(dynamic args) { StopCommand stopCommand = new StopCommand(); stopCommand.inheritFromParent(daemon.daemonCommand); @@ -198,8 +204,138 @@ class AppDomain extends Domain { } } +/// This domain lets callers list and monitor connected devices. +/// +/// It exports a `getDevices()` call, as well as firing `device.added`, +/// `device.removed`, and `device.changed` events. +class DeviceDomain extends Domain { + DeviceDomain(Daemon daemon) : super(daemon, 'device') { + registerHandler('getDevices', getDevices); + + _androidDeviceDiscovery = new AndroidDeviceDiscovery(); + _androidDeviceDiscovery.onAdded.listen((Device device) { + sendEvent('device.added', _deviceToMap(device)); + }); + _androidDeviceDiscovery.onRemoved.listen((Device device) { + sendEvent('device.removed', _deviceToMap(device)); + }); + _androidDeviceDiscovery.onChanged.listen((Device device) { + sendEvent('device.changed', _deviceToMap(device)); + }); + } + + AndroidDeviceDiscovery _androidDeviceDiscovery; + + Future> getDevices(dynamic args) { + List devices = []; + devices.addAll(_androidDeviceDiscovery.getDevices()); + return new Future.value(devices); + } + + void dispose() { + _androidDeviceDiscovery.dispose(); + } +} + +class AndroidDeviceDiscovery { + AndroidDeviceDiscovery() { + _initAdb(); + + if (_adb != null) { + _subscription = _adb.trackDevices().listen(_handleNewDevices); + } + } + + Adb _adb; + StreamSubscription _subscription; + Map _devices = new Map(); + + StreamController addedController = new StreamController.broadcast(); + StreamController removedController = new StreamController.broadcast(); + StreamController changedController = new StreamController.broadcast(); + + List getDevices() => _devices.values.toList(); + + Stream get onAdded => addedController.stream; + Stream get onRemoved => removedController.stream; + Stream get onChanged => changedController.stream; + + void _initAdb() { + if (_adb == null) { + _adb = new Adb(AndroidDevice.getAdbPath()); + if (!_adb.exists()) + _adb = null; + } + } + + void _handleNewDevices(List newDevices) { + List currentDevices = new List.from(getDevices()); + + for (AdbDevice device in newDevices) { + AndroidDevice androidDevice = _devices[device.id]; + + if (androidDevice == null) { + // device added + androidDevice = new AndroidDevice( + id: device.id, + productID: device.productID, + modelID: device.modelID, + deviceCodeName: device.deviceCodeName, + connected: device.isAvailable + ); + _devices[androidDevice.id] = androidDevice; + addedController.add(androidDevice); + } else { + currentDevices.remove(androidDevice); + + // check state + if (androidDevice.isConnected() != device.isAvailable) { + androidDevice.setConnected(device.isAvailable); + changedController.add(androidDevice); + } + } + } + + // device removed + for (AndroidDevice device in currentDevices) { + _devices.remove(device.id); + + // I don't know the purpose of this cache or if it's a good idea. We should + // probably have a DeviceManager singleton class to coordinate known devices + // and different device discovery mechanisms. + Device.removeFromCache(device.id); + + removedController.add(device); + } + } + + void dispose() { + _subscription?.cancel(); + } +} + +Map _deviceToMap(Device device) { + return { + 'id': device.id, + 'platform': _enumToString(device.platform), + 'available': device.isConnected() + }; +} + +/// Take an enum value and get the best string representation of that. +/// +/// toString() on enums returns 'EnumType.enumName'. +String _enumToString(dynamic enumValue) { + String str = '$enumValue'; + if (str.contains('.')) + return str.substring(str.indexOf('.') + 1); + return str; +} + dynamic _toJsonable(dynamic obj) { if (obj is String || obj is int || obj is bool || obj is Map || obj is List || obj == null) return obj; + if (obj is Device) + return obj; return '$obj'; } diff --git a/packages/flutter_tools/lib/src/device.dart b/packages/flutter_tools/lib/src/device.dart index ff7401aff06..6507313b51e 100644 --- a/packages/flutter_tools/lib/src/device.dart +++ b/packages/flutter_tools/lib/src/device.dart @@ -21,8 +21,14 @@ abstract class Device { return _deviceCache.putIfAbsent(id, () => constructor(id)); } + static void removeFromCache(String id) { + _deviceCache.remove(id); + } + Device._(this.id); + String get name; + /// Install an app package on the current device bool installApp(ApplicationPackage app); @@ -532,20 +538,25 @@ class AndroidDevice extends Device { String modelID; String deviceCodeName; + bool _connected; String _adbPath; String get adbPath => _adbPath; bool _hasAdb = false; bool _hasValidAndroid = false; - factory AndroidDevice( - {String id: null, - String productID: null, - String modelID: null, - String deviceCodeName: null}) { + factory AndroidDevice({ + String id: null, + String productID: null, + String modelID: null, + String deviceCodeName: null, + bool connected + }) { AndroidDevice device = Device._unique(id ?? defaultDeviceID, (String id) => new AndroidDevice._(id)); device.productID = productID; device.modelID = modelID; device.deviceCodeName = deviceCodeName; + if (connected != null) + device._connected = connected; return device; } @@ -553,7 +564,7 @@ class AndroidDevice extends Device { /// we don't have to rely on the test setup having adb available to it. static List getAttachedDevices([AndroidDevice mockAndroid]) { List devices = []; - String adbPath = (mockAndroid != null) ? mockAndroid.adbPath : _getAdbPath(); + String adbPath = (mockAndroid != null) ? mockAndroid.adbPath : getAdbPath(); try { runCheckedSync([adbPath, 'version']); @@ -623,7 +634,7 @@ class AndroidDevice extends Device { } AndroidDevice._(id) : super._(id) { - _adbPath = _getAdbPath(); + _adbPath = getAdbPath(); _hasAdb = _checkForAdb(); // Checking for [minApiName] only needs to be done if we are starting an @@ -655,7 +666,7 @@ class AndroidDevice extends Device { } } - static String _getAdbPath() { + static String getAdbPath() { if (Platform.environment.containsKey('ANDROID_HOME')) { String androidHomeDir = Platform.environment['ANDROID_HOME']; String adbPath1 = path.join(androidHomeDir, 'sdk', 'platform-tools', 'adb'); @@ -782,6 +793,8 @@ class AndroidDevice extends Device { return CryptoUtils.bytesToHex(sha1.close()); } + String get name => modelID; + @override bool isAppInstalled(ApplicationPackage app) { if (!isConnected()) { @@ -992,8 +1005,11 @@ class AndroidDevice extends Device { return null; } - @override - bool isConnected() => _hasValidAndroid; + bool isConnected() => _connected != null ? _connected : _hasValidAndroid; + + void setConnected(bool value) { + _connected = value; + } } class DeviceStore { diff --git a/packages/flutter_tools/test/daemon_test.dart b/packages/flutter_tools/test/daemon_test.dart index 9a61dd0baa6..d52fd17d145 100644 --- a/packages/flutter_tools/test/daemon_test.dart +++ b/packages/flutter_tools/test/daemon_test.dart @@ -26,9 +26,9 @@ defineTests() { StreamController responses = new StreamController(); daemon = new Daemon( commands.stream, - (Map result) => responses.add(result) + (Map result) => responses.add(result) ); - commands.add({'id': 0, 'event': 'daemon.version'}); + commands.add({'id': 0, 'method': 'daemon.version'}); Map response = await responses.stream.first; expect(response['id'], 0); expect(response['result'], isNotEmpty); @@ -40,9 +40,9 @@ defineTests() { StreamController responses = new StreamController(); daemon = new Daemon( commands.stream, - (Map result) => responses.add(result) + (Map result) => responses.add(result) ); - commands.add({'id': 0, 'event': 'daemon.shutdown'}); + commands.add({'id': 0, 'method': 'daemon.shutdown'}); return daemon.onExit.then((int code) { expect(code, 0); }); @@ -56,7 +56,7 @@ defineTests() { StreamController responses = new StreamController(); daemon = new Daemon( commands.stream, - (Map result) => responses.add(result), + (Map result) => responses.add(result), daemonCommand: command ); @@ -71,10 +71,23 @@ defineTests() { when(mockDevices.iOSSimulator.isConnected()).thenReturn(false); when(mockDevices.iOSSimulator.stopApp(any)).thenReturn(false); - commands.add({'id': 0, 'event': 'app.stopAll'}); + commands.add({'id': 0, 'method': 'app.stopAll'}); Map response = await responses.stream.first; expect(response['id'], 0); expect(response['result'], true); }); + + test('device.getDevices', () async { + StreamController commands = new StreamController(); + StreamController responses = new StreamController(); + daemon = new Daemon( + commands.stream, + (Map result) => responses.add(result) + ); + commands.add({'id': 0, 'method': 'device.getDevices'}); + Map response = await responses.stream.first; + expect(response['id'], 0); + expect(response['result'], isList); + }); }); } diff --git a/packages/flutter_tools/tool/daemon_client.dart b/packages/flutter_tools/tool/daemon_client.dart index cba8eaece24..e1be9ffa473 100644 --- a/packages/flutter_tools/tool/daemon_client.dart +++ b/packages/flutter_tools/tool/daemon_client.dart @@ -7,8 +7,15 @@ import 'dart:io'; Process daemon; +// To use, start from the console and enter: +// version: print version +// shutdown: terminate the server +// start: start an app +// stopAll: stop any running app +// devices: list devices + main() async { - daemon = await Process.start('dart', ['bin/flutter_tools.dart', 'daemon']); + daemon = await Process.start('flutter', ['daemon']); print('daemon process started, pid: ${daemon.pid}'); daemon.stdout @@ -20,13 +27,15 @@ main() async { stdout.write('> '); stdin.transform(UTF8.decoder).transform(const LineSplitter()).listen((String line) { if (line == 'version' || line == 'v') { - _send({'event': 'daemon.version'}); + _send({'method': 'daemon.version'}); } else if (line == 'shutdown' || line == 'q') { - _send({'event': 'daemon.shutdown'}); + _send({'method': 'daemon.shutdown'}); } else if (line == 'start') { - _send({'event': 'app.start'}); + _send({'method': 'app.start'}); } else if (line == 'stopAll') { - _send({'event': 'app.stopAll'}); + _send({'method': 'app.stopAll'}); + } else if (line == 'devices') { + _send({'method': 'device.getDevices'}); } else { print('command not understood: ${line}'); }