blob: ba03b197e4ba07dd0d6332b285a6c0004697160c [file] [log] [blame]
// 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();
}