Reid Baker f15262c9f7
[android][tool] Consolidate minimum versions for android projects. (#171965)
I discovered we had a duplicate entry for what the min sdk api level
flutter supports along with duplicate code for telling the user.
This pr keeps the duplicate warnings but removed duplicate definitions. 
Updates the related tests
Updates workflow_test and android_workflow to compare against the
compile sdk version since the validation was ensuring that the lastest
of the installed sdks was at least what was needed for flutter to
compile. This make this pr more than a formatting/refactoring change and
an actual user facing change. Requesting re-review.
## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [ ] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [ ] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.
2025-08-07 14:12:43 +00:00

1407 lines
48 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 'package:vm_service/vm_service.dart';
import '../application_package.dart';
import '../base/common.dart' show throwToolExit;
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 '../device_port_forwarder.dart';
import '../device_vm_service_discovery_for_attach.dart';
import '../project.dart';
import '../protocol_discovery.dart';
import '../vmservice.dart';
import 'android_builder.dart';
import 'android_console.dart';
import 'android_sdk.dart';
import 'application_package.dart';
import 'gradle_utils.dart' as gradle_utils;
/// Whether the [AndroidDevice] is believed to be a physical device or an emulator.
enum HardwareType { emulator, physical }
/// Map to help our `isLocalEmulator` detection.
///
/// See [AndroidDevice] for more explanation of why this is needed.
const 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,
};
/// A physical Android device or emulator.
///
/// While [isLocalEmulator] attempts to distinguish between the device categories,
/// this is a best effort process and not a guarantee; certain physical devices
/// identify as emulators. These device identifiers may be added to the [kKnownHardware]
/// map to specify that they are actually physical devices.
class AndroidDevice extends Device {
AndroidDevice(
super.id, {
this.productID,
required this.modelID,
this.deviceCodeName,
required super.logger,
required ProcessManager processManager,
required Platform platform,
required AndroidSdk androidSdk,
required FileSystem fileSystem,
AndroidConsoleSocketFactory androidConsoleSocketFactory = kAndroidConsoleSocketFactory,
}) : _logger = logger,
_processManager = processManager,
_androidSdk = androidSdk,
_platform = platform,
_fileSystem = fileSystem,
_androidConsoleSocketFactory = androidConsoleSocketFactory,
_processUtils = ProcessUtils(logger: logger, processManager: processManager),
super(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 String? productID;
final String modelID;
final String? deviceCodeName;
@override
// Wirelessly paired Android devices should have `adb-tls-connect` in the id.
// Source: https://android.googlesource.com/platform/packages/modules/adb/+/f4ba8d73079b99532069dbe888a58167b8723d6c/adb_mdns.h#30
DeviceConnectionInterface get connectionInterface => id.contains('adb-tls-connect')
? DeviceConnectionInterface.wireless
: DeviceConnectionInterface.attached;
late final Future<Map<String, String>> _properties = () async {
var 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;
}();
Future<String?> _getProperty(String name) async {
return (await _properties)[name];
}
@override
late final Future<bool> isLocalEmulator = () async {
final String? hardware = await _getProperty('ro.hardware');
_logger.printTrace('ro.hardware = $hardware');
if (kKnownHardware.containsKey(hardware)) {
// Look for known hardware models.
return kKnownHardware[hardware] == HardwareType.emulator;
}
// Fall back to a best-effort heuristic-based approach.
final String? characteristics = await _getProperty('ro.build.characteristics');
_logger.printTrace('ro.build.characteristics = $characteristics');
return characteristics != null && characteristics.contains('emulator');
}();
/// 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 emulatorPortRegex = RegExp(r'emulator-(\d+)');
final Match? portMatch = emulatorPortRegex.firstMatch(id);
if (portMatch == null || portMatch.groupCount < 1) {
return null;
}
const 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 console = AndroidConsole(socket);
try {
await console.connect().timeout(
const Duration(seconds: 2),
onTimeout: () => throw TimeoutException('Connection timed out'),
);
return await console.getAvdName().timeout(
const Duration(seconds: 2),
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
late final Future<TargetPlatform> targetPlatform = () async {
// http://developer.android.com/ndk/guides/abis.html (x86, armeabi-v7a, ...)
final String? abi = await _getProperty('ro.product.cpu.abi');
switch (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')) {
return TargetPlatform.android_arm64;
} else {
return TargetPlatform.android_arm;
}
case 'armeabi-v7a':
return TargetPlatform.android_arm;
case 'x86_64':
return TargetPlatform.android_x64;
default:
return TargetPlatform.unsupported;
}
}();
@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:
case TargetPlatform.darwin:
case TargetPlatform.fuchsia_arm64:
case TargetPlatform.fuchsia_x64:
case TargetPlatform.ios:
case TargetPlatform.linux_arm64:
case TargetPlatform.linux_x64:
case TargetPlatform.tester:
case TargetPlatform.web_javascript:
case TargetPlatform.windows_x64:
case TargetPlatform.windows_arm64:
case TargetPlatform.unsupported:
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;
List<String> adbCommandForDevice(List<String> args) {
return <String>[_androidSdk.adbPath!, '-s', id, ...args];
}
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 {
final String? adbPath = _androidSdk.adbPath;
if (adbPath == null) {
return false;
}
try {
final RunResult adbVersion = await _processUtils.run(<String>[
adbPath,
'version',
], throwOnError: true);
if (_isValidAdbVersion(adbVersion.stdout)) {
return true;
}
_logger.printError(
'The ADB at "$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 {
final String? adbPath = _androidSdk.adbPath;
if (adbPath == null) {
return false;
}
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>[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: '24'
final String sdkVersion =
await _getProperty('ro.build.version.sdk') ?? gradle_utils.minSdkVersion;
final int? sdkVersionParsed = int.tryParse(sdkVersion);
if (sdkVersionParsed == null) {
_logger.printError('Unexpected response from getprop: "$sdkVersion"');
return false;
}
if (sdkVersionParsed < gradle_utils.minSdkVersionInt) {
_logger.printError(
'The Android version ($sdkVersion) on the target device is too old. Please '
'use a API ${gradle_utils.minSdkVersion} 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.applicationPackage.path}.sha1');
return shaFile.existsSync() ? shaFile.readAsStringSync() : '';
}
@override
String get name => modelID;
@override
bool get supportsFlavors => true;
@override
Future<bool> isAppInstalled(ApplicationPackage 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(covariant AndroidApk app) async {
final String installedSha1 = await _getDeviceApkSha1(app);
return installedSha1.isNotEmpty && installedSha1 == _getSourceSha1(app);
}
@override
Future<bool> installApp(covariant AndroidApk app, {String? userIdentifier}) async {
if (!await _adbIsValid) {
return false;
}
_logger.printTrace('Installing APK.');
if (await _installApp(app, userIdentifier: userIdentifier)) {
return true;
}
_logger.printTrace('Warning: Failed to install APK.');
if (!await isAppInstalled(app, userIdentifier: userIdentifier)) {
return false;
}
_logger.printStatus('Uninstalling old version...');
if (!await uninstallApp(app, userIdentifier: userIdentifier)) {
_logger.printError('Error: Uninstalling old version failed.');
return false;
}
if (!await _installApp(app, userIdentifier: userIdentifier)) {
_logger.printError('Error: Failed to install APK again.');
return false;
}
return true;
}
Future<bool> _installApp(AndroidApk app, {String? userIdentifier}) async {
if (!app.applicationPackage.existsSync()) {
_logger.printError(
'"${_fileSystem.path.relative(app.applicationPackage.path)}" does not exist.',
);
return false;
}
final Status status = _logger.startProgress(
'Installing ${_fileSystem.path.relative(app.applicationPackage.path)}...',
);
final RunResult installResult = await _processUtils.run(
adbCommandForDevice(<String>[
'install',
'-t',
'-r',
if (userIdentifier != null) ...<String>['--user', userIdentifier],
app.applicationPackage.path,
]),
);
status.stop();
// Some versions of adb exit with exit code 0 even on failure :(
// Parsing the output to check for failures.
final 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(ApplicationPackage app, {String? userIdentifier}) async {
if (!await _adbIsValid) {
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 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;
}
// Whether the adb and Android versions are aligned.
late final Future<bool> _adbIsValid = () async {
return await _checkForSupportedAdbVersion() && await _checkForSupportedAndroidVersion();
}();
AndroidApk? _package;
@override
Future<LaunchResult> startApp(
AndroidApk? package, {
String? mainPath,
String? route,
required DebuggingOptions debuggingOptions,
Map<String, Object?> platformArgs = const <String, Object>{},
bool prebuiltApplication = false,
bool ipv6 = false,
String? userIdentifier,
}) async {
if (!await _adbIsValid) {
return LaunchResult.failed();
}
final TargetPlatform devicePlatform = await targetPlatform;
var builtPackage = package;
AndroidArch androidArch;
switch (devicePlatform) {
case TargetPlatform.android_arm:
androidArch = AndroidArch.armeabi_v7a;
case TargetPlatform.android_arm64:
androidArch = AndroidArch.arm64_v8a;
case TargetPlatform.android_x64:
androidArch = AndroidArch.x86_64;
case TargetPlatform.android:
case TargetPlatform.darwin:
case TargetPlatform.fuchsia_arm64:
case TargetPlatform.fuchsia_x64:
case TargetPlatform.ios:
case TargetPlatform.linux_arm64:
case TargetPlatform.linux_x64:
case TargetPlatform.tester:
case TargetPlatform.web_javascript:
case TargetPlatform.windows_arm64:
case TargetPlatform.windows_x64:
case TargetPlatform.unsupported:
_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 ?? 'lib/main.dart',
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.
builtPackage =
await ApplicationPackageFactory.instance!.getPackageForPlatform(
devicePlatform,
buildInfo: debuggingOptions.buildInfo,
)
as AndroidApk?;
}
// There was a failure parsing the android project information.
if (builtPackage == null) {
throwToolExit('Problem building Android application: see above error(s).');
}
_logger.printTrace("Stopping app '${builtPackage.name}' on $name.");
await stopApp(builtPackage, userIdentifier: userIdentifier);
if (!await installApp(builtPackage, userIdentifier: userIdentifier)) {
return LaunchResult.failed();
}
final bool traceStartup = platformArgs['trace-startup'] as bool? ?? false;
ProtocolDiscovery? vmServiceDiscovery;
if (debuggingOptions.debuggingEnabled) {
vmServiceDiscovery = ProtocolDiscovery.vmService(
// Avoid using getLogReader, which returns a singleton instance, because the
// VM Service discovery will dispose at the end. creating a new logger here allows
// logs to be surfaced normally during `flutter drive`.
await AdbLogReader.createLogReader(this, _processManager, _logger),
portForwarder: portForwarder,
hostPort: debuggingOptions.hostVmServicePort,
devicePort: debuggingOptions.deviceVmServicePort,
ipv6: ipv6,
logger: _logger,
);
}
final String? traceAllowlist = debuggingOptions.traceAllowlist;
final String? traceSkiaAllowlist = debuggingOptions.traceSkiaAllowlist;
final String? traceToFile = debuggingOptions.traceToFile;
final cmd = <String>[
'shell', 'am', 'start',
'-a', 'android.intent.action.MAIN',
'-c', 'android.intent.category.LAUNCHER',
'-f', '0x20000000', // FLAG_ACTIVITY_SINGLE_TOP
if (debuggingOptions.enableDartProfiling) ...<String>[
'--ez',
'enable-dart-profiling',
'true',
],
if (debuggingOptions.profileStartup) ...<String>['--ez', 'profile-startup', '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 (traceAllowlist != null) ...<String>['--es', 'trace-allowlist', traceAllowlist],
if (traceSkiaAllowlist != null) ...<String>[
'--es',
'trace-skia-allowlist',
traceSkiaAllowlist,
],
if (debuggingOptions.traceSystrace) ...<String>['--ez', 'trace-systrace', 'true'],
if (traceToFile != null) ...<String>['--es', 'trace-to-file', traceToFile],
if (debuggingOptions.endlessTraceBuffer) ...<String>['--ez', 'endless-trace-buffer', 'true'],
if (debuggingOptions.profileMicrotasks) ...<String>['--ez', 'profile-microtasks', 'true'],
if (debuggingOptions.purgePersistentCache) ...<String>[
'--ez',
'purge-persistent-cache',
'true',
],
if (debuggingOptions.enableImpeller == ImpellerStatus.enabled) ...<String>[
'--ez',
'enable-impeller',
'true',
],
if (debuggingOptions.enableImpeller == ImpellerStatus.disabled) ...<String>[
'--ez',
'enable-impeller',
'false',
],
if (debuggingOptions.enableFlutterGpu) ...<String>['--ez', 'enable-flutter-gpu', 'true'],
if (debuggingOptions.enableVulkanValidation) ...<String>[
'--ez',
'enable-vulkan-validation',
'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 (debuggingOptions.dartFlags.isNotEmpty) ...<String>[
'--es',
'dart-flags',
debuggingOptions.dartFlags,
],
if (debuggingOptions.useTestFonts) ...<String>['--ez', 'use-test-fonts', 'true'],
if (debuggingOptions.verboseSystemLogs) ...<String>['--ez', 'verbose-logging', 'true'],
if (userIdentifier != null) ...<String>['--user', userIdentifier],
],
builtPackage.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 = builtPackage;
if (!debuggingOptions.debuggingEnabled) {
return LaunchResult.succeeded();
}
// Wait for the service protocol port here. This will complete once the
// device has printed "VM Service is listening on...".
_logger.printTrace('Waiting for VM Service port to be available...');
try {
Uri? vmServiceUri;
if (debuggingOptions.buildInfo.isDebug || debuggingOptions.buildInfo.isProfile) {
vmServiceUri = await vmServiceDiscovery?.uri;
if (vmServiceUri == null) {
_logger.printError(
'Error waiting for a debug connection: '
'The log reader stopped unexpectedly',
);
return LaunchResult.failed();
}
}
return LaunchResult.succeeded(vmServiceUri: vmServiceUri);
} on Exception catch (error) {
_logger.printError('Error waiting for a debug connection: $error');
return LaunchResult.failed();
} finally {
await vmServiceDiscovery?.cancel();
}
}
@override
bool get supportsHotReload => true;
@override
bool get supportsHotRestart => true;
@override
bool get supportsFastStart => true;
@override
Future<bool> stopApp(ApplicationPackage? app, {String? userIdentifier}) async {
if (app == null) {
return 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 AndroidApk? package = _package;
if (package == null) {
_logger.printError('Android package unknown, skipping dumpsys meminfo.');
return const MemoryInfo.empty();
}
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() {
final RunResult result = _processUtils.runSync(adbCommandForDevice(<String>['logcat', '-c']));
// Do not log to standard error because that causes test to fail.
if (result.exitCode != 0) {
_logger.printTrace(
'"adb logcat -c" failed: exitCode: ${result.exitCode}'
' stdout: ${result.stdout} stderr: ${result.stderr}',
);
}
}
@override
FutureOr<DeviceLogReader> getLogReader({
ApplicationPackage? 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,
_logger,
includePastLogs: true,
);
} else {
return _logReader ??= await AdbLogReader.createLogReader(this, _processManager, _logger);
}
}
@override
VMServiceDiscoveryForAttach getVMServiceDiscoveryForAttach({
String? appId,
String? fuchsiaModule,
int? filterDevicePort,
int? expectedHostPort,
required bool ipv6,
required Logger logger,
}) => LogScanningVMServiceDiscoveryForAttach(
// If it's an Android device, attaching relies on past log searching
// to find the service protocol.
Future<DeviceLogReader>.value(getLogReader(includePastLogs: true)),
portForwarder: portForwarder,
ipv6: ipv6,
devicePort: filterDevicePort,
hostPort: expectedHostPort,
logger: logger,
);
@override
late final DevicePortForwarder? portForwarder = () {
final String? adbPath = _androidSdk.adbPath;
if (adbPath == null) {
return null;
}
return AndroidDevicePortForwarder(
processManager: _processManager,
logger: _logger,
deviceId: id,
adbPath: adbPath,
);
}();
static final _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.
@visibleForTesting
Future<String?> lastLogcatTimestamp() async {
RunResult output;
try {
output = await runAdbCheckedAsync(<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.stdout);
return timeMatch?.group(0);
}
@override
Future<bool> isSupported() async {
final TargetPlatform platform = await targetPlatform;
return switch (platform) {
TargetPlatform.android ||
TargetPlatform.android_arm ||
TargetPlatform.android_arm64 ||
TargetPlatform.android_x64 => true,
_ => false,
};
}
@override
bool get supportsScreenshot => true;
@override
Future<void> takeScreenshot(File outputFile) async {
const 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();
}
}
Map<String, String> parseAdbDeviceProperties(String str) {
final properties = <String, String>{};
final 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:
///
/// ```none
/// 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();
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;
case AndroidMemoryInfo._kNativeHeapKey:
androidMemoryInfo.nativeHeap = value;
case AndroidMemoryInfo._kCodeKey:
androidMemoryInfo.code = value;
case AndroidMemoryInfo._kStackKey:
androidMemoryInfo.stack = value;
case AndroidMemoryInfo._kGraphicsKey:
androidMemoryInfo.graphics = value;
case AndroidMemoryInfo._kPrivateOtherKey:
androidMemoryInfo.privateOther = value;
case AndroidMemoryInfo._kSystemKey:
androidMemoryInfo.system = value;
}
});
return androidMemoryInfo;
}
/// Android specific implementation of memory info.
class AndroidMemoryInfo extends MemoryInfo {
static const _kUpTimeKey = 'Uptime';
static const _kRealTimeKey = 'Realtime';
static const _kJavaHeapKey = 'Java Heap';
static const _kNativeHeapKey = 'Native Heap';
static const _kCodeKey = 'Code';
static const _kStackKey = 'Stack';
static const _kGraphicsKey = 'Graphics';
static const _kPrivateOtherKey = 'Private Other';
static const _kSystemKey = 'System';
static const _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.
var realTime = 0;
// Each measurement has KB as a unit.
var javaHeap = 0;
var nativeHeap = 0;
var code = 0;
var stack = 0;
var graphics = 0;
var privateOther = 0;
var 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, this._logger);
@visibleForTesting
factory AdbLogReader.test(Process adbProcess, String name, Logger logger) = AdbLogReader._;
/// Create a new [AdbLogReader] from an [AndroidDevice] instance.
static Future<AdbLogReader> createLogReader(
AndroidDevice device,
ProcessManager processManager,
Logger logger, {
bool includePastLogs = false,
}) async {
// logcat -T is not supported on Android releases before Lollipop.
const 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 args = <String>['shell', '-x', 'logcat', '-v', 'time'];
// If past logs are included then filter for 'flutter' logs only.
if (includePastLogs) {
args.addAll(<String>['-s', 'flutter']);
} else if (apiVersion != null && apiVersion >= kLollipopVersionCode) {
// Otherwise, filter for logs appearing past the present.
// '-T 0` means the timestamp of the logcat command invocation.
final String? lastLogcatTimestamp = await device.lastLogcatTimestamp();
args.addAll(<String>[
'-T',
if (lastLogcatTimestamp != null) "'$lastLogcatTimestamp'" else '0',
]);
}
final Process process = await processManager.start(device.adbCommandForDevice(args));
return AdbLogReader._(process, device.displayName, logger);
}
int? _appPid;
final Process _adbProcess;
final Logger _logger;
@override
final String name;
late final _linesController = StreamController<String>.broadcast(
onListen: _start,
onCancel: _stop,
);
@override
Stream<String> get logLines => _linesController.stream;
@override
Future<void> provideVmService(FlutterVmService connectedVmService) async {
final VM? vm = await connectedVmService.getVmGuarded();
if (vm == null) {
_logger.printError(
'An error occurred when setting up filtering for adb logs. '
'Unable to communicate with the VM service.',
);
} else {
_appPid = vm.pid;
}
}
void _start() {
// We expect logcat streams to occasionally contain invalid utf-8,
// see: https://github.com/flutter/flutter/pull/8864.
const 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 _logFormat = RegExp(r'^[VDIWEF]\/.*?\(\s*(\d+)\):\s');
static final _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+'),
];
static final _filteredMessagees = <RegExp>[
// E/SurfaceSyncer(22636): Failed to find sync for id=9
// Some versions of Android spew this out. It is inactionable to the end user
// and causes no problems for the application.
RegExp(r'^E/SurfaceSyncer\(\s*\d+\): Failed to find sync for id=\d+'),
// See https://github.com/flutter/flutter/issues/160598
RegExp(r'ViewPostIme pointer'),
RegExp(r'mali.instrumentation.graph.work'),
];
// 'F/libc(pid): Fatal signal 11'
static final _fatalLog = RegExp(r'^F\/libc\s*\(\s*\d+\):\sFatal signal (\d+)');
// 'I/DEBUG(pid): ...'
static final _tombstoneLine = RegExp(r'^[IF]\/DEBUG\s*\(\s*\d+\):\s(.+)$');
// 'I/DEBUG(pid): Tombstone written to: '
static final _tombstoneTerminator = RegExp(r'^Tombstone written to:\s');
// we default to true in case none of the log lines match
var _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.
var _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) {
var 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(line)) {
// Hit crash terminator, stop logging the crash info
_fatalCrash = false;
}
}
} else if (_appPid != null && int.parse(logMatch.group(1)!) == _appPid) {
acceptLine = !_filteredMessagees.any((RegExp e) => e.hasMatch(line));
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 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 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 tcpLine = 'tcp:${forwardedPort.hostPort}';
final RunResult runResult = await _processUtils.run(<String>[
_adbPath,
'-s',
_deviceId,
'forward',
'--remove',
tcpLine,
]);
if (runResult.exitCode == 0) {
return;
}
_logger.printError('Failed to unforward port: $runResult');
}
@override
Future<void> dispose() async {
for (final ForwardedPort port in forwardedPorts) {
await unforward(port);
}
}
}
// In platform tools 29.0.0 adb.exe seems to be ending with this heap
// corruption error code on seemingly successful termination. Ignore
// this error on windows.
bool _allowHeapCorruptionOnWindows(int exitCode, Platform platform) {
return exitCode == -1073740940 && platform.isWindows;
}