| 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 | |
| 5 | import 'dart:collection'; |
| 6 | |
| 7 | import 'package:meta/meta.dart' ; |
| 8 | import 'package:process/process.dart' ; |
| 9 | import 'package:xml/xml.dart' ; |
| 10 | |
| 11 | import '../application_package.dart'; |
| 12 | import '../base/common.dart'; |
| 13 | import '../base/file_system.dart'; |
| 14 | import '../base/io.dart'; |
| 15 | import '../base/logger.dart'; |
| 16 | import '../base/process.dart'; |
| 17 | import '../base/user_messages.dart'; |
| 18 | import '../build_info.dart'; |
| 19 | import '../project.dart'; |
| 20 | import 'android_sdk.dart'; |
| 21 | import 'gradle.dart'; |
| 22 | |
| 23 | /// An application package created from an already built Android APK. |
| 24 | class 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 | |
| 238 | abstract class _Entry { |
| 239 | const _Entry(this.parent, this.level); |
| 240 | |
| 241 | final _Element? parent; |
| 242 | final int level; |
| 243 | } |
| 244 | |
| 245 | class _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 | |
| 284 | class _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 | |
| 300 | class 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 | |