import 'dart:async'; import 'dart:convert'; import 'dart:io'; const _fvpDepsUrl = 'https://github.com/wang-bin/mdk-sdk/releases/download/v0.37.0'; const _androidApplicationId = 'com.smplayer.smplayer'; const _debugApplicationId = '$_androidApplicationId.dev'; const _maxEmulatorWait = Duration(seconds: 120); const _pollInterval = Duration(seconds: 3); Future main(List arguments) async { try { final options = _Options.parse(arguments); if (options.help) { stdout.writeln(_usage); return; } exitCode = await _run(options); } on FormatException catch (error) { stderr.writeln('error: ${error.message}'); stderr.writeln(_usage); exitCode = 2; } on ProcessException catch (error) { stderr.writeln('error: $error'); exitCode = 1; } on StateError catch (error) { stderr.writeln('error: ${error.message}'); exitCode = 1; } } Future _run(_Options options) async { final stopwatch = Stopwatch()..start(); final projectRoot = options.projectRoot ?? _defaultProjectRoot; final environment = _flutterEnvironment(); var deviceId = options.device; if (deviceId != null) { stdout.writeln('[dev-android] 使用指定设备: $deviceId'); } else { deviceId = await _androidDevice(projectRoot, environment); if (deviceId == null) { final emulatorId = options.emulator ?? await _firstEmulator(projectRoot, environment); if (emulatorId == null) { stderr ..writeln('[dev-android] 未找到 Android 设备,请:') ..writeln(' 1. 连接真机(开启 USB 调试)') ..writeln( ' 2. 或创建模拟器: ' 'flutter emulators --create --name MyEmulator', ) ..writeln(' 也可用 --device 直接指定设备'); return 1; } stdout ..writeln('[dev-android] 启动模拟器: $emulatorId') ..writeln('[dev-android] 等待模拟器启动...'); await Process.start( 'flutter', ['emulators', '--launch', emulatorId], workingDirectory: projectRoot, environment: environment, includeParentEnvironment: false, runInShell: Platform.isWindows, mode: ProcessStartMode.detached, ); deviceId = await _waitForDevice(projectRoot, environment); if (deviceId == null) { stderr.writeln( '[dev-android] 模拟器启动超时 ' '(${_maxEmulatorWait.inSeconds}s),请手动启动后重试', ); return 1; } } stdout.writeln('[dev-android] 目标设备: $deviceId'); } if (_needsPubGet(projectRoot, options.force)) { stdout.writeln('[dev-android] 依赖有变更 -> flutter pub get'); final pubExit = await _inheritFlutter( ['pub', 'get', if (options.offline) '--offline'], projectRoot, environment, ); if (pubExit != 0) return pubExit; } else { stdout.writeln('[dev-android] 依赖未变更 -> 跳过 pub get'); } final prepareFvpExit = await _prepareFvpAndroidDependencies( projectRoot, environment, ); if (prepareFvpExit != 0) return prepareFvpExit; final appVersion = await _appVersion(projectRoot); final versionBuildNumber = await _appBuildNumber(projectRoot, appVersion); final installedBuildNumber = await _installedAppBuildNumber( projectRoot, deviceId, environment, ); final appBuildNumber = installedBuildNumber != null && installedBuildNumber > versionBuildNumber ? installedBuildNumber : versionBuildNumber; stdout.writeln( '[dev-android] App version: $appVersion (android line), ' 'build number: $appBuildNumber', ); if (installedBuildNumber != null && installedBuildNumber > versionBuildNumber) { stdout.writeln( '[dev-android] 设备已安装更高版本号 $installedBuildNumber,' '开发包沿用该版本号以避免降级重装', ); } environment['SMPLAYER_ANDROID_VERSION_CODE'] = '$appBuildNumber'; final runArguments = [ 'run', '-d', deviceId, '--no-pub', ...options.flutterArguments, ]; if (!_hasDartDefine(runArguments, 'SMPLAYER_VERSION')) { runArguments.add('--dart-define=SMPLAYER_VERSION=$appVersion'); } stdout ..writeln('[dev-android] flutter ${runArguments.join(' ')}') ..writeln('[dev-android] 启动准备耗时: ${stopwatch.elapsedMilliseconds} ms'); return _inheritFlutter(runArguments, projectRoot, environment); } Future _prepareFvpAndroidDependencies( String projectRoot, Map environment, ) async { final prepareScript = File( '${File.fromUri(Platform.script).parent.path}' '${Platform.pathSeparator}prepare_fvp_android_deps.dart', ); final result = await Process.run( Platform.resolvedExecutable, [prepareScript.path, '--project-root', projectRoot], workingDirectory: projectRoot, environment: environment, includeParentEnvironment: false, ); stdout.write(result.stdout); stderr.write(result.stderr); return result.exitCode; } Map _flutterEnvironment() { final environment = Map.of(Platform.environment) ..['FVP_DEPS_URL'] = _fvpDepsUrl ..remove('FVP_DEPS_LATEST'); if (!Platform.isWindows) { if ((environment['FLUTTER_STORAGE_BASE_URL'] ?? '').isEmpty) { environment['FLUTTER_STORAGE_BASE_URL'] = 'https://storage.flutter-io.cn'; } if ((environment['PUB_HOSTED_URL'] ?? '').isEmpty) { environment['PUB_HOSTED_URL'] = 'https://pub.flutter-io.cn'; } } return environment; } bool _needsPubGet(String projectRoot, bool force) { if (force) return true; final packageConfig = File( '$projectRoot${Platform.pathSeparator}.dart_tool' '${Platform.pathSeparator}package_config.json', ); final pubspec = File('$projectRoot${Platform.pathSeparator}pubspec.yaml'); if (!packageConfig.existsSync() || !pubspec.existsSync()) return true; final configTime = packageConfig.lastModifiedSync(); if (pubspec.lastModifiedSync().isAfter(configTime)) return true; final lock = File('$projectRoot${Platform.pathSeparator}pubspec.lock'); return lock.existsSync() && lock.lastModifiedSync().isAfter(configTime); } Future _androidDevice( String projectRoot, Map environment, ) async { final result = await _flutter( ['devices', '--machine'], projectRoot, environment, ); if (result.exitCode != 0) return null; try { final devices = jsonDecode('${result.stdout}'); if (devices is! List) return null; for (final device in devices) { if (device is Map && '${device['targetPlatform']}'.startsWith('android')) { final id = '${device['id']}'; if (id.isNotEmpty && id != 'null') return id; } } } on FormatException { return null; } return null; } Future _firstEmulator( String projectRoot, Map environment, ) async { final result = await _flutter(['emulators'], projectRoot, environment); if (result.exitCode != 0) return null; final emulatorPattern = RegExp( r'^\s*(\S+)\s+.*\bandroid\b', caseSensitive: false, ); for (final line in '${result.stdout}'.split(RegExp(r'\r?\n'))) { final match = emulatorPattern.firstMatch(line); if (match != null) return match[1]; } return null; } Future _waitForDevice( String projectRoot, Map environment, ) async { for ( var waited = _pollInterval; waited <= _maxEmulatorWait; waited += _pollInterval ) { await Future.delayed(_pollInterval); final deviceId = await _androidDevice(projectRoot, environment); if (deviceId != null) return deviceId; stdout.writeln('[dev-android] 等待中... (${waited.inSeconds}s)'); } return null; } Future _appVersion(String projectRoot) async { final result = await _runVersionCommand(projectRoot, ['current', 'android']); return '${result.stdout}'.trim(); } Future _appBuildNumber(String projectRoot, String appVersion) async { final result = await _runVersionCommand(projectRoot, [ 'build-number', appVersion, ]); final buildNumber = int.tryParse('${result.stdout}'.trim()); if (buildNumber == null || buildNumber <= 0) { throw StateError('Invalid Android build number: ${result.stdout}'); } return buildNumber; } Future _installedAppBuildNumber( String projectRoot, String deviceId, Map environment, ) async { final adbExecutable = _androidDebugBridgeExecutable(projectRoot, environment); try { final result = await Process.run( adbExecutable, ['-s', deviceId, 'shell', 'dumpsys', 'package', _debugApplicationId], workingDirectory: projectRoot, environment: environment, includeParentEnvironment: false, runInShell: Platform.isWindows, ); if (result.exitCode != 0) return null; final versionCodeMatch = RegExp( r'\bversionCode=(\d+)\b', ).firstMatch('${result.stdout}'); return versionCodeMatch == null ? null : int.tryParse(versionCodeMatch.group(1)!); } on ProcessException { return null; } } String _androidDebugBridgeExecutable( String projectRoot, Map environment, ) { final sdkDirectories = [ environment['ANDROID_SDK_ROOT'], environment['ANDROID_HOME'], _androidSdkDirectoryFromLocalProperties(projectRoot), if (Platform.isWindows && environment['LOCALAPPDATA'] != null) '${environment['LOCALAPPDATA']}${Platform.pathSeparator}Android' '${Platform.pathSeparator}sdk', ]; final executableName = Platform.isWindows ? 'adb.exe' : 'adb'; for (final sdkDirectory in sdkDirectories) { if (sdkDirectory == null || sdkDirectory.trim().isEmpty) continue; final executable = File( '${sdkDirectory.trim()}${Platform.pathSeparator}platform-tools' '${Platform.pathSeparator}$executableName', ); if (executable.existsSync()) return executable.path; } return executableName; } String? _androidSdkDirectoryFromLocalProperties(String projectRoot) { final propertiesFile = File( '$projectRoot${Platform.pathSeparator}android' '${Platform.pathSeparator}local.properties', ); if (!propertiesFile.existsSync()) return null; for (final line in propertiesFile.readAsLinesSync()) { final trimmedLine = line.trim(); if (!trimmedLine.startsWith('sdk.dir=')) continue; return trimmedLine .substring('sdk.dir='.length) .replaceAll(r'\:', ':') .replaceAll(r'\\', r'\'); } return null; } Future _runVersionCommand( String projectRoot, List arguments, ) async { final result = await Process.run(Platform.resolvedExecutable, [ '${File.fromUri(Platform.script).parent.path}' '${Platform.pathSeparator}version.dart', ...arguments, '--project-root', projectRoot, ]); if (result.exitCode != 0) { throw StateError('${result.stderr}'.trim()); } return result; } bool _hasDartDefine(List arguments, String name) { var previous = ''; for (final argument in arguments) { if (argument.startsWith('--dart-define=$name=') || (previous == '--dart-define' && argument.startsWith('$name='))) { return true; } previous = argument; } return false; } Future _flutter( List arguments, String projectRoot, Map environment, ) { return Process.run( 'flutter', arguments, workingDirectory: projectRoot, environment: environment, includeParentEnvironment: false, runInShell: Platform.isWindows, ); } Future _inheritFlutter( List arguments, String projectRoot, Map environment, ) async { final process = await Process.start( 'flutter', arguments, workingDirectory: projectRoot, environment: environment, includeParentEnvironment: false, runInShell: Platform.isWindows, mode: ProcessStartMode.inheritStdio, ); return process.exitCode; } final class _Options { const _Options({ this.device, this.emulator, this.projectRoot, this.force = false, this.offline = false, this.help = false, this.flutterArguments = const [], }); factory _Options.parse(List arguments) { String? device; String? emulator; String? projectRoot; var force = false; var offline = false; var help = false; var flutterArguments = []; for (var index = 0; index < arguments.length; index++) { final argument = arguments[index]; switch (argument.toLowerCase()) { case '--device' || '-device': device = _valueAfter(arguments, ++index, argument); case '--emulator' || '-emulator': emulator = _valueAfter(arguments, ++index, argument); case '--project-root': projectRoot = _valueAfter(arguments, ++index, argument); case '--force' || '-force': force = true; case '--offline' || '-offline': offline = true; case '-h' || '--help': help = true; case '--' || '-flutterargs': flutterArguments = arguments.skip(index + 1).toList(); index = arguments.length; default: throw FormatException("Unknown argument '$argument'."); } } return _Options( device: device, emulator: emulator, projectRoot: projectRoot, force: force, offline: offline, help: help, flutterArguments: flutterArguments, ); } final String? device; final String? emulator; final String? projectRoot; final bool force; final bool offline; final bool help; final List flutterArguments; } String _valueAfter(List arguments, int index, String option) { if (index >= arguments.length) { throw FormatException('Missing value for $option.'); } return arguments[index]; } String get _defaultProjectRoot => File.fromUri(Platform.script).parent.parent.path; const _usage = '''Usage: dart scripts/dev_android.dart [--device ] [--emulator ] [--force] [--offline] [-- ] PowerShell aliases -Device, -Emulator, -Force, -Offline, and -FlutterArgs remain supported.''';