Files
2026-07-14 11:11:36 +08:00

472 lines
14 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void> main(List<String> 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<int> _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 <id> 直接指定设备');
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<int> _prepareFvpAndroidDependencies(
String projectRoot,
Map<String, String> 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<String, String> _flutterEnvironment() {
final environment = Map<String, String>.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<String?> _androidDevice(
String projectRoot,
Map<String, String> 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<String?> _firstEmulator(
String projectRoot,
Map<String, String> 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<String?> _waitForDevice(
String projectRoot,
Map<String, String> environment,
) async {
for (
var waited = _pollInterval;
waited <= _maxEmulatorWait;
waited += _pollInterval
) {
await Future<void>.delayed(_pollInterval);
final deviceId = await _androidDevice(projectRoot, environment);
if (deviceId != null) return deviceId;
stdout.writeln('[dev-android] 等待中... (${waited.inSeconds}s)');
}
return null;
}
Future<String> _appVersion(String projectRoot) async {
final result = await _runVersionCommand(projectRoot, ['current', 'android']);
return '${result.stdout}'.trim();
}
Future<int> _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<int?> _installedAppBuildNumber(
String projectRoot,
String deviceId,
Map<String, String> 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<String, String> environment,
) {
final sdkDirectories = <String?>[
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<ProcessResult> _runVersionCommand(
String projectRoot,
List<String> 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<String> 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<ProcessResult> _flutter(
List<String> arguments,
String projectRoot,
Map<String, String> environment,
) {
return Process.run(
'flutter',
arguments,
workingDirectory: projectRoot,
environment: environment,
includeParentEnvironment: false,
runInShell: Platform.isWindows,
);
}
Future<int> _inheritFlutter(
List<String> arguments,
String projectRoot,
Map<String, String> 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<String> arguments) {
String? device;
String? emulator;
String? projectRoot;
var force = false;
var offline = false;
var help = false;
var flutterArguments = <String>[];
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<String> flutterArguments;
}
String _valueAfter(List<String> 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 <id>] [--emulator <id>]
[--force] [--offline] [-- <flutter run args>]
PowerShell aliases -Device, -Emulator, -Force, -Offline, and -FlutterArgs
remain supported.''';