Files
sm-emby-share/scripts/dev_android.dart
T

472 lines
14 KiB
Dart
Raw Normal View History

2026-07-14 11:11:36 +08:00
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.''';