1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'dart:collection';
6
7import 'package:meta/meta.dart';
8import 'package:process/process.dart';
9import 'package:xml/xml.dart';
10
11import '../application_package.dart';
12import '../base/common.dart';
13import '../base/file_system.dart';
14import '../base/io.dart';
15import '../base/logger.dart';
16import '../base/process.dart';
17import '../base/user_messages.dart';
18import '../build_info.dart';
19import '../project.dart';
20import 'android_sdk.dart';
21import 'gradle.dart';
22
23/// An application package created from an already built Android APK.
24class AndroidApk extends ApplicationPackage implements PrebuiltApplicationPackage {
25 AndroidApk({
26 required super.id,
27 required this.applicationPackage,
28 required this.versionCode,
29 required this.launchActivity,
30 });
31
32 /// Creates a new AndroidApk from an existing APK.
33 ///
34 /// Returns `null` if the APK was invalid or any required tooling was missing.
35 static AndroidApk? fromApk(
36 File apk, {
37 required AndroidSdk androidSdk,
38 required ProcessManager processManager,
39 required UserMessages userMessages,
40 required Logger logger,
41 required ProcessUtils processUtils,
42 }) {
43 final String? aaptPath = androidSdk.latestVersion?.aaptPath;
44 if (aaptPath == null || !processManager.canRun(aaptPath)) {
45 logger.printError(userMessages.aaptNotFound);
46 return null;
47 }
48
49 String apptStdout;
50 try {
51 apptStdout = processUtils
52 .runSync(<String>[
53 aaptPath,
54 'dump',
55 'xmltree',
56 apk.path,
57 'AndroidManifest.xml',
58 ], throwOnError: true)
59 .stdout
60 .trim();
61 } on ProcessException catch (error) {
62 logger.printError('Failed to extract manifest from APK: $error.');
63 return null;
64 }
65
66 final ApkManifestData? data = ApkManifestData.parseFromXmlDump(apptStdout, logger);
67
68 if (data == null) {
69 logger.printError('Unable to read manifest info from ${apk.path}.');
70 return null;
71 }
72
73 final String? packageName = data.packageName;
74 if (packageName == null || data.launchableActivityName == null) {
75 logger.printError('Unable to read manifest info from ${apk.path}.');
76 return null;
77 }
78
79 return AndroidApk(
80 id: packageName,
81 applicationPackage: apk,
82 versionCode: data.versionCode == null ? null : int.tryParse(data.versionCode!),
83 launchActivity: '${data.packageName}/${data.launchableActivityName}',
84 );
85 }
86
87 @override
88 final FileSystemEntity applicationPackage;
89
90 /// The path to the activity that should be launched.
91 final String launchActivity;
92
93 /// The version code of the APK.
94 final int? versionCode;
95
96 /// Creates a new AndroidApk based on the information in the Android manifest.
97 static Future<AndroidApk?> fromAndroidProject(
98 AndroidProject androidProject, {
99 required AndroidSdk? androidSdk,
100 required ProcessManager processManager,
101 required UserMessages userMessages,
102 required ProcessUtils processUtils,
103 required Logger logger,
104 required FileSystem fileSystem,
105 BuildInfo? buildInfo,
106 }) async {
107 final File apkFile;
108 final String filename;
109 if (buildInfo == null) {
110 filename = 'app.apk';
111 } else if (buildInfo.flavor == null) {
112 filename = 'app-${buildInfo.mode.cliName}.apk';
113 } else {
114 filename = 'app-${buildInfo.lowerCasedFlavor}-${buildInfo.mode.cliName}.apk';
115 }
116
117 if (androidProject.isUsingGradle && androidProject.isSupportedVersion) {
118 Directory apkDirectory = getApkDirectory(androidProject.parent);
119 if (androidProject.parent.isModule) {
120 // Module builds output the apk in a subdirectory that corresponds
121 // to the buildmode of the apk.
122 apkDirectory = apkDirectory.childDirectory(buildInfo!.mode.cliName);
123 }
124 apkFile = apkDirectory.childFile(filename);
125 if (apkFile.existsSync()) {
126 // Grab information from the .apk. The gradle build script might alter
127 // the application Id, so we need to look at what was actually built.
128 return AndroidApk.fromApk(
129 apkFile,
130 androidSdk: androidSdk!,
131 processManager: processManager,
132 logger: logger,
133 userMessages: userMessages,
134 processUtils: processUtils,
135 );
136 }
137 // The .apk hasn't been built yet, so we work with what we have. The run
138 // command will grab a new AndroidApk after building, to get the updated
139 // IDs.
140 } else {
141 apkFile = fileSystem.file(fileSystem.path.join(getAndroidBuildDirectory(), filename));
142 }
143
144 final File manifest = androidProject.appManifestFile;
145
146 if (!manifest.existsSync()) {
147 logger.printError('AndroidManifest.xml could not be found.');
148 logger.printError('Please check ${manifest.path} for errors.');
149 return null;
150 }
151
152 final String manifestString = manifest.readAsStringSync();
153 XmlDocument document;
154 try {
155 document = XmlDocument.parse(manifestString);
156 } on XmlException catch (exception) {
157 String manifestLocation;
158 if (androidProject.isUsingGradle) {
159 manifestLocation = fileSystem.path.join(
160 androidProject.hostAppGradleRoot.path,
161 'app',
162 'src',
163 'main',
164 'AndroidManifest.xml',
165 );
166 } else {
167 manifestLocation = fileSystem.path.join(
168 androidProject.hostAppGradleRoot.path,
169 'AndroidManifest.xml',
170 );
171 }
172 logger.printError('AndroidManifest.xml is not a valid XML document.');
173 logger.printError('Please check $manifestLocation for errors.');
174 throwToolExit('XML Parser error message: $exception');
175 }
176
177 final Iterable<XmlElement> manifests = document.findElements('manifest');
178 if (manifests.isEmpty) {
179 logger.printError('AndroidManifest.xml has no manifest element.');
180 logger.printError('Please check ${manifest.path} for errors.');
181 return null;
182 }
183
184 // Starting from AGP version 7.3, the `package` attribute in Manifest.xml
185 // can be replaced with the `namespace` attribute under the `android` section in `android/app/build.gradle`.
186 final String? packageId = manifests.first.getAttribute('package') ?? androidProject.namespace;
187
188 String? launchActivity;
189 for (final XmlElement activity in document.findAllElements('activity')) {
190 final String? enabled = activity.getAttribute('android:enabled');
191 if (enabled != null && enabled == 'false') {
192 continue;
193 }
194
195 for (final XmlElement element in activity.findElements('intent-filter')) {
196 String? actionName = '';
197 String? categoryName = '';
198 for (final XmlNode node in element.children) {
199 if (node is! XmlElement) {
200 continue;
201 }
202 final String? name = node.getAttribute('android:name');
203 if (name == 'android.intent.action.MAIN') {
204 actionName = name;
205 } else if (name == 'android.intent.category.LAUNCHER') {
206 categoryName = name;
207 }
208 }
209 if (actionName != null &&
210 categoryName != null &&
211 actionName.isNotEmpty &&
212 categoryName.isNotEmpty) {
213 final String? activityName = activity.getAttribute('android:name');
214 launchActivity = '$packageId/$activityName';
215 break;
216 }
217 }
218 }
219
220 if (packageId == null || launchActivity == null) {
221 logger.printError('package identifier or launch activity not found.');
222 logger.printError('Please check ${manifest.path} for errors.');
223 return null;
224 }
225
226 return AndroidApk(
227 id: packageId,
228 applicationPackage: apkFile,
229 versionCode: null,
230 launchActivity: launchActivity,
231 );
232 }
233
234 @override
235 String get name => applicationPackage.basename;
236}
237
238abstract class _Entry {
239 const _Entry(this.parent, this.level);
240
241 final _Element? parent;
242 final int level;
243}
244
245class _Element extends _Entry {
246 _Element._(this.name, _Element? parent, int level) : super(parent, level);
247
248 factory _Element.fromLine(String line, _Element? parent) {
249 // E: application (line=29)
250 final List<String> parts = line.trimLeft().split(' ');
251 return _Element._(parts[1], parent, line.length - line.trimLeft().length);
252 }
253
254 final children = <_Entry>[];
255 final String? name;
256
257 void addChild(_Entry child) {
258 children.add(child);
259 }
260
261 _Attribute? firstAttribute(String name) {
262 for (final _Attribute child in children.whereType<_Attribute>()) {
263 if (child.key?.startsWith(name) ?? false) {
264 return child;
265 }
266 }
267 return null;
268 }
269
270 _Element? firstElement(String name) {
271 for (final _Element child in children.whereType<_Element>()) {
272 if (child.name?.startsWith(name) ?? false) {
273 return child;
274 }
275 }
276 return null;
277 }
278
279 Iterable<_Element> allElements(String name) {
280 return children.whereType<_Element>().where((_Element e) => e.name?.startsWith(name) ?? false);
281 }
282}
283
284class _Attribute extends _Entry {
285 const _Attribute._(this.key, this.value, _Element? parent, int level) : super(parent, level);
286
287 factory _Attribute.fromLine(String line, _Element parent) {
288 // A: android:label(0x01010001)="hello_world" (Raw: "hello_world")
289 const attributePrefix = 'A: ';
290 final List<String> keyVal = line
291 .substring(line.indexOf(attributePrefix) + attributePrefix.length)
292 .split('=');
293 return _Attribute._(keyVal[0], keyVal[1], parent, line.length - line.trimLeft().length);
294 }
295
296 final String? key;
297 final String? value;
298}
299
300class ApkManifestData {
301 ApkManifestData._(this._data);
302
303 static bool _isAttributeWithValuePresent(
304 _Element baseElement,
305 String childElement,
306 String attributeName,
307 String attributeValue,
308 ) {
309 final Iterable<_Element> allElements = baseElement.allElements(childElement);
310 for (final oneElement in allElements) {
311 final String? elementAttributeValue = oneElement.firstAttribute(attributeName)?.value;
312 if (elementAttributeValue != null && elementAttributeValue.startsWith(attributeValue)) {
313 return true;
314 }
315 }
316 return false;
317 }
318
319 static ApkManifestData? parseFromXmlDump(String data, Logger logger) {
320 if (data.trim().isEmpty) {
321 return null;
322 }
323
324 final List<String> lines = data.split('\n');
325 assert(lines.length > 3);
326
327 final int manifestLine = lines.indexWhere((String line) => line.contains('E: manifest'));
328 final manifest = _Element.fromLine(lines[manifestLine], null);
329 var currentElement = manifest;
330
331 for (final String line in lines.skip(manifestLine)) {
332 final String trimLine = line.trimLeft();
333 final int level = line.length - trimLine.length;
334
335 // Handle level out
336 while (currentElement.parent != null && level <= currentElement.level) {
337 currentElement = currentElement.parent!;
338 }
339
340 if (level > currentElement.level) {
341 switch (trimLine[0]) {
342 case 'A':
343 currentElement.addChild(_Attribute.fromLine(line, currentElement));
344 case 'E':
345 final element = _Element.fromLine(line, currentElement);
346 currentElement.addChild(element);
347 currentElement = element;
348 }
349 }
350 }
351
352 final _Element? application = manifest.firstElement('application');
353 if (application == null) {
354 return null;
355 }
356
357 final Iterable<_Element> activities = application.allElements('activity');
358
359 _Element? launchActivity;
360 for (final activity in activities) {
361 final _Attribute? enabled = activity.firstAttribute('android:enabled');
362 final Iterable<_Element> intentFilters = activity.allElements('intent-filter');
363 final isEnabledByDefault = enabled == null;
364 final bool isExplicitlyEnabled =
365 enabled != null && (enabled.value?.contains('0xffffffff') ?? false);
366 if (!(isEnabledByDefault || isExplicitlyEnabled)) {
367 continue;
368 }
369
370 for (final element in intentFilters) {
371 final bool isMainAction = _isAttributeWithValuePresent(
372 element,
373 'action',
374 'android:name',
375 '"android.intent.action.MAIN"',
376 );
377 if (!isMainAction) {
378 continue;
379 }
380 final bool isLauncherCategory = _isAttributeWithValuePresent(
381 element,
382 'category',
383 'android:name',
384 '"android.intent.category.LAUNCHER"',
385 );
386 if (!isLauncherCategory) {
387 continue;
388 }
389 launchActivity = activity;
390 break;
391 }
392 if (launchActivity != null) {
393 break;
394 }
395 }
396
397 final _Attribute? package = manifest.firstAttribute('package');
398 // "io.flutter.examples.hello_world" (Raw: "io.flutter.examples.hello_world")
399 final String? packageName = package?.value?.substring(1, package.value?.indexOf('" '));
400
401 if (launchActivity == null) {
402 logger.printError('Error running $packageName. Default activity not found');
403 return null;
404 }
405
406 final _Attribute? nameAttribute = launchActivity.firstAttribute('android:name');
407 // "io.flutter.examples.hello_world.MainActivity" (Raw: "io.flutter.examples.hello_world.MainActivity")
408 final String? activityName = nameAttribute?.value?.substring(
409 1,
410 nameAttribute.value?.indexOf('" '),
411 );
412
413 // Example format: (type 0x10)0x1
414 final _Attribute? versionCodeAttr = manifest.firstAttribute('android:versionCode');
415 if (versionCodeAttr == null) {
416 logger.printError('Error running $packageName. Manifest versionCode not found');
417 return null;
418 }
419 if (versionCodeAttr.value?.startsWith('(type 0x10)') != true) {
420 logger.printError('Error running $packageName. Manifest versionCode invalid');
421 return null;
422 }
423 final int? versionCode = versionCodeAttr.value == null
424 ? null
425 : int.tryParse(versionCodeAttr.value!.substring(11));
426 if (versionCode == null) {
427 logger.printError('Error running $packageName. Manifest versionCode invalid');
428 return null;
429 }
430
431 final map = <String, Map<String, String>>{
432 if (packageName != null) 'package': <String, String>{'name': packageName},
433 'version-code': <String, String>{'name': versionCode.toString()},
434 if (activityName != null) 'launchable-activity': <String, String>{'name': activityName},
435 };
436
437 return ApkManifestData._(map);
438 }
439
440 final Map<String, Map<String, String>> _data;
441
442 @visibleForTesting
443 Map<String, Map<String, String>> get data =>
444 UnmodifiableMapView<String, Map<String, String>>(_data);
445
446 String? get packageName => _data['package'] == null ? null : _data['package']?['name'];
447
448 String? get versionCode => _data['version-code'] == null ? null : _data['version-code']?['name'];
449
450 String? get launchableActivityName {
451 return _data['launchable-activity'] == null ? null : _data['launchable-activity']?['name'];
452 }
453
454 @override
455 String toString() => _data.toString();
456}
457