// 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 '../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 '../project.dart'; import '../protocol_discovery.dart'; import 'android.dart'; import 'android_builder.dart'; import 'android_console.dart'; import 'android_sdk.dart'; import 'application_package.dart'; /// 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 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, }; /// A physical Android device or emulator. /// /// While [isEmulator] 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 Logger 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 { Map<String, String> 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 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(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, ...) 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')) { return TargetPlatform.android_arm64; } else { return TargetPlatform.android_arm; } case 'x86_64': return TargetPlatform.android_x64; case 'x86': return TargetPlatform.android_x86; default: return TargetPlatform.android_arm; } }(); @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; 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: 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: '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.applicationPackage.path}.sha1'); return shaFile.existsSync() ? shaFile.readAsStringSync() : ''; } @override String get name => modelID; @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; } final bool wasInstalled = await isAppInstalled(app, userIdentifier: userIdentifier); if (wasInstalled && await isLatestBuildInstalled(app)) { _logger.printTrace('Latest build already installed.'); return true; } _logger.printTrace('Installing APK.'); if (await _installApp(app, userIdentifier: userIdentifier)) { return true; } _logger.printTrace('Warning: Failed to install APK.'); if (!wasInstalled) { 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 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( 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 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; } // 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; if (devicePlatform == TargetPlatform.android_x86 && !debuggingOptions.buildInfo.isDebug) { _logger.printError('Profile and release builds are only supported on ARM/x64 targets.'); return LaunchResult.failed(); } AndroidApk? 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_x86: androidArch = AndroidArch.x86; 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: _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, multidexEnabled: (platformArgs['multidex'] as bool?) ?? false, ), ); // 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 dipose at the end. creating a new logger here allows // logs to be surfaced normally during `flutter drive`. await AdbLogReader.createLogReader( this, _processManager, ), portForwarder: portForwarder, hostPort: debuggingOptions.hostVmServicePort, devicePort: debuggingOptions.deviceVmServicePort, ipv6: ipv6, logger: _logger, ); } final String dartVmFlags = computeDartVmFlags(debuggingOptions); final String? traceAllowlist = debuggingOptions.traceAllowlist; final String? traceSkiaAllowlist = debuggingOptions.traceSkiaAllowlist; final List<String> 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 (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 (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.enableImpeller == ImpellerStatus.enabled) ...<String>['--ez', 'enable-impeller', 'true'], if (debuggingOptions.enableImpeller == ImpellerStatus.disabled) ...<String>['--ez', 'enable-impeller', 'false'], if (debuggingOptions.enableVulkanValidation) ...<String>['--ez', 'enable-vulkan-validation', 'true'], if (debuggingOptions.impellerForceGL) ...<String>['--ez', 'impeller-force-gl', '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], ], 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() { _processUtils.runSync(adbCommandForDevice(<String>['logcat', '-c'])); } @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, includePastLogs: true, ); } else { return _logReader ??= await AdbLogReader.createLogReader( this, _processManager, ); } } @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 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. @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 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(); } } 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; 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 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); @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 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.name); } final Process _adbProcess; @override final String name; late final StreamController<String> _linesController = StreamController<String>.broadcast( onListen: _start, onCancel: _stop, ); @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+'), ]; // 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. static final RegExp _surfaceSyncerSpam = RegExp(r'^E/SurfaceSyncer\(\s*\d+\): Failed to find sync for id=\d+'); // '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(line)) { // Hit crash terminator, stop logging the crash info _fatalCrash = false; } } } else if (appPid != null && int.parse(logMatch.group(1)!) == appPid) { acceptLine = !_surfaceSyncerSpam.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 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, ], ); 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; }