Jonah Williams 401d401c59
[flutter_tools] reland: avoid creating Android Devices if AndroidSDK cannot be found (#64665)
Avoid creating AndroidDevice discovery if the SDK cannot be located. Previously the tool would use which/where adb, however this required us to handle the AndroidSdk class being potentially null - which required an additional layer of indirection around all access. Sometimes these were forgotten leading to NPEs.

In general, not much can be done with an Android Device if the actual SDK is not installed.

Reland with fixed code + tests for null SDK + adb in AndroidDeviceDiscovery
2020-08-27 09:44:31 -07:00

1344 lines
44 KiB
Dart

// 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 'dart:async';
import 'package:meta/meta.dart';
import 'package:process/process.dart';
import '../android/android_builder.dart';
import '../android/android_sdk.dart';
import '../application_package.dart';
import '../base/common.dart' show throwToolExit, unawaited;
import '../base/file_system.dart';
import '../base/io.dart';
import '../base/logger.dart';
import '../base/platform.dart';
import '../base/process.dart';
import '../build_info.dart';
import '../convert.dart';
import '../device.dart';
import '../project.dart';
import '../protocol_discovery.dart';
import 'android.dart';
import 'android_console.dart';
import 'android_sdk.dart';
// TODO(jonahwilliams): update google3 client after roll to remove export.
export 'android_device_discovery.dart';
enum _HardwareType { emulator, physical }
/// Map to help our `isLocalEmulator` detection.
const Map<String, _HardwareType> _kKnownHardware = <String, _HardwareType>{
'goldfish': _HardwareType.emulator,
'qcom': _HardwareType.physical,
'ranchu': _HardwareType.emulator,
'samsungexynos7420': _HardwareType.physical,
'samsungexynos7580': _HardwareType.physical,
'samsungexynos7870': _HardwareType.physical,
'samsungexynos7880': _HardwareType.physical,
'samsungexynos8890': _HardwareType.physical,
'samsungexynos8895': _HardwareType.physical,
'samsungexynos9810': _HardwareType.physical,
'samsungexynos7570': _HardwareType.physical,
};
bool allowHeapCorruptionOnWindows(int exitCode, Platform platform) {
// In platform tools 29.0.0 adb.exe seems to be ending with this heap
// corruption error code on seemingly successful termination.
// So we ignore this error on Windows.
return exitCode == -1073740940 && platform.isWindows;
}
class AndroidDevice extends Device {
AndroidDevice(
String id, {
this.productID,
this.modelID,
this.deviceCodeName,
@required Logger logger,
@required ProcessManager processManager,
@required Platform platform,
@required AndroidSdk androidSdk,
@required FileSystem fileSystem,
TimeoutConfiguration timeoutConfiguration = const TimeoutConfiguration(),
AndroidConsoleSocketFactory androidConsoleSocketFactory = kAndroidConsoleSocketFactory,
}) : _logger = logger,
_processManager = processManager,
_androidSdk = androidSdk,
_platform = platform,
_fileSystem = fileSystem,
_androidConsoleSocketFactory = androidConsoleSocketFactory,
_timeoutConfiguration = timeoutConfiguration,
_processUtils = ProcessUtils(logger: logger, processManager: processManager),
super(
id,
category: Category.mobile,
platformType: PlatformType.android,
ephemeral: true,
);
final Logger _logger;
final ProcessManager _processManager;
final AndroidSdk _androidSdk;
final Platform _platform;
final FileSystem _fileSystem;
final ProcessUtils _processUtils;
final AndroidConsoleSocketFactory _androidConsoleSocketFactory;
final TimeoutConfiguration _timeoutConfiguration;
final String productID;
final String modelID;
final String deviceCodeName;
Map<String, String> _properties;
bool _isLocalEmulator;
TargetPlatform _applicationPlatform;
Future<String> _getProperty(String name) async {
if (_properties == null) {
_properties = <String, String>{};
final List<String> propCommand = adbCommandForDevice(<String>['shell', 'getprop']);
_logger.printTrace(propCommand.join(' '));
try {
// We pass an encoding of latin1 so that we don't try and interpret the
// `adb shell getprop` result as UTF8.
final ProcessResult result = await _processManager.run(
propCommand,
stdoutEncoding: latin1,
stderrEncoding: latin1,
);
if (result.exitCode == 0 || allowHeapCorruptionOnWindows(result.exitCode, _platform)) {
_properties = parseAdbDeviceProperties(result.stdout as String);
} else {
_logger.printError('Error ${result.exitCode} retrieving device properties for $name:');
_logger.printError(result.stderr as String);
}
} on ProcessException catch (error) {
_logger.printError('Error retrieving device properties for $name: $error');
}
}
return _properties[name];
}
@override
Future<bool> get isLocalEmulator async {
if (_isLocalEmulator == null) {
final String hardware = await _getProperty('ro.hardware');
_logger.printTrace('ro.hardware = $hardware');
if (_kKnownHardware.containsKey(hardware)) {
// Look for known hardware models.
_isLocalEmulator = _kKnownHardware[hardware] == _HardwareType.emulator;
} else {
// Fall back to a best-effort heuristic-based approach.
final String characteristics = await _getProperty('ro.build.characteristics');
_logger.printTrace('ro.build.characteristics = $characteristics');
_isLocalEmulator = characteristics != null && characteristics.contains('emulator');
}
}
return _isLocalEmulator;
}
/// The unique identifier for the emulator that corresponds to this device, or
/// null if it is not an emulator.
///
/// The ID returned matches that in the output of `flutter emulators`. Fetching
/// this name may require connecting to the device and if an error occurs null
/// will be returned.
@override
Future<String> get emulatorId async {
if (!(await isLocalEmulator)) {
return null;
}
// Emulators always have IDs in the format emulator-(port) where port is the
// Android Console port number.
final RegExp emulatorPortRegex = RegExp(r'emulator-(\d+)');
final Match portMatch = emulatorPortRegex.firstMatch(id);
if (portMatch == null || portMatch.groupCount < 1) {
return null;
}
const String host = 'localhost';
final int port = int.parse(portMatch.group(1));
_logger.printTrace('Fetching avd name for $name via Android console on $host:$port');
try {
final Socket socket = await _androidConsoleSocketFactory(host, port);
final AndroidConsole console = AndroidConsole(socket);
try {
await console
.connect()
.timeout(_timeoutConfiguration.fastOperation,
onTimeout: () => throw TimeoutException('Connection timed out'));
return await console
.getAvdName()
.timeout(_timeoutConfiguration.fastOperation,
onTimeout: () => throw TimeoutException('"avd name" timed out'));
} finally {
console.destroy();
}
} on Exception catch (e) {
_logger.printTrace('Failed to fetch avd name for emulator at $host:$port: $e');
// If we fail to connect to the device, we should not fail so just return
// an empty name. This data is best-effort.
return null;
}
}
@override
Future<TargetPlatform> get targetPlatform async {
if (_applicationPlatform == null) {
// http://developer.android.com/ndk/guides/abis.html (x86, armeabi-v7a, ...)
switch (await _getProperty('ro.product.cpu.abi')) {
case 'arm64-v8a':
// Perform additional verification for 64 bit ABI. Some devices,
// like the Kindle Fire 8, misreport the abilist. We might not
// be able to retrieve this property, in which case we fall back
// to assuming 64 bit.
final String abilist = await _getProperty('ro.product.cpu.abilist');
if (abilist == null || abilist.contains('arm64-v8a')) {
_applicationPlatform = TargetPlatform.android_arm64;
} else {
_applicationPlatform = TargetPlatform.android_arm;
}
break;
case 'x86_64':
_applicationPlatform = TargetPlatform.android_x64;
break;
case 'x86':
_applicationPlatform = TargetPlatform.android_x86;
break;
default:
_applicationPlatform = TargetPlatform.android_arm;
break;
}
}
return _applicationPlatform;
}
@override
Future<bool> supportsRuntimeMode(BuildMode buildMode) async {
switch (await targetPlatform) {
case TargetPlatform.android_arm:
case TargetPlatform.android_arm64:
case TargetPlatform.android_x64:
return buildMode != BuildMode.jitRelease;
case TargetPlatform.android_x86:
return buildMode == BuildMode.debug;
default:
throw UnsupportedError('Invalid target platform for Android');
}
}
@override
Future<String> get sdkNameAndVersion async =>
'Android ${await _sdkVersion} (API ${await apiVersion})';
Future<String> get _sdkVersion => _getProperty('ro.build.version.release');
@visibleForTesting
Future<String> get apiVersion => _getProperty('ro.build.version.sdk');
AdbLogReader _logReader;
AdbLogReader _pastLogReader;
AndroidDevicePortForwarder _portForwarder;
List<String> adbCommandForDevice(List<String> args) {
return <String>[_androidSdk.adbPath, '-s', id, ...args];
}
String runAdbCheckedSync(
List<String> params, {
String workingDirectory,
bool allowReentrantFlutter = false,
Map<String, String> environment,
}) {
return _processUtils.runSync(
adbCommandForDevice(params),
throwOnError: true,
workingDirectory: workingDirectory,
allowReentrantFlutter: allowReentrantFlutter,
environment: environment,
allowedFailures: (int value) => allowHeapCorruptionOnWindows(value, _platform),
).stdout.trim();
}
Future<RunResult> runAdbCheckedAsync(
List<String> params, {
String workingDirectory,
bool allowReentrantFlutter = false,
}) async {
return _processUtils.run(
adbCommandForDevice(params),
throwOnError: true,
workingDirectory: workingDirectory,
allowReentrantFlutter: allowReentrantFlutter,
allowedFailures: (int value) => allowHeapCorruptionOnWindows(value, _platform),
);
}
bool _isValidAdbVersion(String adbVersion) {
// Sample output: 'Android Debug Bridge version 1.0.31'
final Match versionFields = RegExp(r'(\d+)\.(\d+)\.(\d+)').firstMatch(adbVersion);
if (versionFields != null) {
final int majorVersion = int.parse(versionFields[1]);
final int minorVersion = int.parse(versionFields[2]);
final int patchVersion = int.parse(versionFields[3]);
if (majorVersion > 1) {
return true;
}
if (majorVersion == 1 && minorVersion > 0) {
return true;
}
if (majorVersion == 1 && minorVersion == 0 && patchVersion >= 39) {
return true;
}
return false;
}
_logger.printError(
'Unrecognized adb version string $adbVersion. Skipping version check.');
return true;
}
Future<bool> _checkForSupportedAdbVersion() async {
if (_androidSdk == null) {
return false;
}
try {
final RunResult adbVersion = await _processUtils.run(
<String>[_androidSdk.adbPath, 'version'],
throwOnError: true,
);
if (_isValidAdbVersion(adbVersion.stdout)) {
return true;
}
_logger.printError('The ADB at "${_androidSdk.adbPath}" is too old; please install version 1.0.39 or later.');
} on Exception catch (error, trace) {
_logger.printError('Error running ADB: $error', stackTrace: trace);
}
return false;
}
Future<bool> _checkForSupportedAndroidVersion() async {
try {
// If the server is automatically restarted, then we get irrelevant
// output lines like this, which we want to ignore:
// adb server is out of date. killing..
// * daemon started successfully *
await _processUtils.run(
<String>[_androidSdk.adbPath, 'start-server'],
throwOnError: true,
);
// This has been reported to return null on some devices. In this case,
// assume the lowest supported API to still allow Flutter to run.
// Sample output: '22'
final String sdkVersion = await _getProperty('ro.build.version.sdk')
?? minApiLevel.toString();
final int sdkVersionParsed = int.tryParse(sdkVersion);
if (sdkVersionParsed == null) {
_logger.printError('Unexpected response from getprop: "$sdkVersion"');
return false;
}
if (sdkVersionParsed < minApiLevel) {
_logger.printError(
'The Android version ($sdkVersion) on the target device is too old. Please '
'use a $minVersionName (version $minApiLevel / $minVersionText) device or later.');
return false;
}
return true;
} on Exception catch (e, stacktrace) {
_logger.printError('Unexpected failure from adb: $e');
_logger.printError('Stacktrace: $stacktrace');
return false;
}
}
String _getDeviceSha1Path(AndroidApk apk) {
return '/data/local/tmp/sky.${apk.id}.sha1';
}
Future<String> _getDeviceApkSha1(AndroidApk apk) async {
final RunResult result = await _processUtils.run(
adbCommandForDevice(<String>['shell', 'cat', _getDeviceSha1Path(apk)]));
return result.stdout;
}
String _getSourceSha1(AndroidApk apk) {
final File shaFile = _fileSystem.file('${apk.file.path}.sha1');
return shaFile.existsSync() ? shaFile.readAsStringSync() : '';
}
@override
String get name => modelID;
@override
Future<bool> isAppInstalled(
AndroidApk app, {
String userIdentifier,
}) async {
// This call takes 400ms - 600ms.
try {
final RunResult listOut = await runAdbCheckedAsync(<String>[
'shell',
'pm',
'list',
'packages',
if (userIdentifier != null)
...<String>['--user', userIdentifier],
app.id
]);
return LineSplitter.split(listOut.stdout).contains('package:${app.id}');
} on Exception catch (error) {
_logger.printTrace('$error');
return false;
}
}
@override
Future<bool> isLatestBuildInstalled(AndroidApk app) async {
final String installedSha1 = await _getDeviceApkSha1(app);
return installedSha1.isNotEmpty && installedSha1 == _getSourceSha1(app);
}
@override
Future<bool> installApp(
AndroidApk app, {
String userIdentifier,
}) async {
if (!app.file.existsSync()) {
_logger.printError('"${_fileSystem.path.relative(app.file.path)}" does not exist.');
return false;
}
if (!await _checkForSupportedAdbVersion() ||
!await _checkForSupportedAndroidVersion()) {
return false;
}
final Status status = _logger.startProgress(
'Installing ${_fileSystem.path.relative(app.file.path)}...',
timeout: _timeoutConfiguration.slowOperation,
);
final RunResult installResult = await _processUtils.run(
adbCommandForDevice(<String>[
'install',
'-t',
'-r',
if (userIdentifier != null)
...<String>['--user', userIdentifier],
app.file.path
]));
status.stop();
// Some versions of adb exit with exit code 0 even on failure :(
// Parsing the output to check for failures.
final RegExp failureExp = RegExp(r'^Failure.*$', multiLine: true);
final String failure = failureExp.stringMatch(installResult.stdout);
if (failure != null) {
_logger.printError('Package install error: $failure');
return false;
}
if (installResult.exitCode != 0) {
if (installResult.stderr.contains('Bad user number')) {
_logger.printError('Error: User "$userIdentifier" not found. Run "adb shell pm list users" to see list of available identifiers.');
} else {
_logger.printError('Error: ADB exited with exit code ${installResult.exitCode}');
_logger.printError('$installResult');
}
return false;
}
try {
await runAdbCheckedAsync(<String>[
'shell', 'echo', '-n', _getSourceSha1(app), '>', _getDeviceSha1Path(app),
]);
} on ProcessException catch (error) {
_logger.printError('adb shell failed to write the SHA hash: $error.');
return false;
}
return true;
}
@override
Future<bool> uninstallApp(
AndroidApk app, {
String userIdentifier,
}) async {
if (!await _checkForSupportedAdbVersion() ||
!await _checkForSupportedAndroidVersion()) {
return false;
}
String uninstallOut;
try {
final RunResult uninstallResult = await _processUtils.run(
adbCommandForDevice(<String>[
'uninstall',
if (userIdentifier != null)
...<String>['--user', userIdentifier],
app.id]),
throwOnError: true,
);
uninstallOut = uninstallResult.stdout;
} on Exception catch (error) {
_logger.printError('adb uninstall failed: $error');
return false;
}
final RegExp failureExp = RegExp(r'^Failure.*$', multiLine: true);
final String failure = failureExp.stringMatch(uninstallOut);
if (failure != null) {
_logger.printError('Package uninstall error: $failure');
return false;
}
return true;
}
Future<bool> _installLatestApp(AndroidApk package, String userIdentifier) async {
final bool wasInstalled = await isAppInstalled(package, userIdentifier: userIdentifier);
if (wasInstalled) {
if (await isLatestBuildInstalled(package)) {
_logger.printTrace('Latest build already installed.');
return true;
}
}
_logger.printTrace('Installing APK.');
if (!await installApp(package, userIdentifier: userIdentifier)) {
_logger.printTrace('Warning: Failed to install APK.');
if (wasInstalled) {
_logger.printStatus('Uninstalling old version...');
if (!await uninstallApp(package, userIdentifier: userIdentifier)) {
_logger.printError('Error: Uninstalling old version failed.');
return false;
}
if (!await installApp(package, userIdentifier: userIdentifier)) {
_logger.printError('Error: Failed to install APK again.');
return false;
}
return true;
}
return false;
}
return true;
}
AndroidApk _package;
@override
Future<LaunchResult> startApp(
AndroidApk package, {
String mainPath,
String route,
DebuggingOptions debuggingOptions,
Map<String, dynamic> platformArgs,
bool prebuiltApplication = false,
bool ipv6 = false,
String userIdentifier,
}) async {
if (!await _checkForSupportedAdbVersion() ||
!await _checkForSupportedAndroidVersion()) {
return LaunchResult.failed();
}
final TargetPlatform devicePlatform = await targetPlatform;
if (devicePlatform == TargetPlatform.android_x86 &&
!debuggingOptions.buildInfo.isDebug) {
_logger.printError('Profile and release builds are only supported on ARM/x64 targets.');
return LaunchResult.failed();
}
AndroidArch androidArch;
switch (devicePlatform) {
case TargetPlatform.android_arm:
androidArch = AndroidArch.armeabi_v7a;
break;
case TargetPlatform.android_arm64:
androidArch = AndroidArch.arm64_v8a;
break;
case TargetPlatform.android_x64:
androidArch = AndroidArch.x86_64;
break;
case TargetPlatform.android_x86:
androidArch = AndroidArch.x86;
break;
default:
_logger.printError('Android platforms are only supported.');
return LaunchResult.failed();
}
if (!prebuiltApplication || _androidSdk.licensesAvailable && _androidSdk.latestVersion == null) {
_logger.printTrace('Building APK');
final FlutterProject project = FlutterProject.current();
await androidBuilder.buildApk(
project: project,
target: mainPath,
androidBuildInfo: AndroidBuildInfo(
debuggingOptions.buildInfo,
targetArchs: <AndroidArch>[androidArch],
fastStart: debuggingOptions.fastStart
),
);
// Package has been built, so we can get the updated application ID and
// activity name from the .apk.
package = await AndroidApk.fromAndroidProject(project.android);
}
// There was a failure parsing the android project information.
if (package == null) {
throwToolExit('Problem building Android application: see above error(s).');
}
_logger.printTrace("Stopping app '${package.name}' on $name.");
await stopApp(package, userIdentifier: userIdentifier);
if (!await _installLatestApp(package, userIdentifier)) {
return LaunchResult.failed();
}
final bool traceStartup = platformArgs['trace-startup'] as bool ?? false;
_logger.printTrace('$this startApp');
ProtocolDiscovery observatoryDiscovery;
if (debuggingOptions.debuggingEnabled) {
// TODO(devoncarew): Remember the forwarding information (so we can later remove the
// port forwarding or set it up again when adb fails on us).
observatoryDiscovery = ProtocolDiscovery.observatory(
await getLogReader(),
portForwarder: portForwarder,
hostPort: debuggingOptions.hostVmServicePort,
devicePort: debuggingOptions.deviceVmServicePort,
ipv6: ipv6,
);
}
final String dartVmFlags = computeDartVmFlags(debuggingOptions);
final List<String> cmd = <String>[
'shell', 'am', 'start',
'-a', 'android.intent.action.RUN',
'-f', '0x20000000', // FLAG_ACTIVITY_SINGLE_TOP
'--ez', 'enable-background-compilation', 'true',
'--ez', 'enable-dart-profiling', 'true',
if (traceStartup)
...<String>['--ez', 'trace-startup', 'true'],
if (route != null)
...<String>['--es', 'route', route],
if (debuggingOptions.enableSoftwareRendering)
...<String>['--ez', 'enable-software-rendering', 'true'],
if (debuggingOptions.skiaDeterministicRendering)
...<String>['--ez', 'skia-deterministic-rendering', 'true'],
if (debuggingOptions.traceSkia)
...<String>['--ez', 'trace-skia', 'true'],
if (debuggingOptions.traceAllowlist != null)
...<String>['--ez', 'trace-allowlist', debuggingOptions.traceAllowlist],
if (debuggingOptions.traceSystrace)
...<String>['--ez', 'trace-systrace', 'true'],
if (debuggingOptions.endlessTraceBuffer)
...<String>['--ez', 'endless-trace-buffer', 'true'],
if (debuggingOptions.dumpSkpOnShaderCompilation)
...<String>['--ez', 'dump-skp-on-shader-compilation', 'true'],
if (debuggingOptions.cacheSkSL)
...<String>['--ez', 'cache-sksl', 'true'],
if (debuggingOptions.purgePersistentCache)
...<String>['--ez', 'purge-persistent-cache', 'true'],
if (debuggingOptions.debuggingEnabled) ...<String>[
if (debuggingOptions.buildInfo.isDebug) ...<String>[
...<String>['--ez', 'enable-checked-mode', 'true'],
...<String>['--ez', 'verify-entry-points', 'true'],
],
if (debuggingOptions.startPaused)
...<String>['--ez', 'start-paused', 'true'],
if (debuggingOptions.disableServiceAuthCodes)
...<String>['--ez', 'disable-service-auth-codes', 'true'],
if (dartVmFlags.isNotEmpty)
...<String>['--es', 'dart-flags', dartVmFlags],
if (debuggingOptions.useTestFonts)
...<String>['--ez', 'use-test-fonts', 'true'],
if (debuggingOptions.verboseSystemLogs)
...<String>['--ez', 'verbose-logging', 'true'],
if (userIdentifier != null)
...<String>['--user', userIdentifier],
],
package.launchActivity,
];
final String result = (await runAdbCheckedAsync(cmd)).stdout;
// This invocation returns 0 even when it fails.
if (result.contains('Error: ')) {
_logger.printError(result.trim(), wrap: false);
return LaunchResult.failed();
}
_package = package;
if (!debuggingOptions.debuggingEnabled) {
return LaunchResult.succeeded();
}
// Wait for the service protocol port here. This will complete once the
// device has printed "Observatory is listening on...".
_logger.printTrace('Waiting for observatory port to be available...');
// TODO(danrubel): Waiting for observatory services can be made common across all devices.
try {
Uri observatoryUri;
if (debuggingOptions.buildInfo.isDebug || debuggingOptions.buildInfo.isProfile) {
observatoryUri = await observatoryDiscovery.uri;
if (observatoryUri == null) {
_logger.printError(
'Error waiting for a debug connection: '
'The log reader stopped unexpectedly',
);
return LaunchResult.failed();
}
}
return LaunchResult.succeeded(observatoryUri: observatoryUri);
} on Exception catch (error) {
_logger.printError('Error waiting for a debug connection: $error');
return LaunchResult.failed();
} finally {
await observatoryDiscovery.cancel();
}
}
@override
bool get supportsHotReload => true;
@override
bool get supportsHotRestart => true;
@override
bool get supportsFastStart => true;
@override
Future<bool> stopApp(
AndroidApk app, {
String userIdentifier,
}) {
if (app == null) {
return Future<bool>.value(false);
}
final List<String> command = adbCommandForDevice(<String>[
'shell',
'am',
'force-stop',
if (userIdentifier != null)
...<String>['--user', userIdentifier],
app.id,
]);
return _processUtils.stream(command).then<bool>(
(int exitCode) => exitCode == 0 || allowHeapCorruptionOnWindows(exitCode, _platform));
}
@override
Future<MemoryInfo> queryMemoryInfo() async {
final RunResult runResult = await _processUtils.run(adbCommandForDevice(<String>[
'shell',
'dumpsys',
'meminfo',
_package.id,
'-d',
]));
if (runResult.exitCode != 0) {
return const MemoryInfo.empty();
}
return parseMeminfoDump(runResult.stdout);
}
@override
void clearLogs() {
_processUtils.runSync(adbCommandForDevice(<String>['logcat', '-c']));
}
@override
FutureOr<DeviceLogReader> getLogReader({
AndroidApk app,
bool includePastLogs = false,
}) async {
// The Android log reader isn't app-specific. The `app` parameter isn't used.
if (includePastLogs) {
return _pastLogReader ??= await AdbLogReader.createLogReader(
this,
_processManager,
includePastLogs: true,
);
} else {
return _logReader ??= await AdbLogReader.createLogReader(
this,
_processManager,
);
}
}
@override
DevicePortForwarder get portForwarder => _portForwarder ??= AndroidDevicePortForwarder(
processManager: _processManager,
logger: _logger,
deviceId: id,
adbPath: _androidSdk.adbPath,
);
static final RegExp _timeRegExp = RegExp(r'^\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}', multiLine: true);
/// Return the most recent timestamp in the Android log or [null] if there is
/// no available timestamp. The format can be passed to logcat's -T option.
String get lastLogcatTimestamp {
String output;
try {
output = runAdbCheckedSync(<String>[
'shell', '-x', 'logcat', '-v', 'time', '-t', '1'
]);
} on Exception catch (error) {
_logger.printError('Failed to extract the most recent timestamp from the Android log: $error.');
return null;
}
final Match timeMatch = _timeRegExp.firstMatch(output);
return timeMatch?.group(0);
}
@override
bool isSupported() => true;
@override
bool get supportsScreenshot => true;
@override
Future<void> takeScreenshot(File outputFile) async {
const String remotePath = '/data/local/tmp/flutter_screenshot.png';
await runAdbCheckedAsync(<String>['shell', 'screencap', '-p', remotePath]);
await _processUtils.run(
adbCommandForDevice(<String>['pull', remotePath, outputFile.path]),
throwOnError: true,
);
await runAdbCheckedAsync(<String>['shell', 'rm', remotePath]);
}
@override
bool isSupportedForProject(FlutterProject flutterProject) {
return flutterProject.android.existsSync();
}
@override
Future<void> dispose() async {
_logReader?._stop();
_pastLogReader?._stop();
await _portForwarder?.dispose();
}
}
Map<String, String> parseAdbDeviceProperties(String str) {
final Map<String, String> properties = <String, String>{};
final RegExp propertyExp = RegExp(r'\[(.*?)\]: \[(.*?)\]');
for (final Match match in propertyExp.allMatches(str)) {
properties[match.group(1)] = match.group(2);
}
return properties;
}
/// Process the dumpsys info formatted in a table-like structure.
///
/// Currently this only pulls information from the "App Summary" subsection.
///
/// Example output:
///
/// ```
/// Applications Memory Usage (in Kilobytes):
/// Uptime: 441088659 Realtime: 521464097
///
/// ** MEMINFO in pid 16141 [io.flutter.demo.gallery] **
/// Pss Private Private SwapPss Heap Heap Heap
/// Total Dirty Clean Dirty Size Alloc Free
/// ------ ------ ------ ------ ------ ------ ------
/// Native Heap 8648 8620 0 16 20480 12403 8076
/// Dalvik Heap 547 424 40 18 2628 1092 1536
/// Dalvik Other 464 464 0 0
/// Stack 496 496 0 0
/// Ashmem 2 0 0 0
/// Gfx dev 212 204 0 0
/// Other dev 48 0 48 0
/// .so mmap 10770 708 9372 25
/// .apk mmap 240 0 0 0
/// .ttf mmap 35 0 32 0
/// .dex mmap 2205 4 1172 0
/// .oat mmap 64 0 0 0
/// .art mmap 4228 3848 24 2
/// Other mmap 20713 4 20704 0
/// GL mtrack 2380 2380 0 0
/// Unknown 43971 43968 0 1
/// TOTAL 95085 61120 31392 62 23108 13495 9612
///
/// App Summary
/// Pss(KB)
/// ------
/// Java Heap: 4296
/// Native Heap: 8620
/// Code: 11288
/// Stack: 496
/// Graphics: 2584
/// Private Other: 65228
/// System: 2573
///
/// TOTAL: 95085 TOTAL SWAP PSS: 62
///
/// Objects
/// Views: 9 ViewRootImpl: 1
/// AppContexts: 3 Activities: 1
/// Assets: 4 AssetManagers: 3
/// Local Binders: 10 Proxy Binders: 18
/// Parcel memory: 6 Parcel count: 24
/// Death Recipients: 0 OpenSSL Sockets: 0
/// WebViews: 0
///
/// SQL
/// MEMORY_USED: 0
/// PAGECACHE_OVERFLOW: 0 MALLOC_SIZE: 0
/// ...
/// ```
///
/// For more information, see https://developer.android.com/studio/command-line/dumpsys.
@visibleForTesting
AndroidMemoryInfo parseMeminfoDump(String input) {
final AndroidMemoryInfo androidMemoryInfo = AndroidMemoryInfo();
final List<String> lines = input.split('\n');
final String timelineData = lines.firstWhere((String line) =>
line.startsWith('${AndroidMemoryInfo._kUpTimeKey}: '));
final List<String> times = timelineData.trim().split('${AndroidMemoryInfo._kRealTimeKey}:');
androidMemoryInfo.realTime = int.tryParse(times.last.trim()) ?? 0;
lines
.skipWhile((String line) => !line.contains('App Summary'))
.takeWhile((String line) => !line.contains('TOTAL'))
.where((String line) => line.contains(':'))
.forEach((String line) {
final List<String> sections = line.trim().split(':');
final String key = sections.first.trim();
final int value = int.tryParse(sections.last.trim()) ?? 0;
switch (key) {
case AndroidMemoryInfo._kJavaHeapKey:
androidMemoryInfo.javaHeap = value;
break;
case AndroidMemoryInfo._kNativeHeapKey:
androidMemoryInfo.nativeHeap = value;
break;
case AndroidMemoryInfo._kCodeKey:
androidMemoryInfo.code = value;
break;
case AndroidMemoryInfo._kStackKey:
androidMemoryInfo.stack = value;
break;
case AndroidMemoryInfo._kGraphicsKey:
androidMemoryInfo.graphics = value;
break;
case AndroidMemoryInfo._kPrivateOtherKey:
androidMemoryInfo.privateOther = value;
break;
case AndroidMemoryInfo._kSystemKey:
androidMemoryInfo.system = value;
break;
}
});
return androidMemoryInfo;
}
/// Android specific implementation of memory info.
class AndroidMemoryInfo extends MemoryInfo {
static const String _kUpTimeKey = 'Uptime';
static const String _kRealTimeKey = 'Realtime';
static const String _kJavaHeapKey = 'Java Heap';
static const String _kNativeHeapKey = 'Native Heap';
static const String _kCodeKey = 'Code';
static const String _kStackKey = 'Stack';
static const String _kGraphicsKey = 'Graphics';
static const String _kPrivateOtherKey = 'Private Other';
static const String _kSystemKey = 'System';
static const String _kTotalKey = 'Total';
// Realtime is time since the system was booted includes deep sleep. Clock
// is monotonic, and ticks even when the CPU is in power saving modes.
int realTime = 0;
// Each measurement has KB as a unit.
int javaHeap = 0;
int nativeHeap = 0;
int code = 0;
int stack = 0;
int graphics = 0;
int privateOther = 0;
int system = 0;
@override
Map<String, Object> toJson() {
return <String, Object>{
'platform': 'Android',
_kRealTimeKey: realTime,
_kJavaHeapKey: javaHeap,
_kNativeHeapKey: nativeHeap,
_kCodeKey: code,
_kStackKey: stack,
_kGraphicsKey: graphics,
_kPrivateOtherKey: privateOther,
_kSystemKey: system,
_kTotalKey: javaHeap + nativeHeap + code + stack + graphics + privateOther + system,
};
}
}
/// A log reader that logs from `adb logcat`.
class AdbLogReader extends DeviceLogReader {
AdbLogReader._(this._adbProcess, this.name) {
_linesController = StreamController<String>.broadcast(
onListen: _start,
onCancel: _stop,
);
}
@visibleForTesting
factory AdbLogReader.test(Process adbProcess, String name) = AdbLogReader._;
/// Create a new [AdbLogReader] from an [AndroidDevice] instance.
static Future<AdbLogReader> createLogReader(
AndroidDevice device,
ProcessManager processManager,
{
bool includePastLogs = false,
}
) async {
// logcat -T is not supported on Android releases before Lollipop.
const int kLollipopVersionCode = 21;
final int apiVersion = (String v) {
// If the API version string isn't found, conservatively assume that the
// version is less recent than the one we're looking for.
return v == null ? kLollipopVersionCode - 1 : int.tryParse(v);
}(await device.apiVersion);
// Start the adb logcat process and filter the most recent logs since `lastTimestamp`.
// Some devices (notably LG) will only output logcat via shell
// https://github.com/flutter/flutter/issues/51853
final List<String> args = <String>[
'shell',
'-x',
'logcat',
'-v',
'time',
// If we include logs from the past, filter for 'flutter' logs only.
if (includePastLogs) ...<String>[
'-s',
'flutter',
] else if (apiVersion != null && apiVersion >= kLollipopVersionCode) ...<String>[
// Otherwise, filter for logs appearing past the present.
// '-T 0` means the timestamp of the logcat command invocation.
'-T',
if (device.lastLogcatTimestamp != null) '\'${device.lastLogcatTimestamp}\'' else '0',
],
];
final Process process = await processManager.start(device.adbCommandForDevice(args));
return AdbLogReader._(process, device.name);
}
final Process _adbProcess;
@override
final String name;
StreamController<String> _linesController;
@override
Stream<String> get logLines => _linesController.stream;
void _start() {
// We expect logcat streams to occasionally contain invalid utf-8,
// see: https://github.com/flutter/flutter/pull/8864.
const Utf8Decoder decoder = Utf8Decoder(reportErrors: false);
_adbProcess.stdout.transform<String>(decoder)
.transform<String>(const LineSplitter())
.listen(_onLine);
_adbProcess.stderr.transform<String>(decoder)
.transform<String>(const LineSplitter())
.listen(_onLine);
unawaited(_adbProcess.exitCode.whenComplete(() {
if (_linesController.hasListener) {
_linesController.close();
}
}));
}
// 'W/ActivityManager(pid): '
static final RegExp _logFormat = RegExp(r'^[VDIWEF]\/.*?\(\s*(\d+)\):\s');
static final List<RegExp> _allowedTags = <RegExp>[
RegExp(r'^[VDIWEF]\/flutter[^:]*:\s+', caseSensitive: false),
RegExp(r'^[IE]\/DartVM[^:]*:\s+'),
RegExp(r'^[WEF]\/AndroidRuntime:\s+'),
RegExp(r'^[WEF]\/AndroidRuntime\([0-9]+\):\s+'),
RegExp(r'^[WEF]\/ActivityManager:\s+.*(\bflutter\b|\bdomokit\b|\bsky\b)'),
RegExp(r'^[WEF]\/System\.err:\s+'),
RegExp(r'^[F]\/[\S^:]+:\s+'),
];
// 'F/libc(pid): Fatal signal 11'
static final RegExp _fatalLog = RegExp(r'^F\/libc\s*\(\s*\d+\):\sFatal signal (\d+)');
// 'I/DEBUG(pid): ...'
static final RegExp _tombstoneLine = RegExp(r'^[IF]\/DEBUG\s*\(\s*\d+\):\s(.+)$');
// 'I/DEBUG(pid): Tombstone written to: '
static final RegExp _tombstoneTerminator = RegExp(r'^Tombstone written to:\s');
// we default to true in case none of the log lines match
bool _acceptedLastLine = true;
// Whether a fatal crash is happening or not.
// During a fatal crash only lines from the crash are accepted, the rest are
// dropped.
bool _fatalCrash = false;
// The format of the line is controlled by the '-v' parameter passed to
// adb logcat. We are currently passing 'time', which has the format:
// mm-dd hh:mm:ss.milliseconds Priority/Tag( PID): ....
void _onLine(String line) {
// This line might be processed after the subscription is closed but before
// adb stops streaming logs.
if (_linesController.isClosed) {
return;
}
final Match timeMatch = AndroidDevice._timeRegExp.firstMatch(line);
if (timeMatch == null || line.length == timeMatch.end) {
_acceptedLastLine = false;
return;
}
// Chop off the time.
line = line.substring(timeMatch.end + 1);
final Match logMatch = _logFormat.firstMatch(line);
if (logMatch != null) {
bool acceptLine = false;
if (_fatalCrash) {
// While a fatal crash is going on, only accept lines from the crash
// Otherwise the crash log in the console may get interrupted
final Match fatalMatch = _tombstoneLine.firstMatch(line);
if (fatalMatch != null) {
acceptLine = true;
line = fatalMatch[1];
if (_tombstoneTerminator.hasMatch(fatalMatch[1])) {
// Hit crash terminator, stop logging the crash info
_fatalCrash = false;
}
}
} else if (appPid != null && int.parse(logMatch.group(1)) == appPid) {
acceptLine = true;
if (_fatalLog.hasMatch(line)) {
// Hit fatal signal, app is now crashing
_fatalCrash = true;
}
} else {
// Filter on approved names and levels.
acceptLine = _allowedTags.any((RegExp re) => re.hasMatch(line));
}
if (acceptLine) {
_acceptedLastLine = true;
_linesController.add(line);
return;
}
_acceptedLastLine = false;
} else if (line == '--------- beginning of system' ||
line == '--------- beginning of main') {
// hide the ugly adb logcat log boundaries at the start
_acceptedLastLine = false;
} else {
// If it doesn't match the log pattern at all, then pass it through if we
// passed the last matching line through. It might be a multiline message.
if (_acceptedLastLine) {
_linesController.add(line);
return;
}
}
}
void _stop() {
_linesController.close();
_adbProcess?.kill();
}
@override
void dispose() {
_stop();
}
}
/// A [DevicePortForwarder] implemented for Android devices that uses adb.
class AndroidDevicePortForwarder extends DevicePortForwarder {
AndroidDevicePortForwarder({
@required ProcessManager processManager,
@required Logger logger,
@required String deviceId,
@required String adbPath,
}) : _deviceId = deviceId,
_adbPath = adbPath,
_logger = logger,
_processUtils = ProcessUtils(logger: logger, processManager: processManager);
final String _deviceId;
final String _adbPath;
final Logger _logger;
final ProcessUtils _processUtils;
static int _extractPort(String portString) {
return int.tryParse(portString.trim());
}
@override
List<ForwardedPort> get forwardedPorts {
final List<ForwardedPort> ports = <ForwardedPort>[];
String stdout;
try {
stdout = _processUtils.runSync(
<String>[
_adbPath,
'-s',
_deviceId,
'forward',
'--list',
],
throwOnError: true,
).stdout.trim();
} on ProcessException catch (error) {
_logger.printError('Failed to list forwarded ports: $error.');
return ports;
}
final List<String> lines = LineSplitter.split(stdout).toList();
for (final String line in lines) {
if (!line.startsWith(_deviceId)) {
continue;
}
final List<String> splitLine = line.split('tcp:');
// Sanity check splitLine.
if (splitLine.length != 3) {
continue;
}
// Attempt to extract ports.
final int hostPort = _extractPort(splitLine[1]);
final int devicePort = _extractPort(splitLine[2]);
// Failed, skip.
if (hostPort == null || devicePort == null) {
continue;
}
ports.add(ForwardedPort(hostPort, devicePort));
}
return ports;
}
@override
Future<int> forward(int devicePort, { int hostPort }) async {
hostPort ??= 0;
final RunResult process = await _processUtils.run(
<String>[
_adbPath,
'-s',
_deviceId,
'forward',
'tcp:$hostPort',
'tcp:$devicePort',
],
throwOnError: true,
);
if (process.stderr.isNotEmpty) {
process.throwException('adb returned error:\n${process.stderr}');
}
if (process.exitCode != 0) {
if (process.stdout.isNotEmpty) {
process.throwException('adb returned error:\n${process.stdout}');
}
process.throwException('adb failed without a message');
}
if (hostPort == 0) {
if (process.stdout.isEmpty) {
process.throwException('adb did not report forwarded port');
}
hostPort = int.tryParse(process.stdout);
if (hostPort == null) {
process.throwException('adb returned invalid port number:\n${process.stdout}');
}
} else {
// stdout may be empty or the port we asked it to forward, though it's
// not documented (or obvious) what triggers each case.
//
// Observations are:
// - On MacOS it's always empty when Flutter spawns the process, but
// - On MacOS it prints the port number when run from the terminal, unless
// the port is already forwarded, when it also prints nothing.
// - On ChromeOS, the port appears to be printed even when Flutter spawns
// the process
//
// To cover all cases, we accept the output being either empty or exactly
// the port number, but treat any other output as probably being an error
// message.
if (process.stdout.isNotEmpty && process.stdout.trim() != '$hostPort') {
process.throwException('adb returned error:\n${process.stdout}');
}
}
return hostPort;
}
@override
Future<void> unforward(ForwardedPort forwardedPort) async {
final String tcpLine = 'tcp:${forwardedPort.hostPort}';
final RunResult runResult = await _processUtils.run(
<String>[
_adbPath,
'-s',
_deviceId,
'forward',
'--remove',
tcpLine,
],
throwOnError: false,
);
// The port may have already been unforwarded, for example if there
// are multiple attach process already connected.
if (runResult.exitCode == 0 || runResult
.stderr.contains("listener '$tcpLine' not found")) {
return;
}
runResult.throwException('Process exited abnormally:\n$runResult');
}
@override
Future<void> dispose() async {
for (final ForwardedPort port in forwardedPorts) {
await unforward(port);
}
}
}