472 lines
14 KiB
Dart
472 lines
14 KiB
Dart
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.''';
|