| // 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 { |
| AndroidApk({ |
| required String id, |
| required this.file, |
| required this.versionCode, |
| required this.launchActivity, |
| }) : assert(file != null), |
| assert(launchActivity != null), |
| super(id: id); |
| |
| /// 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, |
| file: apk, |
| versionCode: data.versionCode == null ? null : int.tryParse(data.versionCode!), |
| launchActivity: '${data.packageName}/${data.launchableActivityName}', |
| ); |
| } |
| |
| /// Path to the actual apk file. |
| final File file; |
| |
| /// 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, |
| }) async { |
| File apkFile; |
| |
| if (androidProject.isUsingGradle && androidProject.isSupportedVersion) { |
| apkFile = getApkDirectory(androidProject.parent).childFile('app.apk'); |
| 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(), 'app.apk')); |
| } |
| |
| 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 XmlParserException 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.toString()}'); |
| } |
| |
| 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; |
| } |
| final String? packageId = manifests.first.getAttribute('package'); |
| |
| 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, |
| file: apkFile, |
| versionCode: null, |
| launchActivity: launchActivity, |
| ); |
| } |
| |
| @override |
| String get name => file.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) == true) { |
| return child; |
| } |
| } |
| return null; |
| } |
| |
| _Element? firstElement(String name) { |
| for (final _Element child in children.whereType<_Element>()) { |
| if (child.name?.startsWith(name) == true) { |
| return child; |
| } |
| } |
| return null; |
| } |
| |
| Iterable<_Element> allElements(String name) { |
| return children.whereType<_Element>().where((_Element e) => e.name?.startsWith(name) == true); |
| } |
| } |
| |
| 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 == null || 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)); |
| break; |
| 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') == true; |
| 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(); |
| } |