| // 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, | |
| }); | |
| static String get _aaptNotFound => | |
| 'Could not locate aapt. Please ensure you have the Android buildtools installed.'; | |
| /// 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(_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; | |
| try { | |
| data = ApkManifestData.parseFromXmlDump(apptStdout, logger); | |
| // ignore: avoid_catches_without_on_clauses | |
| } catch (error, stackTrace) { | |
| logger.printError('Failed to parse manifest from APK: $error', stackTrace: stackTrace); | |
| return null; | |
| } | |
| 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 && buildInfo != null) { | |
| // 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. | |
| final AndroidApk? builtApk = AndroidApk.fromApk( | |
| apkFile, | |
| androidSdk: androidSdk!, | |
| processManager: processManager, | |
| logger: logger, | |
| userMessages: userMessages, | |
| processUtils: processUtils, | |
| ); | |
| if (builtApk != null) { | |
| return builtApk; | |
| } | |
| logger.printWarning( | |
| 'Failed to extract manifest from APK: falling back to source AndroidManifest.xml', | |
| ); | |
| } | |
| // 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; | |
| final Iterable<XmlElement> activities = document | |
| .findAllElements('activity') | |
| .followedBy(document.findAllElements('activity-alias')); | |
| for (final activity in activities) { | |
| final String? enabled = activity.getAttribute('android:enabled'); | |
| if (enabled != null && enabled == 'false') { | |
| continue; | |
| } | |
| var foundLauncher = false; | |
| 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'); | |
| if (packageId != null && activityName != null && activityName.isNotEmpty) { | |
| launchActivity = '$packageId/$activityName'; | |
| foundLauncher = true; | |
| break; | |
| } | |
| } | |
| } | |
| if (foundLauncher) { | |
| 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 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 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 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 manifest = _Element.fromLine(lines[manifestLine], null); | |
| var 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.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 activity in activities) { | |
| final _Attribute? enabled = activity.firstAttribute('android:enabled'); | |
| final Iterable<_Element> intentFilters = activity.allElements('intent-filter'); | |
| final isEnabledByDefault = enabled == null; | |
| final bool isExplicitlyEnabled = | |
| enabled != null && (enabled.value?.contains('0xffffffff') ?? false); | |
| if (!(isEnabledByDefault || isExplicitlyEnabled)) { | |
| continue; | |
| } | |
| for (final 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>>{ | |
| 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(); | |
| } |