Unverified Commit e8ad0309 authored by Hannes Winkler's avatar Hannes Winkler Committed by GitHub

[flutter_tools] Add support for custom devices (#78113)

parent c1ca47b4
...@@ -28,11 +28,12 @@ class Config { ...@@ -28,11 +28,12 @@ class Config {
required FileSystem fileSystem, required FileSystem fileSystem,
required Logger logger, required Logger logger,
required Platform platform, required Platform platform,
bool deleteFileOnFormatException = true
}) { }) {
final String filePath = _configPath(platform, fileSystem, name); final String filePath = _configPath(platform, fileSystem, name);
final File file = fileSystem.file(filePath); final File file = fileSystem.file(filePath);
file.parent.createSync(recursive: true); file.parent.createSync(recursive: true);
return Config.createForTesting(file, logger); return Config.createForTesting(file, logger, deleteFileOnFormatException: deleteFileOnFormatException);
} }
/// Constructs a new [Config] object from a file called [name] in /// Constructs a new [Config] object from a file called [name] in
...@@ -43,14 +44,19 @@ class Config { ...@@ -43,14 +44,19 @@ class Config {
String name = 'test', String name = 'test',
Directory? directory, Directory? directory,
Logger? logger, Logger? logger,
bool deleteFileOnFormatException = true
}) { }) {
directory ??= MemoryFileSystem.test().directory('/'); directory ??= MemoryFileSystem.test().directory('/');
return Config.createForTesting(directory.childFile('.${kConfigDir}_$name'), logger ?? BufferLogger.test()); return Config.createForTesting(
directory.childFile('.${kConfigDir}_$name'),
logger ?? BufferLogger.test(),
deleteFileOnFormatException: deleteFileOnFormatException
);
} }
/// Test only access to the Config constructor. /// Test only access to the Config constructor.
@visibleForTesting @visibleForTesting
Config.createForTesting(File file, Logger logger) : _file = file, _logger = logger { Config.createForTesting(File file, Logger logger, {bool deleteFileOnFormatException = true}) : _file = file, _logger = logger {
if (!_file.existsSync()) { if (!_file.existsSync()) {
return; return;
} }
...@@ -65,7 +71,10 @@ class Config { ...@@ -65,7 +71,10 @@ class Config {
'You may need to reapply any previously saved configuration ' 'You may need to reapply any previously saved configuration '
'with the "flutter config" command.', 'with the "flutter config" command.',
); );
if (deleteFileOnFormatException) {
_file.deleteSync(); _file.deleteSync();
}
} on Exception catch (err) { } on Exception catch (err) {
_logger _logger
..printError('Could not read preferences in ${file.path}.\n$err') ..printError('Could not read preferences in ${file.path}.\n$err')
......
...@@ -389,3 +389,55 @@ bool isWhitespace(_AnsiRun run) { ...@@ -389,3 +389,55 @@ bool isWhitespace(_AnsiRun run) {
rune == 0x3000 || rune == 0x3000 ||
rune == 0xFEFF; rune == 0xFEFF;
} }
final RegExp _interpolationRegex = RegExp(r'\$\{([^}]*)\}');
/// Given a string that possibly contains string interpolation sequences
/// (so for example, something like `ping -n 1 ${host}`), replace all those
/// interpolation sequences with the matching value given in [replacementValues].
///
/// If the value could not be found inside [replacementValues], an empty
/// string will be substituted instead.
///
/// However, if the dollar sign inside the string is preceded with a backslash,
/// the sequences won't be substituted at all.
///
/// Example:
/// ```dart
/// final interpolated = _interpolateString(r'ping -n 1 ${host}', {'host': 'raspberrypi'});
/// print(interpolated); // will print 'ping -n 1 raspberrypi'
///
/// final interpolated2 = _interpolateString(r'ping -n 1 ${_host}', {'host': 'raspberrypi'});
/// print(interpolated2); // will print 'ping -n 1 '
/// ```
String interpolateString(String toInterpolate, Map<String, String> replacementValues) {
return toInterpolate.replaceAllMapped(_interpolationRegex, (Match match) {
/// The name of the variable to be inserted into the string.
/// Example: If the source string is 'ping -n 1 ${host}',
/// `name` would be 'host'
final String name = match.group(1)!;
return replacementValues.containsKey(name) ? replacementValues[name]! : '';
});
}
/// Given a list of strings possibly containing string interpolation sequences
/// (so for example, something like `['ping', '-n', '1', '${host}']`), replace
/// all those interpolation sequences with the matching value given in [replacementValues].
///
/// If the value could not be found inside [replacementValues], an empty
/// string will be substituted instead.
///
/// However, if the dollar sign inside the string is preceded with a backslash,
/// the sequences won't be substituted at all.
///
/// Example:
/// ```dart
/// final interpolated = _interpolateString(['ping', '-n', '1', r'${host}'], {'host': 'raspberrypi'});
/// print(interpolated); // will print '[ping, -n, 1, raspberrypi]'
///
/// final interpolated2 = _interpolateString(['ping', '-n', '1', r'${_host}'], {'host': 'raspberrypi'});
/// print(interpolated2); // will print '[ping, -n, 1, ]'
/// ```
List<String> interpolateStringList(List<String> toInterpolate, Map<String, String> replacementValues) {
return toInterpolate.map((String s) => interpolateString(s, replacementValues)).toList();
}
...@@ -391,6 +391,9 @@ class DaemonDomain extends Domain { ...@@ -391,6 +391,9 @@ class DaemonDomain extends Domain {
if (featureFlags.isFuchsiaEnabled && flutterProject.fuchsia.existsSync()) { if (featureFlags.isFuchsiaEnabled && flutterProject.fuchsia.existsSync()) {
result.add('fuchsia'); result.add('fuchsia');
} }
if (featureFlags.areCustomDevicesEnabled) {
result.add('custom');
}
return <String, Object>{ return <String, Object>{
'platforms': result, 'platforms': result,
}; };
......
...@@ -30,6 +30,7 @@ import 'base/user_messages.dart'; ...@@ -30,6 +30,7 @@ import 'base/user_messages.dart';
import 'build_info.dart'; import 'build_info.dart';
import 'build_system/build_system.dart'; import 'build_system/build_system.dart';
import 'cache.dart'; import 'cache.dart';
import 'custom_devices/custom_devices_config.dart';
import 'dart/pub.dart'; import 'dart/pub.dart';
import 'devfs.dart'; import 'devfs.dart';
import 'device.dart'; import 'device.dart';
...@@ -197,6 +198,11 @@ Future<T> runInContext<T>( ...@@ -197,6 +198,11 @@ Future<T> runInContext<T>(
), ),
operatingSystemUtils: globals.os, operatingSystemUtils: globals.os,
terminal: globals.terminal, terminal: globals.terminal,
customDevicesConfig: CustomDevicesConfig(
fileSystem: globals.fs,
logger: globals.logger,
platform: globals.platform
),
), ),
DevtoolsLauncher: () => DevtoolsServerLauncher( DevtoolsLauncher: () => DevtoolsServerLauncher(
processManager: globals.processManager, processManager: globals.processManager,
......
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// @dart = 2.8
import 'dart:async';
import 'package:meta/meta.dart';
import 'package:process/process.dart';
import '../application_package.dart';
import '../base/common.dart';
import '../base/io.dart';
import '../base/logger.dart';
import '../base/process.dart';
import '../base/utils.dart';
import '../build_info.dart';
import '../bundle.dart';
import '../convert.dart';
import '../device.dart';
import '../device_port_forwader.dart';
import '../features.dart';
import '../project.dart';
import '../protocol_discovery.dart';
import 'custom_device_config.dart';
import 'custom_device_workflow.dart';
import 'custom_devices_config.dart';
/// Replace all ocurrences of `${someName}` with the value found for that
/// name inside replacementValues or additionalReplacementValues.
///
/// The replacement value is first looked for in [replacementValues] and then
/// [additionalReplacementValues]. If no value is found, an empty string will be
/// substituted instead.
List<String> interpolateCommand(
List<String> command,
Map<String, String> replacementValues, {
Map<String, String> additionalReplacementValues = const <String, String>{}
}) {
return interpolateStringList(
command,
Map<String, String>.of(additionalReplacementValues)
..addAll(replacementValues)
);
}
/// A log reader that can listen to a process' stdout / stderr or another log line
/// Stream.
class CustomDeviceLogReader extends DeviceLogReader {
CustomDeviceLogReader(this.name);
/// The name of the device this log reader is associated with.
@override
final String name;
final StreamController<String> _logLinesController = StreamController<String>.broadcast();
/// Listen to [process]' stdout and stderr, decode them using [SystemEncoding]
/// and add each decoded line to [logLines].
///
/// However, [logLines] will not be done when the [process]' stdout and stderr
/// streams are done. So [logLines] will still be alive after the process has
/// finished.
///
/// See [CustomDeviceLogReader.dispose] to end the [logLines] stream.
void listenToProcessOutput(Process process, {Encoding encoding = systemEncoding}) {
final Converter<List<int>, String> decoder = encoding.decoder;
process.stdout.transform<String>(decoder)
.transform<String>(const LineSplitter())
.listen(_logLinesController.add);
process.stderr.transform<String>(decoder)
.transform<String>(const LineSplitter())
.listen(_logLinesController.add);
}
/// Add all lines emitted by [lines] to this [CustomDeviceLogReader]s [logLines]
/// stream.
///
/// Similiar to [listenToProcessOutput], [logLines] will not be marked as done
/// when the argument stream is done.
///
/// Useful when you want to combine the contents of multiple log readers.
void listenToLinesStream(Stream<String> lines) {
_logLinesController.addStream(lines);
}
/// Dispose this log reader, freeing all associated resources and marking
/// [logLines] as done.
@override
void dispose() {
_logLinesController.close();
}
@override
Stream<String> get logLines => _logLinesController.stream;
}
/// A [DevicePortForwarder] that uses commands to forward / unforward a port.
class CustomDevicePortForwarder extends DevicePortForwarder {
CustomDevicePortForwarder({
@required String deviceName,
@required List<String> forwardPortCommand,
@required RegExp forwardPortSuccessRegex,
this.numTries/*?*/,
@required ProcessManager processManager,
@required Logger logger,
Map<String, String> additionalReplacementValues = const <String, String>{}
}) : _deviceName = deviceName,
_forwardPortCommand = forwardPortCommand,
_forwardPortSuccessRegex = forwardPortSuccessRegex,
_processManager = processManager,
_processUtils = ProcessUtils(
processManager: processManager,
logger: logger
),
_additionalReplacementValues = additionalReplacementValues;
final String _deviceName;
final List<String> _forwardPortCommand;
final RegExp _forwardPortSuccessRegex;
final ProcessManager _processManager;
final ProcessUtils _processUtils;
final int numTries;
final Map<String, String> _additionalReplacementValues;
final List<ForwardedPort> _forwardedPorts = <ForwardedPort>[];
@override
Future<void> dispose() async {
// copy the list so we don't modify it concurrently
return Future.wait(List<ForwardedPort>.of(_forwardedPorts).map(unforward));
}
Future<ForwardedPort> _tryForward(int devicePort, int hostPort) async {
final List<String> interpolated = interpolateCommand(
_forwardPortCommand,
<String, String>{
'devicePort': '$devicePort',
'hostPort': '$hostPort'
},
additionalReplacementValues: _additionalReplacementValues
);
// launch the forwarding command
final Process process = await _processUtils.start(interpolated);
final Completer<ForwardedPort> completer = Completer<ForwardedPort>();
// read the outputs of the process, if we find a line that matches
// the configs forwardPortSuccessRegex, we complete with a successfully
// forwarded port
// Note that if that regex never matches, this will potentially run forever
// and the forwarding will never complete
final CustomDeviceLogReader reader = CustomDeviceLogReader(_deviceName)..listenToProcessOutput(process);
final StreamSubscription<String> logLinesSubscription = reader.logLines.listen((String line) {
if (_forwardPortSuccessRegex.hasMatch(line) && !completer.isCompleted) {
completer.complete(
ForwardedPort.withContext(hostPort, devicePort, process)
);
}
});
// if the process exits (even with exitCode == 0), that is considered
// a port forwarding failure and we complete with a null value.
unawaited(process.exitCode.whenComplete(() {
if (!completer.isCompleted) {
completer.complete(null);
}
}));
unawaited(completer.future.whenComplete(() {
logLinesSubscription.cancel();
reader.dispose();
}));
return completer.future;
}
@override
Future<int> forward(int devicePort, {int hostPort}) async {
int actualHostPort = (hostPort == 0 || hostPort == null) ? devicePort : hostPort;
int tries = 0;
while ((numTries == null) || (tries < numTries)) {
// when the desired host port is already forwarded by this Forwarder,
// choose another one
while (_forwardedPorts.any((ForwardedPort port) => port.hostPort == actualHostPort)) {
actualHostPort += 1;
}
final ForwardedPort port = await _tryForward(devicePort, actualHostPort);
if (port != null) {
_forwardedPorts.add(port);
return actualHostPort;
} else {
// null value means the forwarding failed (for whatever reason)
// increase port by one and try again
actualHostPort += 1;
tries += 1;
}
}
throw ToolExit('Forwarding port for custom device $_deviceName failed after $tries tries.');
}
@override
List<ForwardedPort> get forwardedPorts => List<ForwardedPort>.unmodifiable(_forwardedPorts);
@override
Future<void> unforward(ForwardedPort forwardedPort) async {
assert(_forwardedPorts.contains(forwardedPort));
// since a forwarded port represents a running process launched with
// the forwardPortCommand, unforwarding is as easy as killing the proces
_processManager.killPid(forwardedPort.context.pid);
_forwardedPorts.remove(forwardedPort);
}
}
/// A combination of [ApplicationPackage] and a [CustomDevice]. Can only start,
/// stop this specific app package with this specific device. Useful because we
/// often need to store additional context to an app that is running on a device,
/// like any forwarded ports we need to unforward later, the process we need to
/// kill to stop the app, maybe other things in the future.
class CustomDeviceAppSession {
CustomDeviceAppSession({
@required this.name,
@required CustomDevice device,
@required ApplicationPackage appPackage,
@required Logger logger,
@required ProcessManager processManager
}) : _appPackage = appPackage,
_device = device,
_logger = logger,
_processManager = processManager,
logReader = CustomDeviceLogReader(name);
final String name;
final CustomDevice _device;
final ApplicationPackage _appPackage;
final Logger _logger;
final ProcessManager _processManager;
final CustomDeviceLogReader logReader;
Process _process;
int _forwardedHostPort;
Future<LaunchResult> start({
String mainPath,
String route,
DebuggingOptions debuggingOptions,
Map<String, dynamic> platformArgs,
bool prebuiltApplication = false,
bool ipv6 = false,
String userIdentifier
}) async {
final List<String> interpolated = interpolateCommand(
_device._config.runDebugCommand,
<String, String>{
'remotePath': '/tmp/',
'appName': _appPackage.name
}
);
final Process process = await _processManager.start(interpolated);
assert(_process == null);
_process = process;
final ProtocolDiscovery discovery = ProtocolDiscovery.observatory(
logReader,
portForwarder: _device._config.usesPortForwarding ? _device.portForwarder : null,
hostPort: null, devicePort: null,
logger: _logger,
ipv6: ipv6,
);
// We need to make the discovery listen to the logReader before the logReader
// listens to the process output since logReader.lines is a broadcast stream
// and events may be discarded.
// Whether that actually happens is another thing since this is all executed
// in the same microtask AFAICT but this way we're on the safe side.
logReader.listenToProcessOutput(process);
final Uri observatoryUri = await discovery.uri;
await discovery.cancel();
if (_device._config.usesPortForwarding) {
_forwardedHostPort = observatoryUri.port;
}
return LaunchResult.succeeded(observatoryUri: observatoryUri);
}
void _maybeUnforwardPort() {
if (_forwardedHostPort != null) {
final ForwardedPort forwardedPort = _device.portForwarder.forwardedPorts.singleWhere((ForwardedPort forwardedPort) {
return forwardedPort.hostPort == _forwardedHostPort;
});
_forwardedHostPort = null;
_device.portForwarder.unforward(forwardedPort);
}
}
Future<bool> stop() async {
if (_process == null) {
return false;
}
_maybeUnforwardPort();
final bool result = _processManager.killPid(_process.pid);
_process = null;
return result;
}
void dispose() {
if (_process != null) {
_maybeUnforwardPort();
_processManager.killPid(_process.pid);
_process = null;
}
logReader.dispose();
}
}
/// A device that uses user-configured actions for the common device methods.
/// The exact actions are defined by the contents of the [CustomDeviceConfig]
/// used to construct it.
class CustomDevice extends Device {
CustomDevice({
@required CustomDeviceConfig config,
@required Logger logger,
@required ProcessManager processManager,
}) : _config = config,
_logger = logger,
_processManager = processManager,
_processUtils = ProcessUtils(
processManager: processManager,
logger: logger
),
_globalLogReader = CustomDeviceLogReader(config.label),
portForwarder = config.usesPortForwarding ?
CustomDevicePortForwarder(
deviceName: config.label,
forwardPortCommand: config.forwardPortCommand,
forwardPortSuccessRegex: config.forwardPortSuccessRegex,
processManager: processManager,
logger: logger,
) : const NoOpDevicePortForwarder(),
super(
config.id,
category: Category.mobile,
ephemeral: true,
platformType: PlatformType.custom
);
final CustomDeviceConfig _config;
final Logger _logger;
final ProcessManager _processManager;
final ProcessUtils _processUtils;
final Map<ApplicationPackage, CustomDeviceAppSession> _sessions = <ApplicationPackage, CustomDeviceAppSession>{};
final CustomDeviceLogReader _globalLogReader;
@override
final DevicePortForwarder portForwarder;
CustomDeviceAppSession _getOrCreateAppSession(covariant ApplicationPackage app) {
return _sessions.putIfAbsent(
app,
() {
/// create a new session and add its logging to the global log reader.
/// (needed bc it's possible the infra requests a global log in [getLogReader]
final CustomDeviceAppSession session = CustomDeviceAppSession(
name: name,
device: this,
appPackage: app,
logger: _logger,
processManager: _processManager
);
_globalLogReader.listenToLinesStream(session.logReader.logLines);
return session;
}
);
}
/// Tries to ping the device using the ping command given in the config.
/// All string interpolation occurrences inside the ping command will be replaced
/// using the entries in [replacementValues].
///
/// If the process finishes with an exit code != 0, false will be returned and
/// the error (with the process' stdout and stderr) will be logged using
/// [_logger.printError].
///
/// If [timeout] is not null and the process doesn't finish in time,
/// it will be killed with a SIGTERM, false will be returned and the timeout
/// will be reported in the log using [_logger.printError]. If [timeout]
/// is null, it's treated as if it's an infinite timeout.
Future<bool> _tryPing({
Duration timeout,
Map<String, String> replacementValues = const <String, String>{}
}) async {
final List<String> interpolated = interpolateCommand(
_config.pingCommand,
replacementValues
);
try {
final RunResult result = await _processUtils.run(
interpolated,
throwOnError: true,
timeout: timeout
);
// If the user doesn't configure a ping success regex, any ping with exitCode zero
// is good enough. Otherwise we check if either stdout or stderr have a match of
// the pingSuccessRegex.
return _config.pingSuccessRegex == null
|| _config.pingSuccessRegex.hasMatch(result.stdout)
|| _config.pingSuccessRegex.hasMatch(result.stderr);
} on ProcessException catch (e) {
_logger.printError('Error executing ping command for custom device $id: $e');
return false;
}
}
/// Tries to execute the configs postBuild command using [appName] for the
/// `${appName}` and [localPath] for the `${localPath}` interpolations,
/// any additional string interpolation occurrences will be replaced using the
/// entries in [additionalReplacementValues].
///
/// Calling this when the config doesn't have a configured postBuild command
/// is an error.
///
/// If [timeout] is not null and the process doesn't finish in time, it
/// will be killed with a SIGTERM, false will be returned and the timeout
/// will be reported in the log using [_logger.printError]. If [timeout]
/// is null, it's treated as if it's an infinite timeout.
Future<bool> _tryPostBuild({
@required String appName,
@required String localPath,
Duration timeout,
Map<String, String> additionalReplacementValues = const <String, String>{}
}) async {
assert(_config.postBuildCommand != null);
final List<String> interpolated = interpolateCommand(
_config.postBuildCommand,
<String, String>{
'appName': appName,
'localPath': localPath
},
additionalReplacementValues: additionalReplacementValues
);
try {
await _processUtils.run(
interpolated,
throwOnError: true,
timeout: timeout
);
return true;
} on ProcessException catch (e) {
_logger.printError('Error executing postBuild command for custom device $id: $e');
return false;
}
}
/// Tries to execute the configs uninstall command.
///
/// [appName] is the name of the app to be installed.
///
/// If [timeout] is not null and the process doesn't finish in time, it
/// will be killed with a SIGTERM, false will be returned and the timeout
/// will be reported in the log using [_logger.printError]. If [timeout]
/// is null, it's treated as if it's an infinite timeout.
Future<bool> _tryUninstall({
@required String appName,
Duration timeout,
Map<String, String> additionalReplacementValues = const <String, String>{}
}) async {
final List<String> interpolated = interpolateCommand(
_config.uninstallCommand,
<String, String>{
'appName': appName
},
additionalReplacementValues: additionalReplacementValues
);
try {
await _processUtils.run(
interpolated,
throwOnError: true,
timeout: timeout
);
return true;
} on ProcessException catch (e) {
_logger.printError('Error executing uninstall command for custom device $id: $e');
return false;
}
}
/// Tries to install the app to the custom device.
///
/// [localPath] is the file / directory on the local device that will be
/// copied over to the target custom device. This is substituted for any occurrence
/// of `${localPath}` in the custom device configs `install` command.
///
/// [appName] is the name of the app to be installed. Substituted for any occurrence
/// of `${appName}` in the custom device configs `install` command.
Future<bool> _tryInstall({
@required String localPath,
@required String appName,
Duration timeout,
Map<String, String> additionalReplacementValues = const <String, String>{}
}) async {
final List<String> interpolated = interpolateCommand(
_config.installCommand,
<String, String>{
'localPath': localPath,
'appName': appName
},
additionalReplacementValues: additionalReplacementValues
);
try {
await _processUtils.run(
interpolated,
throwOnError: true,
timeout: timeout
);
return true;
} on ProcessException catch (e) {
_logger.printError('Error executing install command for custom device $id: $e');
return false;
}
}
@override
void clearLogs() {}
@override
Future<void> dispose() async {
_sessions
..forEach((_, CustomDeviceAppSession session) => session.dispose())
..clear();
}
@override
Future<String> get emulatorId async => null;
@override
FutureOr<DeviceLogReader> getLogReader({
covariant ApplicationPackage app,
bool includePastLogs = false
}) {
if (app != null) {
return _getOrCreateAppSession(app).logReader;
}
return _globalLogReader;
}
@override
Future<bool> installApp(covariant ApplicationPackage app, {String userIdentifier}) async {
if (!await _tryUninstall(appName: app.name)) {
return false;
}
final bool result = await _tryInstall(
localPath: getAssetBuildDirectory(),
appName: app.name
);
return result;
}
@override
Future<bool> isAppInstalled(covariant ApplicationPackage app, {String userIdentifier}) async {
return false;
}
@override
Future<bool> isLatestBuildInstalled(covariant ApplicationPackage app) async {
return false;
}
@override
Future<bool> get isLocalEmulator async => false;
@override
bool isSupported() {
return true;
}
@override
bool isSupportedForProject(FlutterProject flutterProject) {
return true;
}
@override
FutureOr<bool> supportsRuntimeMode(BuildMode buildMode) {
return buildMode == BuildMode.debug;
}
@override
String get name => _config.label;
@override
Future<String> get sdkNameAndVersion => Future<String>.value(_config.sdkNameAndVersion);
@override
Future<LaunchResult> startApp(
covariant ApplicationPackage package, {
String mainPath,
String route,
DebuggingOptions debuggingOptions,
Map<String, dynamic> platformArgs,
bool prebuiltApplication = false,
bool ipv6 = false,
String userIdentifier,
BundleBuilder bundleBuilder
}) async {
if (!prebuiltApplication) {
final String assetBundleDir = getAssetBuildDirectory();
bundleBuilder ??= BundleBuilder();
// this just builds the asset bundle, it's the same as `flutter build bundle`
await bundleBuilder.build(
platform: await targetPlatform,
buildInfo: debuggingOptions.buildInfo,
mainPath: mainPath,
depfilePath: defaultDepfilePath,
assetDirPath: assetBundleDir,
treeShakeIcons: false,
);
// if we have a post build step (needed for some embedders), execute it
if (_config.postBuildCommand != null) {
await _tryPostBuild(
appName: package.name,
localPath: assetBundleDir,
);
}
}
// install the app on the device
// (will invoke the uninstall and then the install command internally)
await installApp(package, userIdentifier: userIdentifier);
// finally launch the app
return _getOrCreateAppSession(package).start(
mainPath: mainPath,
route: route,
debuggingOptions: debuggingOptions,
platformArgs: platformArgs,
prebuiltApplication: prebuiltApplication,
ipv6: ipv6,
userIdentifier: userIdentifier,
);
}
@override
Future<bool> stopApp(covariant ApplicationPackage app, {String userIdentifier}) {
return _getOrCreateAppSession(app).stop();
}
@override
// TODO(ardera): Allow configuring or auto-detecting the target platform, https://github.com/flutter/flutter/issues/78151
Future<TargetPlatform> get targetPlatform async => TargetPlatform.linux_arm64;
@override
Future<bool> uninstallApp(covariant ApplicationPackage app, {String userIdentifier}) {
return _tryUninstall(appName: app.name);
}
}
/// A [PollingDeviceDiscovery] that'll try to ping all enabled devices in the argument
/// [CustomDevicesConfig] and report the ones that were actually reachable.
class CustomDevices extends PollingDeviceDiscovery {
/// Create a custom device discovery that pings all enabled devices in the
/// given [CustomDevicesConfig].
CustomDevices({
@required FeatureFlags featureFlags,
@required ProcessManager processManager,
@required Logger logger,
@required CustomDevicesConfig config
}) : _customDeviceWorkflow = CustomDeviceWorkflow(
featureFlags: featureFlags,
),
_logger = logger,
_processManager = processManager,
_config = config,
super('custom devices');
final CustomDeviceWorkflow _customDeviceWorkflow;
final ProcessManager _processManager;
final Logger _logger;
final CustomDevicesConfig _config;
@override
bool get supportsPlatform => true;
@override
bool get canListAnything => _customDeviceWorkflow.canListDevices;
CustomDevicesConfig get _customDevicesConfig => _config;
List<CustomDevice> get enabledCustomDevices {
return _customDevicesConfig.devices
.where((CustomDeviceConfig element) => !element.disabled)
.map(
(CustomDeviceConfig config) => CustomDevice(
config: config,
logger: _logger,
processManager: _processManager
)
).toList();
}
@override
Future<List<Device>> pollingGetDevices({Duration timeout}) async {
if (!canListAnything) {
return const <Device>[];
}
final List<CustomDevice> devices = enabledCustomDevices;
// maps any custom device to whether its reachable or not.
final Map<CustomDevice, bool> pingedDevices = Map<CustomDevice, bool>.fromIterables(
devices,
await Future.wait(devices.map((CustomDevice e) => e._tryPing(timeout: timeout)))
);
// remove all the devices we couldn't reach.
pingedDevices.removeWhere((_, bool value) => value == false);
// return only the devices.
return pingedDevices.keys.toList();
}
@override
Future<List<String>> getDiagnostics() async => const <String>[];
}
// Copyright 2014 The Flutter 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 'package:meta/meta.dart';
/// A single configured custom device.
///
/// In the custom devices config file on disk, there may be multiple custom
/// devices configured.
@immutable
class CustomDeviceConfig {
const CustomDeviceConfig({
required this.id,
required this.label,
required this.sdkNameAndVersion,
required this.disabled,
required this.pingCommand,
this.pingSuccessRegex,
required this.postBuildCommand,
required this.installCommand,
required this.uninstallCommand,
required this.runDebugCommand,
this.forwardPortCommand,
this.forwardPortSuccessRegex
}) : assert(forwardPortCommand == null || forwardPortSuccessRegex != null);
factory CustomDeviceConfig.fromJson(dynamic json) {
final Map<String, Object> typedMap = (json as Map<dynamic, dynamic>).cast<String, Object>();
return CustomDeviceConfig(
id: typedMap[_kId]! as String,
label: typedMap[_kLabel]! as String,
sdkNameAndVersion: typedMap[_kSdkNameAndVersion]! as String,
disabled: typedMap[_kDisabled]! as bool,
pingCommand: _castStringList(typedMap[_kPingCommand]!),
pingSuccessRegex: _convertToRegexOrNull(typedMap[_kPingSuccessRegex]),
postBuildCommand: _castStringListOrNull(typedMap[_kPostBuildCommand]),
installCommand: _castStringList(typedMap[_kInstallCommand]!),
uninstallCommand: _castStringList(typedMap[_kUninstallCommand]!),
runDebugCommand: _castStringList(typedMap[_kRunDebugCommand]!),
forwardPortCommand: _castStringListOrNull(typedMap[_kForwardPortCommand]),
forwardPortSuccessRegex: _convertToRegexOrNull(typedMap[_kForwardPortSuccessRegex])
);
}
static const String _kId = 'id';
static const String _kLabel = 'label';
static const String _kSdkNameAndVersion = 'sdkNameAndVersion';
static const String _kDisabled = 'disabled';
static const String _kPingCommand = 'ping';
static const String _kPingSuccessRegex = 'pingSuccessRegex';
static const String _kPostBuildCommand = 'postBuild';
static const String _kInstallCommand = 'install';
static const String _kUninstallCommand = 'uninstall';
static const String _kRunDebugCommand = 'runDebug';
static const String _kForwardPortCommand = 'forwardPort';
static const String _kForwardPortSuccessRegex = 'forwardPortSuccessRegex';
/// An example device config used for creating the default config file.
static final CustomDeviceConfig example = CustomDeviceConfig(
id: 'test1',
label: 'Test Device',
sdkNameAndVersion: 'Test Device 4 Model B+',
disabled: true,
pingCommand: const <String>['ping', '-w', '500', '-n', '1', 'raspberrypi'],
pingSuccessRegex: RegExp('ms TTL='),
postBuildCommand: null,
installCommand: const <String>['scp', '-r', r'${localPath}', r'pi@raspberrypi:/tmp/${appName}'],
uninstallCommand: const <String>['ssh', 'pi@raspberrypi', r'rm -rf "/tmp/${appName}"'],
runDebugCommand: const <String>['ssh', 'pi@raspberrypi', r'flutter-pi "/tmp/${appName}"'],
forwardPortCommand: const <String>['ssh', '-o', 'ExitOnForwardFailure=yes', '-L', r'127.0.0.1:${hostPort}:127.0.0.1:${devicePort}', 'pi@raspberrypi'],
forwardPortSuccessRegex: RegExp('Linux')
);
final String id;
final String label;
final String sdkNameAndVersion;
final bool disabled;
final List<String> pingCommand;
final RegExp? pingSuccessRegex;
final List<String>? postBuildCommand;
final List<String> installCommand;
final List<String> uninstallCommand;
final List<String> runDebugCommand;
final List<String>? forwardPortCommand;
final RegExp? forwardPortSuccessRegex;
bool get usesPortForwarding => forwardPortCommand != null;
static List<String> _castStringList(Object object) {
return (object as List<dynamic>).cast<String>();
}
static List<String>? _castStringListOrNull(Object? object) {
return object == null ? null : _castStringList(object);
}
static RegExp? _convertToRegexOrNull(Object? object) {
return object == null ? null : RegExp(object as String);
}
dynamic toJson() {
return <String, Object?>{
_kId: id,
_kLabel: label,
_kSdkNameAndVersion: sdkNameAndVersion,
_kDisabled: disabled,
_kPingCommand: pingCommand,
_kPingSuccessRegex: pingSuccessRegex?.pattern,
_kPostBuildCommand: postBuildCommand,
_kInstallCommand: installCommand,
_kUninstallCommand: uninstallCommand,
_kRunDebugCommand: runDebugCommand,
_kForwardPortCommand: forwardPortCommand,
_kForwardPortSuccessRegex: forwardPortSuccessRegex?.pattern
};
}
CustomDeviceConfig copyWith({
String? id,
String? label,
String? sdkNameAndVersion,
bool? disabled,
List<String>? pingCommand,
bool explicitPingSuccessRegex = false,
RegExp? pingSuccessRegex,
bool explicitPostBuildCommand = false,
List<String>? postBuildCommand,
List<String>? installCommand,
List<String>? uninstallCommand,
List<String>? runDebugCommand,
bool explicitForwardPortCommand = false,
List<String>? forwardPortCommand,
bool explicitForwardPortSuccessRegex = false,
RegExp? forwardPortSuccessRegex
}) {
return CustomDeviceConfig(
id: id ?? this.id,
label: label ?? this.label,
sdkNameAndVersion: sdkNameAndVersion ?? this.sdkNameAndVersion,
disabled: disabled ?? this.disabled,
pingCommand: pingCommand ?? this.pingCommand,
pingSuccessRegex: explicitPingSuccessRegex ? pingSuccessRegex : (pingSuccessRegex ?? this.pingSuccessRegex),
postBuildCommand: explicitPostBuildCommand ? postBuildCommand : (postBuildCommand ?? this.postBuildCommand),
installCommand: installCommand ?? this.installCommand,
uninstallCommand: uninstallCommand ?? this.uninstallCommand,
runDebugCommand: runDebugCommand ?? this.runDebugCommand,
forwardPortCommand: explicitForwardPortCommand ? forwardPortCommand : (forwardPortCommand ?? this.forwardPortCommand),
forwardPortSuccessRegex: explicitForwardPortSuccessRegex ? forwardPortSuccessRegex : (forwardPortSuccessRegex ?? this.forwardPortSuccessRegex)
);
}
}
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// @dart = 2.8
import 'package:meta/meta.dart';
import '../doctor.dart';
import '../features.dart';
/// The custom-devices-specific implementation of a [Workflow].
///
/// Will apply to the host platform / be able to launch & list devices only if
/// the custom devices feature is enabled in the featureFlags argument.
///
/// Can't list emulators at all.
@immutable
class CustomDeviceWorkflow implements Workflow {
const CustomDeviceWorkflow({
@required FeatureFlags featureFlags
}) : _featureFlags = featureFlags;
final FeatureFlags _featureFlags;
@override
bool get appliesToHostPlatform => _featureFlags.areCustomDevicesEnabled;
@override
bool get canLaunchDevices => _featureFlags.areCustomDevicesEnabled;
@override
bool get canListDevices => _featureFlags.areCustomDevicesEnabled;
@override
bool get canListEmulators => false;
}
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// @dart = 2.8
import 'package:meta/meta.dart';
import '../base/config.dart';
import '../base/file_system.dart';
import '../base/logger.dart';
import '../base/platform.dart';
import '../cache.dart';
import 'custom_device_config.dart';
/// Represents the custom devices config file on disk which in turn
/// contains a list of individual custom device configs.
class CustomDevicesConfig {
/// Load a [CustomDevicesConfig] from a (possibly non-existent) location on disk.
///
/// The config is loaded on construction. Any error while loading will be logged
/// but will not result in an exception being thrown. The file will not be deleted
/// when it's not valid JSON (which other configurations do) and will not
/// be implicitly created when it doesn't exist.
CustomDevicesConfig({
@required FileSystem fileSystem,
@required Logger logger,
@required Platform platform
}) : _fileSystem = fileSystem,
_config = Config(
_kCustomDevicesConfigName,
fileSystem: fileSystem,
logger: logger,
platform: platform,
deleteFileOnFormatException: false
)
{
ensureFileExists();
}
@visibleForTesting
CustomDevicesConfig.test({
@required FileSystem fileSystem,
Directory directory,
@required Logger logger
}) : _fileSystem = fileSystem,
_config = Config.test(
name: _kCustomDevicesConfigName,
directory: directory,
logger: logger,
deleteFileOnFormatException: false
)
{
ensureFileExists();
}
static const String _kCustomDevicesConfigName = 'custom_devices.json';
static const String _kCustomDevicesConfigKey = 'custom-devices';
static const String _kSchema = r'$schema';
static const String _kCustomDevices = 'custom-devices';
final FileSystem _fileSystem;
final Config _config;
String get _defaultSchema {
final Uri uri = _fileSystem
.directory(Cache.flutterRoot)
.childDirectory('packages')
.childDirectory('flutter_tools')
.childDirectory('static')
.childFile('custom-devices.schema.json')
.uri;
// otherwise it won't contain the Uri schema, so the file:// at the start
// will be missing
assert(uri.isAbsolute);
return uri.toString();
}
/// Ensure the config file exists on disk by creating one with default values
/// if it doesn't exist yet.
///
/// The config file should always be present so we can give the user a path
/// to a file they can edit.
void ensureFileExists() {
if (!_fileSystem.file(_config.configPath).existsSync()) {
_config.setValue(_kSchema, _defaultSchema);
_config.setValue(_kCustomDevices, <dynamic>[CustomDeviceConfig.example.toJson()]);
}
}
/// Get the list of [CustomDeviceConfig]s that are listed in the config file
/// including disabled ones.
///
/// Returns an empty list when the config could not be loaded.
List<CustomDeviceConfig> get devices {
final dynamic json = _config.getValue(_kCustomDevicesConfigKey);
if (json == null) {
return <CustomDeviceConfig>[];
}
final List<dynamic> typedList = json as List<dynamic>;
return typedList.map((dynamic e) => CustomDeviceConfig.fromJson(e)).toList();
}
// We don't have a setter for devices here because we don't need it and
// it also may overwrite any things done by the user that aren't explicitly
// tracked by the JSON-representation. For example comments (not possible right now,
// but they'd be useful so maybe in the future) or formatting.
}
...@@ -52,6 +52,7 @@ class PlatformType { ...@@ -52,6 +52,7 @@ class PlatformType {
static const PlatformType macos = PlatformType._('macos'); static const PlatformType macos = PlatformType._('macos');
static const PlatformType windows = PlatformType._('windows'); static const PlatformType windows = PlatformType._('windows');
static const PlatformType fuchsia = PlatformType._('fuchsia'); static const PlatformType fuchsia = PlatformType._('fuchsia');
static const PlatformType custom = PlatformType._('custom');
final String value; final String value;
......
...@@ -48,6 +48,9 @@ abstract class FeatureFlags { ...@@ -48,6 +48,9 @@ abstract class FeatureFlags {
/// Whether fuchsia is enabled. /// Whether fuchsia is enabled.
bool get isFuchsiaEnabled => true; bool get isFuchsiaEnabled => true;
/// Whether custom devices are enabled.
bool get areCustomDevicesEnabled => false;
/// Whether fast single widget reloads are enabled. /// Whether fast single widget reloads are enabled.
bool get isSingleWidgetReloadEnabled => false; bool get isSingleWidgetReloadEnabled => false;
...@@ -97,6 +100,9 @@ class FlutterFeatureFlags implements FeatureFlags { ...@@ -97,6 +100,9 @@ class FlutterFeatureFlags implements FeatureFlags {
@override @override
bool get isFuchsiaEnabled => isEnabled(flutterFuchsiaFeature); bool get isFuchsiaEnabled => isEnabled(flutterFuchsiaFeature);
@override
bool get areCustomDevicesEnabled => isEnabled(flutterCustomDevicesFeature);
@override @override
bool get isSingleWidgetReloadEnabled => isEnabled(singleWidgetReload); bool get isSingleWidgetReloadEnabled => isEnabled(singleWidgetReload);
...@@ -140,6 +146,7 @@ const List<Feature> allFeatures = <Feature>[ ...@@ -140,6 +146,7 @@ const List<Feature> allFeatures = <Feature>[
flutterAndroidFeature, flutterAndroidFeature,
flutterIOSFeature, flutterIOSFeature,
flutterFuchsiaFeature, flutterFuchsiaFeature,
flutterCustomDevicesFeature,
experimentalInvalidationStrategy, experimentalInvalidationStrategy,
]; ];
...@@ -297,6 +304,28 @@ const Feature flutterFuchsiaFeature = Feature( ...@@ -297,6 +304,28 @@ const Feature flutterFuchsiaFeature = Feature(
), ),
); );
const Feature flutterCustomDevicesFeature = Feature(
name: 'Early support for custom device types',
configSetting: 'enable-custom-devices',
environmentOverride: 'FLUTTER_CUSTOM_DEVICES',
master: FeatureChannelSetting(
available: true,
enabledByDefault: false,
),
dev: FeatureChannelSetting(
available: true,
enabledByDefault: false,
),
beta: FeatureChannelSetting(
available: false,
enabledByDefault: false,
),
stable: FeatureChannelSetting(
available: false,
enabledByDefault: false,
)
);
/// The fast hot reload feature for https://github.com/flutter/flutter/issues/61407. /// The fast hot reload feature for https://github.com/flutter/flutter/issues/61407.
const Feature singleWidgetReload = Feature( const Feature singleWidgetReload = Feature(
name: 'Hot reload optimization for changes to class body of a single widget', name: 'Hot reload optimization for changes to class body of a single widget',
...@@ -371,7 +400,7 @@ class Feature { ...@@ -371,7 +400,7 @@ class Feature {
this.master = const FeatureChannelSetting(), this.master = const FeatureChannelSetting(),
this.dev = const FeatureChannelSetting(), this.dev = const FeatureChannelSetting(),
this.beta = const FeatureChannelSetting(), this.beta = const FeatureChannelSetting(),
this.stable = const FeatureChannelSetting(), this.stable = const FeatureChannelSetting()
}); });
/// The user visible name for this feature. /// The user visible name for this feature.
......
...@@ -18,6 +18,8 @@ import 'base/os.dart'; ...@@ -18,6 +18,8 @@ import 'base/os.dart';
import 'base/platform.dart'; import 'base/platform.dart';
import 'base/terminal.dart'; import 'base/terminal.dart';
import 'base/user_messages.dart' hide userMessages; import 'base/user_messages.dart' hide userMessages;
import 'custom_devices/custom_device.dart';
import 'custom_devices/custom_devices_config.dart';
import 'device.dart'; import 'device.dart';
import 'features.dart'; import 'features.dart';
import 'fuchsia/fuchsia_device.dart'; import 'fuchsia/fuchsia_device.dart';
...@@ -58,6 +60,7 @@ class FlutterDeviceManager extends DeviceManager { ...@@ -58,6 +60,7 @@ class FlutterDeviceManager extends DeviceManager {
@required OperatingSystemUtils operatingSystemUtils, @required OperatingSystemUtils operatingSystemUtils,
@required WindowsWorkflow windowsWorkflow, @required WindowsWorkflow windowsWorkflow,
@required Terminal terminal, @required Terminal terminal,
@required CustomDevicesConfig customDevicesConfig,
}) : deviceDiscoverers = <DeviceDiscovery>[ }) : deviceDiscoverers = <DeviceDiscovery>[
AndroidDevices( AndroidDevices(
logger: logger, logger: logger,
...@@ -123,6 +126,12 @@ class FlutterDeviceManager extends DeviceManager { ...@@ -123,6 +126,12 @@ class FlutterDeviceManager extends DeviceManager {
processManager: processManager, processManager: processManager,
logger: logger, logger: logger,
), ),
CustomDevices(
featureFlags: featureFlags,
processManager: processManager,
logger: logger,
config: customDevicesConfig
),
], super( ], super(
logger: logger, logger: logger,
terminal: terminal, terminal: terminal,
......
...@@ -149,8 +149,7 @@ class ProtocolDiscovery { ...@@ -149,8 +149,7 @@ class ProtocolDiscovery {
hostUri = deviceUri.replace(port: actualHostPort); hostUri = deviceUri.replace(port: actualHostPort);
} }
assert(InternetAddress(hostUri.host).isLoopback); if (InternetAddress(hostUri.host).isLoopback && ipv6) {
if (ipv6) {
hostUri = hostUri.replace(host: InternetAddress.loopbackIPv6.host); hostUri = hostUri.replace(host: InternetAddress.loopbackIPv6.host);
} }
return hostUri; return hostUri;
......
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://raw.githubusercontent.com/flutter/flutter/stable/packages/flutter_tools/static/custom-devices.schema.json",
"title": "Flutter Custom Devices",
"description": "The schema for the flutter custom devices config file.",
"type": "object",
"properties": {
"custom-devices": {
"description": "The actual list of custom devices.",
"type": "array",
"items": {
"description": "A single custom device to be configured.",
"type": "object",
"properties": {
"id": {
"description": "A unique, short identification string for this device. Used for example as an argument to the flutter run command.",
"type": "string"
},
"label": {
"description": "A more descriptive, user-friendly label for the device.",
"type": "string"
},
"sdkNameAndVersion": {
"description": "Additional information about the device. For other devices, this is the SDK (for example Android SDK, Windows SDK) name and version.",
"type": "string"
},
"disabled": {
"description": "If true, this device will be ignored completely by the flutter SDK and none of the commands configured will be called. You can use this as a way to comment out device configs you're still working on, for example.",
"type": "boolean"
},
"ping": {
"description": "The command to be invoked to ping the device. Used to find out whether its available or not. Every exit code unequal 0 will be treated as \"unavailable\". On Windows, consider providing a \"pingSuccessRegex\" since Windows' ping will also return 0 on failure. Make sure the command times out internally since it's not guaranteed the flutter SDK will enforce a timeout itself.",
"type": "array",
"items": {
"type": "string"
},
"minItems": 1,
"default": [
"ping", "-w", "500", "-n", "1", "raspberrypi"
]
},
"pingSuccessRegex": {
"description": "When the output of the ping command matches this regex (and the ping command finished with exit code 0), the ping will be considered successful and the pinged device reachable. If this regex is not provided the ping command will be considered successful when it returned with exit code 0. The regex syntax is the standard dart syntax.",
"type": ["string", "null"],
"format": "regex",
"default": "ms TTL=",
"required": false
},
"postBuild": {
"description": "The command to be invoked after the build process is done, to do any additional packaging for example.",
"type": ["array", "null"],
"items": {
"type": "string"
},
"minItems": 1,
"default": null,
"required": false
},
"install": {
"description": "The command to be invoked to install the app on the device. The path to the directory / file to be installed (copied over) to the device is available via the ${localPath} string interpolation and the name of the app to be installed via the ${appName} string interpolation.",
"type": "array",
"items": {
"type": "string"
},
"minItems": 1,
"default": [
"scp", "-r", "${localPath}", "pi@raspberrypi:/tmp/${appName}"
]
},
"uninstall": {
"description": "The command to be invoked to remove the app from the device. Invoked before every invocation of the app install command. The name of the app to be removed is available via the ${appName} string interpolation.",
"type": "array",
"items": {
"type": "string"
},
"minItems": 1,
"default": [
"ssh", "pi@raspberrypi", "rm -rf \"/tmp/${appName}\""
]
},
"runDebug": {
"description": "The command to be invoked to run the app in debug mode. The name of the app to be started is available via the ${appName} string interpolation. Make sure the flutter cmdline output is available via this commands stdout/stderr since the SDK needs the \"Observatory is now listening on ...\" message to function. If the forwardPort command is not specified, the observatory URL will be connected to as-is, without any port forwarding. In that case you need to make sure it is reachable from your host device, possibly via the \"--observatory-host=<ip>\" engine flag.",
"type": "array",
"items": {
"type": "string"
},
"minItems": 1,
"default": [
"ssh", "pi@raspberrypi", "flutter-pi /tmp/${appName} --observatory-host=192.168.178.123"
]
},
"forwardPort": {
"description": "The command to be invoked to forward a specific device port to a port on the host device. The host port is available via ${hostPort} and the device port via ${devicePort}. On success, the command should stay running for the duration of the forwarding. The command will be terminated using SIGTERM when the forwarding should be stopped. When using ssh, make sure ssh quits when the forwarding fails since thats not the default behaviour.",
"type": ["array", "null"],
"items": {
"type": "string"
},
"minItems": 1,
"default": [
"ssh", "-o", "ExitOnForwardFailure=yes", "-L", "127.0.0.1:${hostPort}:127.0.0.1:${devicePort}", "pi@raspberrypi"
],
"required": false
},
"forwardPortSuccessRegex": {
"description": "A regular expression to be used to classify a successful port forwarding. As soon as any line of stdout or stderr of the forward port command matches this regex, the port forwarding is considered successful. The regex syntax is the standard dart syntax. This value needs to be present & non-null when \"forwardPort\" specified.",
"type": ["string", "null"],
"format": "regex",
"default": "Linux",
"required": false
}
}
}
}
}
}
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// @dart = 2.8
import 'dart:async';
import 'package:file/src/interface/directory.dart';
import 'package:file/src/interface/file.dart';
import 'package:flutter_tools/src/base/logger.dart';
import 'package:flutter_tools/src/build_info.dart';
import 'package:flutter_tools/src/convert.dart';
import 'package:flutter_tools/src/custom_devices/custom_device.dart';
import 'package:flutter_tools/src/custom_devices/custom_device_config.dart';
import 'package:flutter_tools/src/custom_devices/custom_devices_config.dart';
import 'package:flutter_tools/src/device.dart';
import 'package:flutter_tools/src/linux/application_package.dart';
import 'package:flutter_tools/src/globals.dart' as globals;
import 'package:file/memory.dart';
import 'package:file/file.dart';
import 'package:flutter_tools/src/project.dart';
import '../../src/common.dart';
import '../../src/context.dart';
import '../../src/fake_process_manager.dart';
import '../../src/fakes.dart';
void _writeCustomDevicesConfigFile(Directory dir, List<CustomDeviceConfig> configs) {
dir.createSync();
final File file = dir.childFile('.flutter_custom_devices.json');
file.writeAsStringSync(jsonEncode(
<String, dynamic>{
'custom-devices': configs.map<dynamic>((CustomDeviceConfig c) => c.toJson()).toList()
}
));
}
FlutterProject _setUpFlutterProject(Directory directory) {
final FlutterProjectFactory flutterProjectFactory = FlutterProjectFactory(
fileSystem: directory.fileSystem,
logger: BufferLogger.test(),
);
return flutterProjectFactory.fromDirectory(directory);
}
void main() {
testWithoutContext('replacing string interpolation occurrences in custom device commands', () async {
expect(
interpolateCommand(
<String>['scp', r'${localPath}', r'/tmp/${appName}', 'pi@raspberrypi'],
<String, String>{
'localPath': 'build/flutter_assets',
'appName': 'hello_world'
}
),
<String>[
'scp', 'build/flutter_assets', '/tmp/hello_world', 'pi@raspberrypi'
]
);
expect(
interpolateCommand(
<String>[r'${test1}', r' ${test2}', r'${test3}'],
<String, String>{
'test1': '_test1',
'test2': '_test2'
}
),
<String>[
'_test1', ' _test2', r''
]
);
expect(
interpolateCommand(
<String>[r'${test1}', r' ${test2}', r'${test3}'],
<String, String>{
'test1': '_test1',
'test2': '_test2'
},
additionalReplacementValues: <String, String>{
'test2': '_nottest2',
'test3': '_test3'
}
),
<String>[
'_test1', ' _test2', r'_test3'
]
);
});
final CustomDeviceConfig testConfig = CustomDeviceConfig(
id: 'testid',
label: 'testlabel',
sdkNameAndVersion: 'testsdknameandversion',
disabled: false,
pingCommand: const <String>['testping'],
pingSuccessRegex: RegExp('testpingsuccess'),
postBuildCommand: const <String>['testpostbuild'],
installCommand: const <String>['testinstall'],
uninstallCommand: const <String>['testuninstall'],
runDebugCommand: const <String>['testrundebug'],
forwardPortCommand: const <String>['testforwardport'],
forwardPortSuccessRegex: RegExp('testforwardportsuccess')
);
const String testConfigPingSuccessOutput = 'testpingsuccess\n';
const String testConfigForwardPortSuccessOutput = 'testforwardportsuccess\n';
final CustomDeviceConfig disabledTestConfig = testConfig.copyWith(disabled: true);
final CustomDeviceConfig testConfigNonForwarding = testConfig.copyWith(
explicitForwardPortCommand: true,
forwardPortCommand: null,
explicitForwardPortSuccessRegex: true,
forwardPortSuccessRegex: null,
);
testUsingContext('CustomDevice defaults',
() async {
final CustomDevice device = CustomDevice(
config: testConfig,
processManager: FakeProcessManager.any(),
logger: BufferLogger.test()
);
final PrebuiltLinuxApp linuxApp = PrebuiltLinuxApp(executable: 'foo');
expect(device.id, 'testid');
expect(device.name, 'testlabel');
expect(device.platformType, PlatformType.custom);
expect(await device.sdkNameAndVersion, 'testsdknameandversion');
expect(await device.targetPlatform, TargetPlatform.linux_arm64);
expect(await device.installApp(linuxApp), true);
expect(await device.uninstallApp(linuxApp), true);
expect(await device.isLatestBuildInstalled(linuxApp), false);
expect(await device.isAppInstalled(linuxApp), false);
expect(await device.stopApp(linuxApp), false);
expect(device.category, Category.mobile);
expect(device.supportsRuntimeMode(BuildMode.debug), true);
expect(device.supportsRuntimeMode(BuildMode.profile), false);
expect(device.supportsRuntimeMode(BuildMode.release), false);
expect(device.supportsRuntimeMode(BuildMode.jitRelease), false);
},
overrides: <Type, dynamic Function()>{
FileSystem: () => MemoryFileSystem.test(),
ProcessManager: () => FakeProcessManager.any()
}
);
testWithoutContext('CustomDevice: no devices listed if only disabled devices configured', () async {
final MemoryFileSystem fs = MemoryFileSystem.test();
final Directory dir = fs.directory('custom_devices_config_dir');
_writeCustomDevicesConfigFile(dir, <CustomDeviceConfig>[disabledTestConfig]);
expect(await CustomDevices(
featureFlags: TestFeatureFlags(areCustomDevicesEnabled: true),
logger: BufferLogger.test(),
processManager: FakeProcessManager.any(),
config: CustomDevicesConfig.test(
fileSystem: fs,
directory: dir,
logger: BufferLogger.test()
)
).devices, <Device>[]);
});
testWithoutContext('CustomDevice: no devices listed if custom devices feature flag disabled', () async {
final MemoryFileSystem fs = MemoryFileSystem.test();
final Directory dir = fs.directory('custom_devices_config_dir');
_writeCustomDevicesConfigFile(dir, <CustomDeviceConfig>[testConfig]);
expect(await CustomDevices(
featureFlags: TestFeatureFlags(areCustomDevicesEnabled: false),
logger: BufferLogger.test(),
processManager: FakeProcessManager.any(),
config: CustomDevicesConfig.test(
fileSystem: fs,
directory: dir,
logger: BufferLogger.test()
)
).devices, <Device>[]);
});
testWithoutContext('CustomDevices.devices', () async {
final MemoryFileSystem fs = MemoryFileSystem.test();
final Directory dir = fs.directory('custom_devices_config_dir');
_writeCustomDevicesConfigFile(dir, <CustomDeviceConfig>[testConfig]);
expect(
await CustomDevices(
featureFlags: TestFeatureFlags(areCustomDevicesEnabled: true),
logger: BufferLogger.test(),
processManager: FakeProcessManager.list(<FakeCommand>[
FakeCommand(
command: testConfig.pingCommand,
stdout: testConfigPingSuccessOutput
),
]),
config: CustomDevicesConfig.test(
fileSystem: fs,
directory: dir,
logger: BufferLogger.test()
)
).devices,
hasLength(1)
);
});
testWithoutContext('CustomDevices.discoverDevices successfully discovers devices and executes ping command', () async {
final MemoryFileSystem fs = MemoryFileSystem.test();
final Directory dir = fs.directory('custom_devices_config_dir');
_writeCustomDevicesConfigFile(dir, <CustomDeviceConfig>[testConfig]);
bool pingCommandWasExecuted = false;
final CustomDevices discovery = CustomDevices(
featureFlags: TestFeatureFlags(areCustomDevicesEnabled: true),
logger: BufferLogger.test(),
processManager: FakeProcessManager.list(<FakeCommand>[
FakeCommand(
command: testConfig.pingCommand,
onRun: () => pingCommandWasExecuted = true,
stdout: testConfigPingSuccessOutput
),
]),
config: CustomDevicesConfig.test(
fileSystem: fs,
directory: dir,
logger: BufferLogger.test(),
),
);
final List<Device> discoveredDevices = await discovery.discoverDevices();
expect(discoveredDevices, hasLength(1));
expect(pingCommandWasExecuted, true);
});
testWithoutContext('CustomDevices.discoverDevices doesn\'t report device when ping command fails', () async {
final MemoryFileSystem fs = MemoryFileSystem.test();
final Directory dir = fs.directory('custom_devices_config_dir');
_writeCustomDevicesConfigFile(dir, <CustomDeviceConfig>[testConfig]);
final CustomDevices discovery = CustomDevices(
featureFlags: TestFeatureFlags(areCustomDevicesEnabled: true),
logger: BufferLogger.test(),
processManager: FakeProcessManager.list(<FakeCommand>[
FakeCommand(
command: testConfig.pingCommand,
stdout: testConfigPingSuccessOutput,
exitCode: 1
),
]),
config: CustomDevicesConfig.test(
fileSystem: fs,
directory: dir,
logger: BufferLogger.test(),
),
);
expect(await discovery.discoverDevices(), hasLength(0));
});
testWithoutContext('CustomDevices.discoverDevices doesn\'t report device when ping command output doesn\'t match ping success regex', () async {
final MemoryFileSystem fs = MemoryFileSystem.test();
final Directory dir = fs.directory('custom_devices_config_dir');
_writeCustomDevicesConfigFile(dir, <CustomDeviceConfig>[testConfig]);
final CustomDevices discovery = CustomDevices(
featureFlags: TestFeatureFlags(areCustomDevicesEnabled: true),
logger: BufferLogger.test(),
processManager: FakeProcessManager.list(<FakeCommand>[
FakeCommand(
command: testConfig.pingCommand,
exitCode: 0,
stdout: '',
),
]),
config: CustomDevicesConfig.test(
fileSystem: fs,
directory: dir,
logger: BufferLogger.test(),
),
);
expect(await discovery.discoverDevices(), hasLength(0));
});
testWithoutContext('CustomDevice.isSupportedForProject is true with editable host app', () async {
final MemoryFileSystem fileSystem = MemoryFileSystem.test();
fileSystem.file('pubspec.yaml').createSync();
fileSystem.file('.packages').createSync();
final FlutterProject flutterProject = _setUpFlutterProject(fileSystem.currentDirectory);
expect(CustomDevice(
config: testConfig,
logger: BufferLogger.test(),
processManager: FakeProcessManager.any(),
).isSupportedForProject(flutterProject), true);
});
testUsingContext(
'CustomDevice.install invokes uninstall and install command',
() async {
bool bothCommandsWereExecuted = false;
final CustomDevice device = CustomDevice(
config: testConfig,
logger: BufferLogger.test(),
processManager: FakeProcessManager.list(<FakeCommand>[
FakeCommand(command: testConfig.uninstallCommand),
FakeCommand(command: testConfig.installCommand, onRun: () => bothCommandsWereExecuted = true)
])
);
expect(await device.installApp(PrebuiltLinuxApp(executable: 'exe')), true);
expect(bothCommandsWereExecuted, true);
},
overrides: <Type, dynamic Function()>{
FileSystem: () => MemoryFileSystem.test(),
ProcessManager: () => FakeProcessManager.any()
}
);
testWithoutContext('CustomDevicePortForwarder will run and terminate forwardPort command', () async {
final Completer<void> forwardPortCommandCompleter = Completer<void>();
final CustomDevicePortForwarder forwarder = CustomDevicePortForwarder(
deviceName: 'testdevicename',
forwardPortCommand: testConfig.forwardPortCommand,
forwardPortSuccessRegex: testConfig.forwardPortSuccessRegex,
logger: BufferLogger.test(),
processManager: FakeProcessManager.list(<FakeCommand>[
FakeCommand(
command: testConfig.forwardPortCommand,
stdout: testConfigForwardPortSuccessOutput,
completer: forwardPortCommandCompleter
)
])
);
// this should start the command
expect(await forwarder.forward(12345, hostPort: null), 12345);
expect(forwardPortCommandCompleter.isCompleted, false);
// this should terminate it
await forwarder.dispose();
// the termination should have completed our completer
expect(forwardPortCommandCompleter.isCompleted, true);
});
testWithoutContext('CustomDevice forwards observatory port correctly when port forwarding is configured', () async {
final Completer<void> runDebugCompleter = Completer<void>();
final Completer<void> forwardPortCompleter = Completer<void>();
final FakeProcessManager processManager = FakeProcessManager.list(<FakeCommand>[
FakeCommand(
command: testConfig.runDebugCommand,
completer: runDebugCompleter,
stdout: 'Observatory listening on http://127.0.0.1:12345/abcd/\n',
),
FakeCommand(
command: testConfig.forwardPortCommand,
completer: forwardPortCompleter,
stdout: testConfigForwardPortSuccessOutput,
)
]);
final CustomDeviceAppSession appSession = CustomDeviceAppSession(
name: 'testname',
device: CustomDevice(
config: testConfig,
logger: BufferLogger.test(),
processManager: processManager
),
appPackage: PrebuiltLinuxApp(executable: 'testexecutable'),
logger: BufferLogger.test(),
processManager: processManager,
);
final LaunchResult launchResult = await appSession.start();
expect(launchResult.started, true);
expect(launchResult.observatoryUri, Uri.parse('http://127.0.0.1:12345/abcd/'));
expect(runDebugCompleter.isCompleted, false);
expect(forwardPortCompleter.isCompleted, false);
expect(await appSession.stop(), true);
expect(runDebugCompleter.isCompleted, true);
expect(forwardPortCompleter.isCompleted, true);
});
testWithoutContext('CustomDeviceAppSession forwards observatory port correctly when port forwarding is not configured', () async {
final Completer<void> runDebugCompleter = Completer<void>();
final FakeProcessManager processManager = FakeProcessManager.list(
<FakeCommand>[
FakeCommand(
command: testConfigNonForwarding.runDebugCommand,
completer: runDebugCompleter,
stdout: 'Observatory listening on http://192.168.178.123:12345/abcd/\n'
),
]
);
final CustomDeviceAppSession appSession = CustomDeviceAppSession(
name: 'testname',
device: CustomDevice(
config: testConfigNonForwarding,
logger: BufferLogger.test(),
processManager: processManager
),
appPackage: PrebuiltLinuxApp(executable: 'testexecutable'),
logger: BufferLogger.test(),
processManager: processManager
);
final LaunchResult launchResult = await appSession.start();
expect(launchResult.started, true);
expect(launchResult.observatoryUri, Uri.parse('http://192.168.178.123:12345/abcd/'));
expect(runDebugCompleter.isCompleted, false);
expect(await appSession.stop(), true);
expect(runDebugCompleter.isCompleted, true);
});
testUsingContext(
'custom device end-to-end test',
() async {
final Completer<void> runDebugCompleter = Completer<void>();
final Completer<void> forwardPortCompleter = Completer<void>();
final FakeProcessManager processManager = FakeProcessManager.list(
<FakeCommand>[
FakeCommand(
command: testConfig.pingCommand,
stdout: testConfigPingSuccessOutput
),
FakeCommand(command: testConfig.postBuildCommand),
FakeCommand(command: testConfig.uninstallCommand),
FakeCommand(command: testConfig.installCommand),
FakeCommand(
command: testConfig.runDebugCommand,
completer: runDebugCompleter,
stdout: 'Observatory listening on http://127.0.0.1:12345/abcd/\n',
),
FakeCommand(
command: testConfig.forwardPortCommand,
completer: forwardPortCompleter,
stdout: testConfigForwardPortSuccessOutput
)
]
);
// Reuse our filesystem from context instead of mixing two filesystem instances
// together
final FileSystem fs = globals.fs;
// CustomDevice.startApp doesn't care whether we pass a prebuilt app or
// buildable app as long as we pass prebuiltApplication as false
final PrebuiltLinuxApp app = PrebuiltLinuxApp(executable: 'testexecutable');
final Directory configFileDir = fs.directory('custom_devices_config_dir');
_writeCustomDevicesConfigFile(configFileDir, <CustomDeviceConfig>[testConfig]);
// finally start actually testing things
final CustomDevices customDevices = CustomDevices(
featureFlags: TestFeatureFlags(areCustomDevicesEnabled: true),
processManager: processManager,
logger: BufferLogger.test(),
config: CustomDevicesConfig.test(
fileSystem: fs,
directory: configFileDir,
logger: BufferLogger.test()
)
);
final List<Device> devices = await customDevices.discoverDevices();
expect(devices.length, 1);
expect(devices.single, isA<CustomDevice>());
final CustomDevice device = devices.single as CustomDevice;
expect(device.id, testConfig.id);
expect(device.name, testConfig.label);
expect(await device.sdkNameAndVersion, testConfig.sdkNameAndVersion);
final LaunchResult result = await device.startApp(
app,
debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug),
bundleBuilder: FakeBundleBuilder()
);
expect(result.started, true);
expect(result.hasObservatory, true);
expect(result.observatoryUri, Uri.tryParse('http://127.0.0.1:12345/abcd/'));
expect(runDebugCompleter.isCompleted, false);
expect(forwardPortCompleter.isCompleted, false);
expect(await device.stopApp(app), true);
expect(runDebugCompleter.isCompleted, true);
expect(forwardPortCompleter.isCompleted, true);
},
overrides: <Type, Generator>{
FileSystem: () => MemoryFileSystem.test(),
ProcessManager: () => FakeProcessManager.any()
}
);
}
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// @dart = 2.8
import 'package:flutter_tools/src/custom_devices/custom_device_workflow.dart';
import '../../src/common.dart';
import '../../src/fakes.dart';
void main() {
testWithoutContext('CustomDeviceWorkflow reports false when custom devices feature is disabled', () {
final CustomDeviceWorkflow workflow = CustomDeviceWorkflow(featureFlags: TestFeatureFlags(areCustomDevicesEnabled: false));
expect(workflow.appliesToHostPlatform, false);
expect(workflow.canLaunchDevices, false);
expect(workflow.canListDevices, false);
expect(workflow.canListEmulators, false);
});
testWithoutContext('CustomDeviceWorkflow reports true for everything except canListEmulators when custom devices feature is enabled', () {
final CustomDeviceWorkflow workflow = CustomDeviceWorkflow(featureFlags: TestFeatureFlags(areCustomDevicesEnabled: true));
expect(workflow.appliesToHostPlatform, true);
expect(workflow.canLaunchDevices, true);
expect(workflow.canListDevices, true);
expect(workflow.canListEmulators, false);
});
}
...@@ -12,7 +12,9 @@ import 'package:flutter_tools/src/base/file_system.dart'; ...@@ -12,7 +12,9 @@ import 'package:flutter_tools/src/base/file_system.dart';
import 'package:flutter_tools/src/base/io.dart'; import 'package:flutter_tools/src/base/io.dart';
import 'package:flutter_tools/src/base/logger.dart'; import 'package:flutter_tools/src/base/logger.dart';
import 'package:flutter_tools/src/base/os.dart'; import 'package:flutter_tools/src/base/os.dart';
import 'package:flutter_tools/src/build_info.dart';
import 'package:flutter_tools/src/build_system/build_system.dart'; import 'package:flutter_tools/src/build_system/build_system.dart';
import 'package:flutter_tools/src/bundle.dart';
import 'package:flutter_tools/src/cache.dart'; import 'package:flutter_tools/src/cache.dart';
import 'package:flutter_tools/src/convert.dart'; import 'package:flutter_tools/src/convert.dart';
import 'package:flutter_tools/src/dart/pub.dart'; import 'package:flutter_tools/src/dart/pub.dart';
...@@ -560,6 +562,7 @@ class TestFeatureFlags implements FeatureFlags { ...@@ -560,6 +562,7 @@ class TestFeatureFlags implements FeatureFlags {
this.isAndroidEnabled = true, this.isAndroidEnabled = true,
this.isIOSEnabled = true, this.isIOSEnabled = true,
this.isFuchsiaEnabled = false, this.isFuchsiaEnabled = false,
this.areCustomDevicesEnabled = false,
this.isExperimentalInvalidationStrategyEnabled = false, this.isExperimentalInvalidationStrategyEnabled = false,
this.isWindowsUwpEnabled = false, this.isWindowsUwpEnabled = false,
}); });
...@@ -588,6 +591,9 @@ class TestFeatureFlags implements FeatureFlags { ...@@ -588,6 +591,9 @@ class TestFeatureFlags implements FeatureFlags {
@override @override
final bool isFuchsiaEnabled; final bool isFuchsiaEnabled;
@override
final bool areCustomDevicesEnabled;
@override @override
final bool isExperimentalInvalidationStrategyEnabled; final bool isExperimentalInvalidationStrategyEnabled;
...@@ -613,6 +619,8 @@ class TestFeatureFlags implements FeatureFlags { ...@@ -613,6 +619,8 @@ class TestFeatureFlags implements FeatureFlags {
return isIOSEnabled; return isIOSEnabled;
case flutterFuchsiaFeature: case flutterFuchsiaFeature:
return isFuchsiaEnabled; return isFuchsiaEnabled;
case flutterCustomDevicesFeature:
return areCustomDevicesEnabled;
case experimentalInvalidationStrategy: case experimentalInvalidationStrategy:
return isExperimentalInvalidationStrategyEnabled; return isExperimentalInvalidationStrategyEnabled;
case windowsUwpEmbedding: case windowsUwpEmbedding:
...@@ -692,3 +700,22 @@ class TestBuildSystem implements BuildSystem { ...@@ -692,3 +700,22 @@ class TestBuildSystem implements BuildSystem {
return _results[_nextResult++]; return _results[_nextResult++];
} }
} }
class FakeBundleBuilder extends Fake implements BundleBuilder {
@override
Future<void> build({
TargetPlatform platform,
BuildInfo buildInfo,
String mainPath,
String manifestPath = defaultManifestPath,
String applicationKernelFilePath,
String depfilePath,
String assetDirPath,
bool trackWidgetCreation = false,
List<String> extraFrontEndOptions = const <String>[],
List<String> extraGenSnapshotOptions = const <String>[],
List<String> fileSystemRoots,
String fileSystemScheme,
bool treeShakeIcons
}) => Future<void>.value();
}
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