Commit 8bb8e1d9 authored by Devon Carew's avatar Devon Carew

add device notifications to the daemon command

parent 477530f3
...@@ -6,21 +6,14 @@ import 'dart:async'; ...@@ -6,21 +6,14 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import '../android/adb.dart';
import '../base/logging.dart'; import '../base/logging.dart';
import '../device.dart';
import '../runner/flutter_command.dart'; import '../runner/flutter_command.dart';
import 'start.dart'; import 'start.dart';
import 'stop.dart'; import 'stop.dart';
const String protocolVersion = '0.0.1'; const String protocolVersion = '0.0.2';
/// 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.
/// A server process command. This command will start up a long-lived server. /// 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 /// It reads JSON-RPC based commands from stdin, executes them, and returns
...@@ -32,7 +25,6 @@ class DaemonCommand extends FlutterCommand { ...@@ -32,7 +25,6 @@ class DaemonCommand extends FlutterCommand {
final String name = 'daemon'; final String name = 'daemon';
final String description = 'Run a persistent, JSON-RPC based server to communicate with devices.'; final String description = 'Run a persistent, JSON-RPC based server to communicate with devices.';
@override
Future<int> runInProject() async { Future<int> runInProject() async {
print('Starting device daemon...'); print('Starting device daemon...');
...@@ -48,90 +40,101 @@ class DaemonCommand extends FlutterCommand { ...@@ -48,90 +40,101 @@ class DaemonCommand extends FlutterCommand {
await downloadApplicationPackagesAndConnectToDevices(); await downloadApplicationPackagesAndConnectToDevices();
Daemon daemon = new Daemon(commandStream, (Map command) { Daemon daemon = new Daemon(commandStream, (Map command) {
stdout.writeln('[${JSON.encode(command)}]'); stdout.writeln('[${JSON.encode(command, toEncodable: _jsonEncodeObject)}]');
}, daemonCommand: this); }, daemonCommand: this);
return await daemon.onExit; 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<String, dynamic> command);
typedef Future<dynamic> CommandHandler(dynamic args); typedef Future<dynamic> CommandHandler(dynamic args);
class Daemon { class Daemon {
final DispatchComand sendCommand;
final DaemonCommand daemonCommand;
final Completer<int> _onExitCompleter = new Completer();
final Map<String, Domain> _domains = {};
Daemon(Stream<Map> commandStream, this.sendCommand, {this.daemonCommand}) { Daemon(Stream<Map> commandStream, this.sendCommand, {this.daemonCommand}) {
// Set up domains. // Set up domains.
_registerDomain(new DaemonDomain(this)); _registerDomain(new DaemonDomain(this));
_registerDomain(new AppDomain(this)); _registerDomain(new AppDomain(this));
_registerDomain(new DeviceDomain(this));
// Start listening. // Start listening.
commandStream.listen( commandStream.listen(
(Map command) => _handleCommand(command), (Map request) => _handleRequest(request),
onDone: () => _onExitCompleter.complete(0) onDone: () => _onExitCompleter.complete(0)
); );
} }
final DispatchComand sendCommand;
final DaemonCommand daemonCommand;
final Completer<int> _onExitCompleter = new Completer<int>();
final Map<String, Domain> _domainMap = <String, Domain>{};
void _registerDomain(Domain domain) { void _registerDomain(Domain domain) {
_domains[domain.name] = domain; _domainMap[domain.name] = domain;
} }
Future<int> get onExit => _onExitCompleter.future; Future<int> get onExit => _onExitCompleter.future;
void _handleCommand(Map command) { void _handleRequest(Map request) {
// {id, event, params} // {id, method, params}
var id = command['id'];
// [id] is an opaque type to us.
dynamic id = request['id'];
if (id == null) { if (id == null) {
logging.severe('no id for command: $command'); logging.severe('no id for request: $request');
return; return;
} }
try { try {
String event = command['event']; String method = request['method'];
if (event.indexOf('.') == -1) if (method.indexOf('.') == -1)
throw 'command not understood: $event'; throw 'method not understood: $method';
String prefix = event.substring(0, event.indexOf('.')); String prefix = method.substring(0, method.indexOf('.'));
String name = event.substring(event.indexOf('.') + 1); String name = method.substring(method.indexOf('.') + 1);
if (_domains[prefix] == null) if (_domainMap[prefix] == null)
throw 'no domain for command: $command'; throw 'no domain for method: $method';
_domains[prefix].handleEvent(name, id, command['params']); _domainMap[prefix].handleCommand(name, id, request['params']);
} catch (error, trace) { } catch (error, trace) {
_send({'id': id, 'error': _toJsonable(error)}); _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 _send(Map map) => sendCommand(map);
void shutdown() { void shutdown() {
_domainMap.values.forEach((Domain domain) => domain.dispose());
if (!_onExitCompleter.isCompleted) if (!_onExitCompleter.isCompleted)
_onExitCompleter.complete(0); _onExitCompleter.complete(0);
} }
} }
abstract class Domain { abstract class Domain {
Domain(this.daemon, this.name);
final Daemon daemon; final Daemon daemon;
final String name; final String name;
final Map<String, CommandHandler> _handlers = {}; final Map<String, CommandHandler> _handlers = {};
Domain(this.daemon, this.name);
void registerHandler(String name, CommandHandler handler) { void registerHandler(String name, CommandHandler handler) {
_handlers[name] = handler; _handlers[name] = handler;
} }
String toString() => name; String toString() => name;
void handleEvent(String name, dynamic id, dynamic args) { void handleCommand(String name, dynamic id, dynamic args) {
new Future.sync(() { new Future.sync(() {
if (_handlers.containsKey(name)) if (_handlers.containsKey(name))
return _handlers[name](args); return _handlers[name](args);
...@@ -148,24 +151,30 @@ abstract class Domain { ...@@ -148,24 +151,30 @@ abstract class Domain {
}); });
} }
void sendEvent(String name, [dynamic args]) {
Map<String, dynamic> map = { 'method': name };
if (args != null)
map['params'] = _toJsonable(args);
_send(map);
}
void _send(Map map) => daemon._send(map); void _send(Map map) => daemon._send(map);
void dispose() { }
} }
/// This domain responds to methods like [version] and [shutdown]. /// This domain responds to methods like [version] and [shutdown].
@domain
class DaemonDomain extends Domain { class DaemonDomain extends Domain {
DaemonDomain(Daemon daemon) : super(daemon, 'daemon') { DaemonDomain(Daemon daemon) : super(daemon, 'daemon') {
registerHandler('version', version); registerHandler('version', version);
registerHandler('shutdown', shutdown); registerHandler('shutdown', shutdown);
} }
@command Future<String> version(dynamic args) {
Future<dynamic> version(dynamic args) {
return new Future.value(protocolVersion); return new Future.value(protocolVersion);
} }
@command Future shutdown(dynamic args) {
Future<dynamic> shutdown(dynamic args) {
Timer.run(() => daemon.shutdown()); Timer.run(() => daemon.shutdown());
return new Future.value(); return new Future.value();
} }
...@@ -175,14 +184,12 @@ class DaemonDomain extends Domain { ...@@ -175,14 +184,12 @@ class DaemonDomain extends Domain {
/// ///
/// It'll be extended to fire events for when applications start, stop, and /// It'll be extended to fire events for when applications start, stop, and
/// log data. /// log data.
@domain
class AppDomain extends Domain { class AppDomain extends Domain {
AppDomain(Daemon daemon) : super(daemon, 'app') { AppDomain(Daemon daemon) : super(daemon, 'app') {
registerHandler('start', start); registerHandler('start', start);
registerHandler('stopAll', stopAll); registerHandler('stopAll', stopAll);
} }
@command
Future<dynamic> start(dynamic args) { Future<dynamic> start(dynamic args) {
// TODO: Add the ability to pass args: target, http, checked // TODO: Add the ability to pass args: target, http, checked
StartCommand startComand = new StartCommand(); StartCommand startComand = new StartCommand();
...@@ -190,7 +197,6 @@ class AppDomain extends Domain { ...@@ -190,7 +197,6 @@ class AppDomain extends Domain {
return startComand.runInProject().then((_) => null); return startComand.runInProject().then((_) => null);
} }
@command
Future<bool> stopAll(dynamic args) { Future<bool> stopAll(dynamic args) {
StopCommand stopCommand = new StopCommand(); StopCommand stopCommand = new StopCommand();
stopCommand.inheritFromParent(daemon.daemonCommand); stopCommand.inheritFromParent(daemon.daemonCommand);
...@@ -198,8 +204,138 @@ class AppDomain extends Domain { ...@@ -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<List<Device>> getDevices(dynamic args) {
List<Device> devices = <Device>[];
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<String, AndroidDevice> _devices = new Map<String, AndroidDevice>();
StreamController<Device> addedController = new StreamController<Device>.broadcast();
StreamController<Device> removedController = new StreamController<Device>.broadcast();
StreamController<Device> changedController = new StreamController<Device>.broadcast();
List<Device> getDevices() => _devices.values.toList();
Stream<Device> get onAdded => addedController.stream;
Stream<Device> get onRemoved => removedController.stream;
Stream<Device> get onChanged => changedController.stream;
void _initAdb() {
if (_adb == null) {
_adb = new Adb(AndroidDevice.getAdbPath());
if (!_adb.exists())
_adb = null;
}
}
void _handleNewDevices(List<AdbDevice> newDevices) {
List<AndroidDevice> 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<String, dynamic> _deviceToMap(Device device) {
return <String, dynamic>{
'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) { dynamic _toJsonable(dynamic obj) {
if (obj is String || obj is int || obj is bool || obj is Map || obj is List || obj == null) if (obj is String || obj is int || obj is bool || obj is Map || obj is List || obj == null)
return obj; return obj;
if (obj is Device)
return obj;
return '$obj'; return '$obj';
} }
...@@ -21,8 +21,14 @@ abstract class Device { ...@@ -21,8 +21,14 @@ abstract class Device {
return _deviceCache.putIfAbsent(id, () => constructor(id)); return _deviceCache.putIfAbsent(id, () => constructor(id));
} }
static void removeFromCache(String id) {
_deviceCache.remove(id);
}
Device._(this.id); Device._(this.id);
String get name;
/// Install an app package on the current device /// Install an app package on the current device
bool installApp(ApplicationPackage app); bool installApp(ApplicationPackage app);
...@@ -532,20 +538,25 @@ class AndroidDevice extends Device { ...@@ -532,20 +538,25 @@ class AndroidDevice extends Device {
String modelID; String modelID;
String deviceCodeName; String deviceCodeName;
bool _connected;
String _adbPath; String _adbPath;
String get adbPath => _adbPath; String get adbPath => _adbPath;
bool _hasAdb = false; bool _hasAdb = false;
bool _hasValidAndroid = false; bool _hasValidAndroid = false;
factory AndroidDevice( factory AndroidDevice({
{String id: null, String id: null,
String productID: null, String productID: null,
String modelID: null, String modelID: null,
String deviceCodeName: null}) { String deviceCodeName: null,
bool connected
}) {
AndroidDevice device = Device._unique(id ?? defaultDeviceID, (String id) => new AndroidDevice._(id)); AndroidDevice device = Device._unique(id ?? defaultDeviceID, (String id) => new AndroidDevice._(id));
device.productID = productID; device.productID = productID;
device.modelID = modelID; device.modelID = modelID;
device.deviceCodeName = deviceCodeName; device.deviceCodeName = deviceCodeName;
if (connected != null)
device._connected = connected;
return device; return device;
} }
...@@ -553,7 +564,7 @@ class AndroidDevice extends Device { ...@@ -553,7 +564,7 @@ class AndroidDevice extends Device {
/// we don't have to rely on the test setup having adb available to it. /// we don't have to rely on the test setup having adb available to it.
static List<AndroidDevice> getAttachedDevices([AndroidDevice mockAndroid]) { static List<AndroidDevice> getAttachedDevices([AndroidDevice mockAndroid]) {
List<AndroidDevice> devices = []; List<AndroidDevice> devices = [];
String adbPath = (mockAndroid != null) ? mockAndroid.adbPath : _getAdbPath(); String adbPath = (mockAndroid != null) ? mockAndroid.adbPath : getAdbPath();
try { try {
runCheckedSync([adbPath, 'version']); runCheckedSync([adbPath, 'version']);
...@@ -623,7 +634,7 @@ class AndroidDevice extends Device { ...@@ -623,7 +634,7 @@ class AndroidDevice extends Device {
} }
AndroidDevice._(id) : super._(id) { AndroidDevice._(id) : super._(id) {
_adbPath = _getAdbPath(); _adbPath = getAdbPath();
_hasAdb = _checkForAdb(); _hasAdb = _checkForAdb();
// Checking for [minApiName] only needs to be done if we are starting an // Checking for [minApiName] only needs to be done if we are starting an
...@@ -655,7 +666,7 @@ class AndroidDevice extends Device { ...@@ -655,7 +666,7 @@ class AndroidDevice extends Device {
} }
} }
static String _getAdbPath() { static String getAdbPath() {
if (Platform.environment.containsKey('ANDROID_HOME')) { if (Platform.environment.containsKey('ANDROID_HOME')) {
String androidHomeDir = Platform.environment['ANDROID_HOME']; String androidHomeDir = Platform.environment['ANDROID_HOME'];
String adbPath1 = path.join(androidHomeDir, 'sdk', 'platform-tools', 'adb'); String adbPath1 = path.join(androidHomeDir, 'sdk', 'platform-tools', 'adb');
...@@ -782,6 +793,8 @@ class AndroidDevice extends Device { ...@@ -782,6 +793,8 @@ class AndroidDevice extends Device {
return CryptoUtils.bytesToHex(sha1.close()); return CryptoUtils.bytesToHex(sha1.close());
} }
String get name => modelID;
@override @override
bool isAppInstalled(ApplicationPackage app) { bool isAppInstalled(ApplicationPackage app) {
if (!isConnected()) { if (!isConnected()) {
...@@ -992,8 +1005,11 @@ class AndroidDevice extends Device { ...@@ -992,8 +1005,11 @@ class AndroidDevice extends Device {
return null; return null;
} }
@override bool isConnected() => _connected != null ? _connected : _hasValidAndroid;
bool isConnected() => _hasValidAndroid;
void setConnected(bool value) {
_connected = value;
}
} }
class DeviceStore { class DeviceStore {
......
...@@ -26,9 +26,9 @@ defineTests() { ...@@ -26,9 +26,9 @@ defineTests() {
StreamController<Map> responses = new StreamController(); StreamController<Map> responses = new StreamController();
daemon = new Daemon( daemon = new Daemon(
commands.stream, commands.stream,
(Map result) => responses.add(result) (Map<String, dynamic> result) => responses.add(result)
); );
commands.add({'id': 0, 'event': 'daemon.version'}); commands.add({'id': 0, 'method': 'daemon.version'});
Map response = await responses.stream.first; Map response = await responses.stream.first;
expect(response['id'], 0); expect(response['id'], 0);
expect(response['result'], isNotEmpty); expect(response['result'], isNotEmpty);
...@@ -40,9 +40,9 @@ defineTests() { ...@@ -40,9 +40,9 @@ defineTests() {
StreamController<Map> responses = new StreamController(); StreamController<Map> responses = new StreamController();
daemon = new Daemon( daemon = new Daemon(
commands.stream, commands.stream,
(Map result) => responses.add(result) (Map<String, dynamic> result) => responses.add(result)
); );
commands.add({'id': 0, 'event': 'daemon.shutdown'}); commands.add({'id': 0, 'method': 'daemon.shutdown'});
return daemon.onExit.then((int code) { return daemon.onExit.then((int code) {
expect(code, 0); expect(code, 0);
}); });
...@@ -56,7 +56,7 @@ defineTests() { ...@@ -56,7 +56,7 @@ defineTests() {
StreamController<Map> responses = new StreamController(); StreamController<Map> responses = new StreamController();
daemon = new Daemon( daemon = new Daemon(
commands.stream, commands.stream,
(Map result) => responses.add(result), (Map<String, dynamic> result) => responses.add(result),
daemonCommand: command daemonCommand: command
); );
...@@ -71,10 +71,23 @@ defineTests() { ...@@ -71,10 +71,23 @@ defineTests() {
when(mockDevices.iOSSimulator.isConnected()).thenReturn(false); when(mockDevices.iOSSimulator.isConnected()).thenReturn(false);
when(mockDevices.iOSSimulator.stopApp(any)).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; Map response = await responses.stream.first;
expect(response['id'], 0); expect(response['id'], 0);
expect(response['result'], true); expect(response['result'], true);
}); });
test('device.getDevices', () async {
StreamController<Map> commands = new StreamController();
StreamController<Map> responses = new StreamController();
daemon = new Daemon(
commands.stream,
(Map<String, dynamic> 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);
});
}); });
} }
...@@ -7,8 +7,15 @@ import 'dart:io'; ...@@ -7,8 +7,15 @@ import 'dart:io';
Process daemon; 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 { 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}'); print('daemon process started, pid: ${daemon.pid}');
daemon.stdout daemon.stdout
...@@ -20,13 +27,15 @@ main() async { ...@@ -20,13 +27,15 @@ main() async {
stdout.write('> '); stdout.write('> ');
stdin.transform(UTF8.decoder).transform(const LineSplitter()).listen((String line) { stdin.transform(UTF8.decoder).transform(const LineSplitter()).listen((String line) {
if (line == 'version' || line == 'v') { if (line == 'version' || line == 'v') {
_send({'event': 'daemon.version'}); _send({'method': 'daemon.version'});
} else if (line == 'shutdown' || line == 'q') { } else if (line == 'shutdown' || line == 'q') {
_send({'event': 'daemon.shutdown'}); _send({'method': 'daemon.shutdown'});
} else if (line == 'start') { } else if (line == 'start') {
_send({'event': 'app.start'}); _send({'method': 'app.start'});
} else if (line == 'stopAll') { } else if (line == 'stopAll') {
_send({'event': 'app.stopAll'}); _send({'method': 'app.stopAll'});
} else if (line == 'devices') {
_send({'method': 'device.getDevices'});
} else { } else {
print('command not understood: ${line}'); print('command not understood: ${line}');
} }
......
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