// 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:collection'; import 'package:meta/meta.dart'; import 'package:process/process.dart'; import 'package:xml/xml.dart'; import '../application_package.dart'; import '../base/common.dart'; import '../base/file_system.dart'; import '../base/io.dart'; import '../base/logger.dart'; import '../base/process.dart'; import '../base/user_messages.dart'; import '../build_info.dart'; import '../project.dart'; import 'android_sdk.dart'; import 'gradle.dart'; /// An application package created from an already built Android APK. class AndroidApk extends ApplicationPackage implements PrebuiltApplicationPackage { AndroidApk({ required super.id, required this.applicationPackage, required this.versionCode, required this.launchActivity, }); /// Creates a new AndroidApk from an existing APK. /// /// Returns `null` if the APK was invalid or any required tooling was missing. static AndroidApk? fromApk( File apk, { required AndroidSdk androidSdk, required ProcessManager processManager, required UserMessages userMessages, required Logger logger, required ProcessUtils processUtils, }) { final String? aaptPath = androidSdk.latestVersion?.aaptPath; if (aaptPath == null || !processManager.canRun(aaptPath)) { logger.printError(userMessages.aaptNotFound); return null; } String apptStdout; try { apptStdout = processUtils.runSync( <String>[ aaptPath, 'dump', 'xmltree', apk.path, 'AndroidManifest.xml', ], throwOnError: true, ).stdout.trim(); } on ProcessException catch (error) { logger.printError('Failed to extract manifest from APK: $error.'); return null; } final ApkManifestData? data = ApkManifestData.parseFromXmlDump(apptStdout, logger); if (data == null) { logger.printError('Unable to read manifest info from ${apk.path}.'); return null; } final String? packageName = data.packageName; if (packageName == null || data.launchableActivityName == null) { logger.printError('Unable to read manifest info from ${apk.path}.'); return null; } return AndroidApk( id: packageName, applicationPackage: apk, versionCode: data.versionCode == null ? null : int.tryParse(data.versionCode!), launchActivity: '${data.packageName}/${data.launchableActivityName}', ); } @override final FileSystemEntity applicationPackage; /// The path to the activity that should be launched. final String launchActivity; /// The version code of the APK. final int? versionCode; /// Creates a new AndroidApk based on the information in the Android manifest. static Future<AndroidApk?> fromAndroidProject( AndroidProject androidProject, { required AndroidSdk? androidSdk, required ProcessManager processManager, required UserMessages userMessages, required ProcessUtils processUtils, required Logger logger, required FileSystem fileSystem, BuildInfo? buildInfo, }) async { final File apkFile; final String filename; if (buildInfo == null) { filename = 'app.apk'; } else if (buildInfo.flavor == null) { filename = 'app-${buildInfo.mode.cliName}.apk'; } else { filename = 'app-${buildInfo.lowerCasedFlavor}-${buildInfo.mode.cliName}.apk'; } if (androidProject.isUsingGradle && androidProject.isSupportedVersion) { Directory apkDirectory = getApkDirectory(androidProject.parent); if (androidProject.parent.isModule) { // Module builds output the apk in a subdirectory that corresponds // to the buildmode of the apk. apkDirectory = apkDirectory.childDirectory(buildInfo!.mode.cliName); } apkFile = apkDirectory.childFile(filename); if (apkFile.existsSync()) { // Grab information from the .apk. The gradle build script might alter // the application Id, so we need to look at what was actually built. return AndroidApk.fromApk( apkFile, androidSdk: androidSdk!, processManager: processManager, logger: logger, userMessages: userMessages, processUtils: processUtils, ); } // The .apk hasn't been built yet, so we work with what we have. The run // command will grab a new AndroidApk after building, to get the updated // IDs. } else { apkFile = fileSystem.file(fileSystem.path.join(getAndroidBuildDirectory(), filename)); } final File manifest = androidProject.appManifestFile; if (!manifest.existsSync()) { logger.printError('AndroidManifest.xml could not be found.'); logger.printError('Please check ${manifest.path} for errors.'); return null; } final String manifestString = manifest.readAsStringSync(); XmlDocument document; try { document = XmlDocument.parse(manifestString); } on XmlException catch (exception) { String manifestLocation; if (androidProject.isUsingGradle) { manifestLocation = fileSystem.path.join(androidProject.hostAppGradleRoot.path, 'app', 'src', 'main', 'AndroidManifest.xml'); } else { manifestLocation = fileSystem.path.join(androidProject.hostAppGradleRoot.path, 'AndroidManifest.xml'); } logger.printError('AndroidManifest.xml is not a valid XML document.'); logger.printError('Please check $manifestLocation for errors.'); throwToolExit('XML Parser error message: $exception'); } final Iterable<XmlElement> manifests = document.findElements('manifest'); if (manifests.isEmpty) { logger.printError('AndroidManifest.xml has no manifest element.'); logger.printError('Please check ${manifest.path} for errors.'); return null; } // Starting from AGP version 7.3, the `package` attribute in Manifest.xml // can be replaced with the `namespace` attribute under the `android` section in `android/app/build.gradle`. final String? packageId = manifests.first.getAttribute('package') ?? androidProject.namespace; String? launchActivity; for (final XmlElement activity in document.findAllElements('activity')) { final String? enabled = activity.getAttribute('android:enabled'); if (enabled != null && enabled == 'false') { continue; } for (final XmlElement element in activity.findElements('intent-filter')) { String? actionName = ''; String? categoryName = ''; for (final XmlNode node in element.children) { if (node is! XmlElement) { continue; } final String? name = node.getAttribute('android:name'); if (name == 'android.intent.action.MAIN') { actionName = name; } else if (name == 'android.intent.category.LAUNCHER') { categoryName = name; } } if (actionName != null && categoryName != null && actionName.isNotEmpty && categoryName.isNotEmpty) { final String? activityName = activity.getAttribute('android:name'); launchActivity = '$packageId/$activityName'; break; } } } if (packageId == null || launchActivity == null) { logger.printError('package identifier or launch activity not found.'); logger.printError('Please check ${manifest.path} for errors.'); return null; } return AndroidApk( id: packageId, applicationPackage: apkFile, versionCode: null, launchActivity: launchActivity, ); } @override String get name => applicationPackage.basename; } abstract class _Entry { const _Entry(this.parent, this.level); final _Element? parent; final int level; } class _Element extends _Entry { _Element._(this.name, _Element? parent, int level) : super(parent, level); factory _Element.fromLine(String line, _Element? parent) { // E: application (line=29) final List<String> parts = line.trimLeft().split(' '); return _Element._(parts[1], parent, line.length - line.trimLeft().length); } final List<_Entry> children = <_Entry>[]; final String? name; void addChild(_Entry child) { children.add(child); } _Attribute? firstAttribute(String name) { for (final _Attribute child in children.whereType<_Attribute>()) { if (child.key?.startsWith(name) ?? false) { return child; } } return null; } _Element? firstElement(String name) { for (final _Element child in children.whereType<_Element>()) { if (child.name?.startsWith(name) ?? false) { return child; } } return null; } Iterable<_Element> allElements(String name) { return children.whereType<_Element>().where((_Element e) => e.name?.startsWith(name) ?? false); } } class _Attribute extends _Entry { const _Attribute._(this.key, this.value, _Element? parent, int level) : super(parent, level); factory _Attribute.fromLine(String line, _Element parent) { // A: android:label(0x01010001)="hello_world" (Raw: "hello_world") const String attributePrefix = 'A: '; final List<String> keyVal = line.substring(line.indexOf(attributePrefix) + attributePrefix.length).split('='); return _Attribute._(keyVal[0], keyVal[1], parent, line.length - line.trimLeft().length); } final String? key; final String? value; } class ApkManifestData { ApkManifestData._(this._data); static bool _isAttributeWithValuePresent( _Element baseElement, String childElement, String attributeName, String attributeValue) { final Iterable<_Element> allElements = baseElement.allElements(childElement); for (final _Element oneElement in allElements) { final String? elementAttributeValue = oneElement .firstAttribute(attributeName) ?.value; if (elementAttributeValue != null && elementAttributeValue.startsWith(attributeValue)) { return true; } } return false; } static ApkManifestData? parseFromXmlDump(String data, Logger logger) { if (data.trim().isEmpty) { return null; } final List<String> lines = data.split('\n'); assert(lines.length > 3); final int manifestLine = lines.indexWhere((String line) => line.contains('E: manifest')); final _Element manifest = _Element.fromLine(lines[manifestLine], null); _Element currentElement = manifest; for (final String line in lines.skip(manifestLine)) { final String trimLine = line.trimLeft(); final int level = line.length - trimLine.length; // Handle level out while (currentElement.parent != null && level <= currentElement.level) { currentElement = currentElement.parent!; } if (level > currentElement.level) { switch (trimLine[0]) { case 'A': currentElement .addChild(_Attribute.fromLine(line, currentElement)); case 'E': final _Element element = _Element.fromLine(line, currentElement); currentElement.addChild(element); currentElement = element; } } } final _Element? application = manifest.firstElement('application'); if (application == null) { return null; } final Iterable<_Element> activities = application.allElements('activity'); _Element? launchActivity; for (final _Element activity in activities) { final _Attribute? enabled = activity.firstAttribute('android:enabled'); final Iterable<_Element> intentFilters = activity.allElements('intent-filter'); final bool isEnabledByDefault = enabled == null; final bool isExplicitlyEnabled = enabled != null && (enabled.value?.contains('0xffffffff') ?? false); if (!(isEnabledByDefault || isExplicitlyEnabled)) { continue; } for (final _Element element in intentFilters) { final bool isMainAction = _isAttributeWithValuePresent( element, 'action', 'android:name', '"android.intent.action.MAIN"'); if (!isMainAction) { continue; } final bool isLauncherCategory = _isAttributeWithValuePresent( element, 'category', 'android:name', '"android.intent.category.LAUNCHER"'); if (!isLauncherCategory) { continue; } launchActivity = activity; break; } if (launchActivity != null) { break; } } final _Attribute? package = manifest.firstAttribute('package'); // "io.flutter.examples.hello_world" (Raw: "io.flutter.examples.hello_world") final String? packageName = package?.value?.substring(1, package.value?.indexOf('" ')); if (launchActivity == null) { logger.printError('Error running $packageName. Default activity not found'); return null; } final _Attribute? nameAttribute = launchActivity.firstAttribute('android:name'); // "io.flutter.examples.hello_world.MainActivity" (Raw: "io.flutter.examples.hello_world.MainActivity") final String? activityName = nameAttribute?.value?.substring(1, nameAttribute.value?.indexOf('" ')); // Example format: (type 0x10)0x1 final _Attribute? versionCodeAttr = manifest.firstAttribute('android:versionCode'); if (versionCodeAttr == null) { logger.printError('Error running $packageName. Manifest versionCode not found'); return null; } if (versionCodeAttr.value?.startsWith('(type 0x10)') != true) { logger.printError('Error running $packageName. Manifest versionCode invalid'); return null; } final int? versionCode = versionCodeAttr.value == null ? null : int.tryParse(versionCodeAttr.value!.substring(11)); if (versionCode == null) { logger.printError('Error running $packageName. Manifest versionCode invalid'); return null; } final Map<String, Map<String, String>> map = <String, Map<String, String>>{ if (packageName != null) 'package': <String, String>{'name': packageName}, 'version-code': <String, String>{'name': versionCode.toString()}, if (activityName != null) 'launchable-activity': <String, String>{'name': activityName}, }; return ApkManifestData._(map); } final Map<String, Map<String, String>> _data; @visibleForTesting Map<String, Map<String, String>> get data => UnmodifiableMapView<String, Map<String, String>>(_data); String? get packageName => _data['package'] == null ? null : _data['package']?['name']; String? get versionCode => _data['version-code'] == null ? null : _data['version-code']?['name']; String? get launchableActivityName { return _data['launchable-activity'] == null ? null : _data['launchable-activity']?['name']; } @override String toString() => _data.toString(); }