This commit is contained in:
Max 2026-04-01 16:48:58 +08:00
commit 40d1b179d0
9 changed files with 1007 additions and 72 deletions

View File

@ -3,5 +3,6 @@
"preferredOrientations": [
"portraitUp",
"portraitDown"
]
],
"upgradeConfigUrl": "https://umsapi.23544.com/api/biz/version/get-latest-config?appId=787154824761413&environment=1"
}

View File

@ -3,7 +3,7 @@ application_id: "com.yuanxuan.aixue"
app_key: "aixue_prod"
default_url: "http://xszy.lzzneng.com/login.html"
bootstrap_config_url: ""
upgrade_config_url: ""
upgrade_config_url: "https://umsapi.23544.com/api/biz/version/get-latest-config?appId=787154824761413&environment=1"
preferred_orientations:
- "portraitUp"
- "portraitDown"

View File

@ -18,6 +18,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart';
import 'package:yx_app_upgrade_flutter/app_upgrade_plugin.dart';
import 'package:yx_app_upgrade_flutter/yx_app_upgrade_flutter.dart';
export 'package:yx_app_upgrade_flutter/yx_app_upgrade_flutter.dart'
@ -54,6 +55,7 @@ part 'src/ui/unsupported_platform_page.dart';
//
late ShellEnvironment _env;
final ValueNotifier<int> _runtimeConfigVersion = ValueNotifier<int>(0);
Color get _shellAccentColor => _env.accentColor;
Color get _shellBackgroundColor => _env.backgroundColor;
@ -83,6 +85,41 @@ Future<void> runShellApp(ShellEnvironment environment) async {
runApp(const ShellApp());
}
///
Future<ShellUpgradeConfig?> reloadUpgradeRuntimeConfig() async {
final configUrl = ShellUpgradeService.instance._configUrl?.trim();
if (configUrl == null || configUrl.isEmpty) {
return null;
}
final remoteConfig = await ShellUpgradeService.instance._fetchConfig(
configUrl,
);
if (remoteConfig == null) {
return null;
}
await _applyUpgradeRuntimeConfig(remoteConfig, source: '升级配置');
return remoteConfig;
}
///
Future<ShellUpgradeConfig?> reloadUpgradeRuntimeConfigAndCheckVersion(
BuildContext context, {
bool showNoUpdateToast = false,
}) async {
final remoteConfig = await reloadUpgradeRuntimeConfig();
if (remoteConfig == null) {
return null;
}
await ShellUpgradeService.instance.checkVersion(
context,
showNoUpdateToast: showNoUpdateToast,
);
return remoteConfig;
}
/// upgradeConfigUrl
Future<String?> _applyBootstrapConfig() async {
String? bootstrapConfigUrl;
@ -145,6 +182,38 @@ void _mergeBootstrapConfig(
);
}
Future<void> _applyUpgradeRuntimeConfig(
ShellUpgradeConfig remoteConfig, {
required String source,
}) async {
final runtimeConfig = remoteConfig.config;
if (runtimeConfig == null) {
return;
}
final previousInitialUrl = _env.initialUrl?.trim();
final previousPreferredOrientations = _env.preferredOrientations;
_mergeBootstrapConfig(runtimeConfig, source: source);
final nextUpgradeConfigUrl = runtimeConfig.upgradeConfigUrl?.trim();
if (nextUpgradeConfigUrl != null && nextUpgradeConfigUrl.isNotEmpty) {
ShellUpgradeService.instance.setupConfigUrl(nextUpgradeConfigUrl);
}
_initializeUrls();
await SystemChrome.setPreferredOrientations(_shellPreferredOrientations);
final hasInitialUrlChanged = previousInitialUrl != _env.initialUrl?.trim();
final hasOrientationsChanged = !listEquals(
previousPreferredOrientations,
_env.preferredOrientations,
);
if (hasInitialUrlChanged || hasOrientationsChanged) {
_runtimeConfigVersion.value += 1;
}
}
Future<void> _enterImmersiveMode() async {
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
SystemChrome.setSystemUIOverlayStyle(

View File

@ -103,6 +103,21 @@ void _setStatusBarFromBridge(Map<String, dynamic> payload) {
);
}
Map<String, dynamic> _runtimeConfigSnapshotFromBridge(
ShellUpgradeConfig? remoteConfig,
) {
return <String, dynamic>{
'initialUrl': _initialUrl,
'preferredOrientations': _shellPreferredOrientations
.map((item) => item.name)
.toList(),
'upgradeConfigUrl': ShellUpgradeService.instance._configUrl,
'versionName': remoteConfig?.versionName,
'version': remoteConfig?.version,
'describe': remoteConfig?.describe,
};
}
// Toast
class _ToastWidget extends StatefulWidget {

View File

@ -91,6 +91,11 @@ String _buildAppShellBridgeScript() {
openExternal: (url) => send('openExternal', { url }),
requestPermissions: (types = []) => send('requestPermissions', { types }),
reloadPage: () => send('reloadPage'),
reloadUpgradeRuntimeConfig: () => send('reloadUpgradeRuntimeConfig'),
reloadUpgradeRuntimeConfigAndCheckVersion: (options = {}) => send(
'reloadUpgradeRuntimeConfigAndCheckVersion',
options,
),
goBack: () => send('goBack'),
closeApp: () => send('closeApp'),
getDeviceInfo: () => send('getDeviceInfo'),

View File

@ -1,7 +1,7 @@
part of '../../core_app.dart';
///
class ShellUpgradeConfig {
/// `upgrade`
class ShellUpgradeReleaseConfig {
final String? versionName;
final int? version;
final int? isForce;
@ -9,7 +9,7 @@ class ShellUpgradeConfig {
final String? filePath;
final int? fileSize;
ShellUpgradeConfig({
const ShellUpgradeReleaseConfig({
this.versionName,
this.version,
this.isForce,
@ -18,31 +18,268 @@ class ShellUpgradeConfig {
this.fileSize,
});
factory ShellUpgradeReleaseConfig.fromJson(Map<String, dynamic> json) {
return ShellUpgradeReleaseConfig(
versionName: _readShellUpgradeString(json['versionName']),
version: _readShellUpgradeInt(json['version']),
isForce: _readShellUpgradeInt(json['isForce']),
remark: _readShellUpgradeString(json['remark']),
filePath: _readShellUpgradeString(json['filePath']),
fileSize: _readShellUpgradeInt(json['fileSize']),
);
}
/// Map `data.config.upgrade` JSON
static ShellUpgradeReleaseConfig? fromDynamic(dynamic value) {
if (value == null) {
return null;
}
if (value is Map<String, dynamic>) {
return ShellUpgradeReleaseConfig.fromJson(value);
}
if (value is Map) {
return ShellUpgradeReleaseConfig.fromJson(
Map<String, dynamic>.from(value),
);
}
if (value is! String) {
return null;
}
final trimmed = value.trim();
if (trimmed.isEmpty) {
return null;
}
try {
final decoded = jsonDecode(trimmed);
if (decoded is Map<String, dynamic>) {
return ShellUpgradeReleaseConfig.fromJson(decoded);
}
if (decoded is Map) {
return ShellUpgradeReleaseConfig.fromJson(
Map<String, dynamic>.from(decoded),
);
}
} catch (e) {
debugPrint('解析 WebShell 升级配置 upgrade 字段异常: $e');
}
return null;
}
}
/// JSON
///
///
/// 1. `success/code/msg/data`
/// 2. `data.config` JSON
/// 3. `data.config` `upgrade` `config`
class ShellUpgradeConfig {
final bool? success;
final String? responseCode;
final String? msg;
final int? id;
final int? upgradeFileId;
final int? installFileId;
final bool? isLatest;
final int? appId;
final String? releaseCode;
final String? releaseTime;
final bool? releaseStatus;
final String? upgradeOssUrl;
final bool? isAllowRollback;
final String? rawConfig;
final String? configDescribe;
final int? describeType;
final int? environment;
final String? createdTime;
final ShellUpgradeReleaseConfig? upgrade;
final ShellBootstrapConfig? config;
final String? installOssUrl;
final String? describe;
const ShellUpgradeConfig({
this.success,
this.responseCode,
this.msg,
this.id,
this.upgradeFileId,
this.installFileId,
this.isLatest,
this.appId,
this.releaseCode,
this.releaseTime,
this.releaseStatus,
this.upgradeOssUrl,
this.isAllowRollback,
this.rawConfig,
this.configDescribe,
this.describe,
this.describeType,
this.environment,
this.createdTime,
this.installOssUrl,
this.upgrade,
this.config,
});
String? get versionName => upgrade?.versionName;
int? get version => upgrade?.version;
int? get isForce => upgrade?.isForce;
String? get remark => describe ?? upgrade?.remark;
String? get filePath =>
_readShellUpgradeString(installOssUrl ?? upgrade?.filePath);
int? get fileSize => upgrade?.fileSize;
bool get hasStructuredPayload => upgrade != null || config != null;
/// 使 `upgrade.version` buildNumber
///
/// `true`
///
bool shouldOfferUpgrade({required int localVersion}) {
final remoteVersion = upgrade?.version;
if (remoteVersion == null) {
return false;
}
return remoteVersion > localVersion;
}
/// `data.config`
/// - `upgrade`
/// - `config` initialUrl / preferredOrientations
factory ShellUpgradeConfig.fromJson(Map<String, dynamic> json) {
final data = json['data'];
final dataMap = data is Map<String, dynamic>
? data
: data is Map
? Map<String, dynamic>.from(data)
: const <String, dynamic>{};
final rawConfig = _readShellUpgradeString(dataMap['config']);
final parsedConfig = _readShellUpgradeEmbeddedConfig(rawConfig);
final rawBootstrapConfig = parsedConfig?['config'];
final bootstrapConfig = rawBootstrapConfig is Map<String, dynamic>
? ShellBootstrapConfig.fromJson(rawBootstrapConfig)
: rawBootstrapConfig is Map
? ShellBootstrapConfig.fromJson(
Map<String, dynamic>.from(rawBootstrapConfig),
)
: null;
return ShellUpgradeConfig(
versionName: json['versionName']?.toString(),
version: json['version'] as int?,
isForce: (json['isForce'] ?? json['isforce']) as int?,
remark: json['remark']?.toString(),
filePath: json['filePath']?.toString(),
fileSize: json['fileSize'] as int?,
success: _readShellUpgradeBool(json['success']),
responseCode: _readShellUpgradeString(json['code']),
msg: _readShellUpgradeString(json['msg']),
id: _readShellUpgradeInt(dataMap['id']),
upgradeFileId: _readShellUpgradeInt(dataMap['upgradeFileId']),
installFileId: _readShellUpgradeInt(dataMap['installFileId']),
isLatest: _readShellUpgradeBool(dataMap['isLatest']),
appId: _readShellUpgradeInt(dataMap['appId']),
releaseCode: _readShellUpgradeString(dataMap['code']),
releaseTime: _readShellUpgradeString(dataMap['releaseTime']),
releaseStatus: _readShellUpgradeBool(dataMap['releaseStatus']),
upgradeOssUrl: _readShellUpgradeString(dataMap['upgradeOssUrl']),
installOssUrl: _readShellUpgradeString(dataMap['installOssUrl']),
isAllowRollback: _readShellUpgradeBool(dataMap['isAllowRollback']),
rawConfig: rawConfig,
upgrade: ShellUpgradeReleaseConfig.fromDynamic(parsedConfig?['upgrade']),
config: bootstrapConfig,
configDescribe: _readShellUpgradeString(dataMap['configDescribe']),
describe: _readShellUpgradeString(dataMap['describe']),
describeType: _readShellUpgradeInt(dataMap['describeType']),
environment: _readShellUpgradeInt(dataMap['environment']),
createdTime: _readShellUpgradeString(dataMap['createdTime']),
);
}
}
/// `yx_app_upgrade_flutter`
String? _readShellUpgradeString(dynamic value) {
final normalized = value?.toString().trim();
if (normalized == null || normalized.isEmpty) {
return null;
}
return normalized;
}
int? _readShellUpgradeInt(dynamic value) {
if (value is int) {
return value;
}
if (value is num) {
return value.toInt();
}
final normalized = value?.toString().trim();
if (normalized == null || normalized.isEmpty) {
return null;
}
return int.tryParse(normalized);
}
bool? _readShellUpgradeBool(dynamic value) {
if (value is bool) {
return value;
}
if (value is num) {
return value != 0;
}
final normalized = value?.toString().trim().toLowerCase();
if (normalized == null || normalized.isEmpty) {
return null;
}
if (normalized == 'true' || normalized == '1') {
return true;
}
if (normalized == 'false' || normalized == '0') {
return false;
}
return null;
}
/// `data.config` JSON
Map<String, dynamic>? _readShellUpgradeEmbeddedConfig(String? value) {
if (value == null || value.isEmpty) {
return null;
}
try {
final decoded = jsonDecode(value);
if (decoded is Map<String, dynamic>) {
return decoded;
}
if (decoded is Map) {
return Map<String, dynamic>.from(decoded);
}
} catch (e) {
debugPrint('解析 WebShell 升级配置 data.config 异常: $e');
}
return null;
}
/// yx_app_upgrade_flutter
class ShellUpgradeService {
static final ShellUpgradeService instance = ShellUpgradeService._();
ShellUpgradeService._();
String? _configUrl;
Future<int?> Function()? _localBuildNumberResolver;
///
///
/// URL
void setupConfigUrl(String? configUrl) {
_configUrl = configUrl?.trim();
}
///
///
/// +
/// `initialUrl``preferredOrientations`
/// `core_app.dart` reload
Future<void> checkVersion(
BuildContext context, {
bool showNoUpdateToast = false,
@ -63,7 +300,8 @@ class ShellUpgradeService {
return;
}
UpgradeAuxiliaryUtils.instance.initiateVersionCheck( // coverage:ignore-line
UpgradeAuxiliaryUtils.instance.initiateVersionCheck(
// coverage:ignore-line
context,
showNoUpdateToast: showNoUpdateToast,
future: _createVersionResolver(remoteConfig), // coverage:ignore-line
@ -74,10 +312,53 @@ class ShellUpgradeService {
ShellUpgradeConfig remoteConfig,
) {
return (int upType) async {
return _convertToAppUpgradeVersion(remoteConfig);
//
//
final upgradeVersion = _convertToAppUpgradeVersion(remoteConfig);
if (upgradeVersion == null) {
return null;
}
// WebShell
//
// buildNumber 退
final localBuildNumber = await _resolveLocalBuildNumber();
if (localBuildNumber == null) {
debugPrint('获取 WebShell 本地版本号失败,回退到升级插件内部版本比较');
return upgradeVersion;
}
if (!remoteConfig.shouldOfferUpgrade(localVersion: localBuildNumber)) {
debugPrint(
'WebShell 远端版本(${remoteConfig.version}) <= 本地版本($localBuildNumber),跳过升级提示',
);
return null;
}
return upgradeVersion;
};
}
/// 使 resolver退 buildNumber
Future<int?> _resolveLocalBuildNumber() async {
final resolver = _localBuildNumberResolver;
if (resolver != null) {
return resolver();
}
try {
final appInfo = await AppUpgradePlugin().getAppInfo();
return int.tryParse((appInfo['buildNumber'] ?? '').trim());
} catch (e) {
debugPrint('获取 WebShell 本地版本号异常: $e');
return null;
}
}
@visibleForTesting
void debugSetLocalBuildNumberResolver(Future<int?> Function()? resolver) {
_localBuildNumberResolver = resolver;
}
/// JSON
Future<ShellUpgradeConfig?> _fetchConfig(String url) async {
try {
final uri = Uri.tryParse(url);
@ -98,14 +379,22 @@ class ShellUpgradeService {
}
}
///
///
/// `upgrade` `config`
///
ShellUpgradeConfig? _parseConfigString(String content) {
try {
final dynamic jsonMap = jsonDecode(content);
if (jsonMap is Map<String, dynamic>) {
final data = jsonMap['data'] is Map<String, dynamic>
? jsonMap['data'] as Map<String, dynamic>
: jsonMap;
return ShellUpgradeConfig.fromJson(data);
final config = ShellUpgradeConfig.fromJson(jsonMap);
return config.hasStructuredPayload ? config : null;
}
if (jsonMap is Map) {
final config = ShellUpgradeConfig.fromJson(
Map<String, dynamic>.from(jsonMap),
);
return config.hasStructuredPayload ? config : null;
}
} catch (e) {
debugPrint('解析 WebShell 升级配置异常: $e');
@ -114,6 +403,14 @@ class ShellUpgradeService {
}
/// JSON
///
///
/// - `upgrade.versionName` -> `versionName`
/// - `upgrade.version` -> `versionBuildNumber`
/// - `upgrade.isForce` -> `isForce`
/// - `describe/upgrade.remark` -> `updateContent`
/// - `installOssUrl/upgrade.filePath` ->
/// - `upgrade.fileSize`(KB) -> `apkSize`(Byte)
AppUpgradeVersion? _convertToAppUpgradeVersion(ShellUpgradeConfig config) {
if (config.version == null || config.versionName == null) {
return null;

View File

@ -53,6 +53,22 @@ class ShellCoreTestHooks {
///
Future<String?> applyBootstrapConfig() => _applyBootstrapConfig();
///
Future<ShellUpgradeConfig?> reloadUpgradeRuntimeConfigForTest() {
return reloadUpgradeRuntimeConfig();
}
///
Future<ShellUpgradeConfig?> reloadUpgradeRuntimeConfigAndCheckVersionForTest(
BuildContext context, {
bool showNoUpdateToast = false,
}) {
return reloadUpgradeRuntimeConfigAndCheckVersion(
context,
showNoUpdateToast: showNoUpdateToast,
);
}
///
ShellUpgradeConfig? parseUpgradeConfigString(String content) {
return ShellUpgradeService.instance._parseConfigString(content);
@ -76,6 +92,11 @@ class ShellCoreTestHooks {
return ShellUpgradeService.instance._createVersionResolver(config)(upType);
}
///
void setLocalBuildNumberResolver(Future<int?> Function()? resolver) {
ShellUpgradeService.instance.debugSetLocalBuildNumberResolver(resolver);
}
///
void setupUpgradeConfigUrl(String? url) {
ShellUpgradeService.instance.setupConfigUrl(url);

View File

@ -35,9 +35,13 @@ class _WebShellPageState extends State<WebShellPage>
bool _hasStartedRemoteMainFrame = false;
bool _hasMainFrameError = false;
bool _hasMeasuredProgress = false;
bool _isApplyingRuntimeConfigReload = false;
bool _hasPendingRuntimeConfigReload = false;
Timer? _startupWatchdogTimer;
int _progress = 0;
int _startupRetryCount = 0;
int _lastRuntimeConfigVersion = _runtimeConfigVersion.value;
String _appliedInitialUrl = _initialUrl;
String _currentUrl = _initialUrl;
String _errorTitle = '页面加载失败';
String _errorMessage = '请检查网络后重试。';
@ -48,6 +52,7 @@ class _WebShellPageState extends State<WebShellPage>
super.initState();
debugPrint('WebShell 初始化,初始地址=$_initialUrl');
WidgetsBinding.instance.addObserver(this);
_runtimeConfigVersion.addListener(_handleRuntimeConfigChanged);
_androidCompatibilityFuture = _prepareAndroidCompatibility();
_recreateWebView();
@ -57,8 +62,8 @@ class _WebShellPageState extends State<WebShellPage>
}
_hasTriggeredInitialLoad = true;
unawaited(_loadInitialPage());
//
unawaited(ShellUpgradeService.instance.checkVersion(context));
//
unawaited(reloadUpgradeRuntimeConfigAndCheckVersion(context));
});
}
@ -444,10 +449,51 @@ class _WebShellPageState extends State<WebShellPage>
@override
void dispose() {
_cancelStartupWatchdog();
_runtimeConfigVersion.removeListener(_handleRuntimeConfigChanged);
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
void _handleRuntimeConfigChanged() {
final nextVersion = _runtimeConfigVersion.value;
if (nextVersion == _lastRuntimeConfigVersion) {
return;
}
_lastRuntimeConfigVersion = nextVersion;
_hasPendingRuntimeConfigReload = true;
unawaited(_applyPendingRuntimeConfigReload());
}
Future<void> _applyPendingRuntimeConfigReload() async {
if (_isApplyingRuntimeConfigReload) {
return;
}
_isApplyingRuntimeConfigReload = true;
try {
while (_hasPendingRuntimeConfigReload && mounted) {
_hasPendingRuntimeConfigReload = false;
final nextInitialUrl = _initialUrl;
final shouldReloadInitialPage = _appliedInitialUrl != nextInitialUrl;
_appliedInitialUrl = nextInitialUrl;
if (!shouldReloadInitialPage) {
continue;
}
debugPrint('WebShell 检测到运行时配置更新,重新加载初始地址: $nextInitialUrl');
await _ensureCompatibilityPlanApplied();
if (!mounted) {
return;
}
await _startLoadSequence(rebuildWebView: false, resetRetryCount: true);
}
} finally {
_isApplyingRuntimeConfigReload = false;
}
}
//
Future<void> _loadInitialPage() async {
@ -693,6 +739,20 @@ class _WebShellPageState extends State<WebShellPage>
case 'reloadPage':
await _reloadPage();
data = true;
case 'reloadUpgradeRuntimeConfig':
data = _runtimeConfigSnapshotFromBridge(
await reloadUpgradeRuntimeConfig(),
);
case 'reloadUpgradeRuntimeConfigAndCheckVersion':
data = _runtimeConfigSnapshotFromBridge(
await reloadUpgradeRuntimeConfigAndCheckVersion(
context,
showNoUpdateToast: _boolValue(
payload['showNoUpdateToast'],
defaultValue: false,
),
),
);
case 'goBack':
data = await _goBackFromBridge();
case 'closeApp':

View File

@ -22,6 +22,9 @@ const MethodChannel _platformChannel = SystemChannels.platform;
const _permissionChannel = MethodChannel(
'flutter.baseflow.com/permissions/methods',
);
const _packageInfoChannel = MethodChannel(
'dev.fluttercommunity.plus/package_info',
);
const _testEnvironment = ShellEnvironment(
appName: '测试应用',
@ -47,8 +50,9 @@ Future<Uri> _startJsonServer(
);
request.response.write(body);
} catch (error) {
request.response.statusCode =
error is int ? error : HttpStatus.internalServerError;
request.response.statusCode = error is int
? error
: HttpStatus.internalServerError;
} finally {
await request.response.close();
}
@ -120,6 +124,19 @@ void main() {
for (final permission in permissions) permission: statusValue,
};
});
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(_packageInfoChannel, (call) async {
if (call.method != 'getAll') {
return null;
}
return <String, String>{
'appName': 'web_shell_core_test',
'packageName': 'com.example.web_shell_core_test',
'version': '1.0.0',
'buildNumber': '1',
};
});
});
tearDown(() {
@ -128,6 +145,8 @@ void main() {
.setMockMethodCallHandler(_platformChannel, null);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(_permissionChannel, null);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(_packageInfoChannel, null);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMessageHandler('flutter/assets', null);
});
@ -1254,7 +1273,10 @@ void main() {
);
expect(config, isNotNull);
expect(config!.bootstrapConfigUrl, ' https://example.com/bootstrap.json ');
expect(
config!.bootstrapConfigUrl,
' https://example.com/bootstrap.json ',
);
expect(config.upgradeConfigUrl, ' https://example.com/upgrade.json ');
expect(
config.preferredOrientations,
@ -1267,15 +1289,19 @@ void main() {
test('启动配置解析支持空数组、非法对象与非法 JSON', () {
expect(
shellCoreTestHooks.parseBootstrapConfigString(
shellCoreTestHooks
.parseBootstrapConfigString(
'{"preferredOrientations":[]}',
)!.preferredOrientations,
)!
.preferredOrientations,
<DeviceOrientation>[],
);
expect(
shellCoreTestHooks.parseBootstrapConfigString(
shellCoreTestHooks
.parseBootstrapConfigString(
'{"preferredOrientations":"portraitUp"}',
)!.preferredOrientations,
)!
.preferredOrientations,
isNull,
);
expect(shellCoreTestHooks.parseBootstrapConfigString('['), isNull);
@ -1319,9 +1345,12 @@ void main() {
await unavailableSocket.close();
final successUri = await _startJsonServer(
(_) async => '{"data":{"initialUrl":"https://remote.example.com","upgradeConfigUrl":"https://remote.example.com/upgrade.json"}}',
(_) async =>
'{"data":{"initialUrl":"https://remote.example.com","upgradeConfigUrl":"https://remote.example.com/upgrade.json"}}',
);
final success = await _runWithRealHttpClient(
() => shellCoreTestHooks.fetchBootstrapConfig(successUri.toString()),
);
final success = await _runWithRealHttpClient(() => shellCoreTestHooks.fetchBootstrapConfig(successUri.toString()));
expect(success?.initialUrl, 'https://remote.example.com');
expect(
success?.upgradeConfigUrl,
@ -1341,7 +1370,9 @@ void main() {
expect(invalidUri?.initialUrl, 'https://remote.example.com');
final notFoundUri = await _startJsonServer((_) async => throw 404);
final notFound = await _runWithRealHttpClient(() => shellCoreTestHooks.fetchBootstrapConfig(notFoundUri.toString()));
final notFound = await _runWithRealHttpClient(
() => shellCoreTestHooks.fetchBootstrapConfig(notFoundUri.toString()),
);
expect(notFound?.initialUrl, 'https://remote.example.com');
final failed = await _runWithRealHttpClient(
@ -1355,7 +1386,9 @@ void main() {
test('读取启动配置缓存异常时返回 null', () async {
final originalStore = SharedPreferencesStorePlatform.instance;
SharedPreferencesStorePlatform.instance = _ThrowingPreferencesStore();
addTearDown(() => SharedPreferencesStorePlatform.instance = originalStore);
addTearDown(
() => SharedPreferencesStorePlatform.instance = originalStore,
);
expect(await shellCoreTestHooks.loadCachedBootstrapConfig(), isNull);
});
@ -1379,7 +1412,7 @@ void main() {
expect(script, contains('window.AppShell'));
});
test('脚本暴露全部 12 个 Action', () {
test('脚本暴露全部 14 个 Action', () {
final script = shellCoreTestHooks.buildAppShellBridgeScript();
for (final action in <String>[
@ -1389,6 +1422,8 @@ void main() {
'openExternal',
'requestPermissions',
'reloadPage',
'reloadUpgradeRuntimeConfig',
'reloadUpgradeRuntimeConfigAndCheckVersion',
'goBack',
'closeApp',
'getDeviceInfo',
@ -1603,6 +1638,7 @@ void main() {
isNull,
);
});
});
group('Phase 2: 新增 Bridge Action', () {
@ -1943,42 +1979,310 @@ void main() {
await tester.pumpAndSettle();
});
test('升级配置解析支持 data、平铺、isforce 和非法 JSON', () {
final wrapped = shellCoreTestHooks.parseUpgradeConfigString(
'{"data":{"versionName":"1.0.1","version":101,"isforce":1,"remark":"fix","filePath":"https://example.com/app.apk","fileSize":2048}}',
testWidgets('checkVersion 不会隐式覆盖运行时首页配置', (tester) async {
shellCoreTestHooks.initializeEnvironment(
_testEnvironment.copyWith(
initialUrl: 'https://before.example.com/home',
preferredOrientations: <DeviceOrientation>[
DeviceOrientation.landscapeLeft,
],
),
);
final flat = shellCoreTestHooks.parseUpgradeConfigString(
'{"versionName":"1.0.2","version":102}',
addTearDown(
() => shellCoreTestHooks.initializeEnvironment(_testEnvironment),
);
late BuildContext pageContext;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) {
pageContext = context;
return const SizedBox.shrink();
},
),
),
);
expect(wrapped, isNotNull);
expect(wrapped!.versionName, '1.0.1');
expect(wrapped.isForce, 1);
expect(flat?.version, 102);
expect(shellCoreTestHooks.parseUpgradeConfigString('{'), isNull);
await tester.runAsync(() async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
try {
server.listen((request) async {
request.response.statusCode = HttpStatus.ok;
request.response.headers.set(
HttpHeaders.contentTypeHeader,
'application/json; charset=utf-8',
);
request.response.write(
jsonEncode({
'success': true,
'data': {
'config': jsonEncode({
'upgrade': {
'versionName': '1.0.0',
'version': 1,
},
'config': {
'initialUrl': 'https://remote.example.com/runtime',
'preferredOrientations': ['portraitUp', 'portraitDown'],
},
}),
},
}),
);
await request.response.close();
});
test('升级配置转换为 AppUpgradeVersion 时处理缺省值', () {
shellCoreTestHooks.setupUpgradeConfigUrl(
'http://127.0.0.1:${server.port}/config.json',
);
await _runWithRealHttpClient(
() => shellCoreTestHooks.checkVersion(pageContext),
);
} finally {
await server.close(force: true);
}
});
await tester.pumpAndSettle();
expect(
shellCoreTestHooks.convertUpgradeConfig(
ShellUpgradeConfig(versionName: '1.0.0'),
shellCoreTestHooks.configuredInitialUrl,
'https://before.example.com/home',
);
expect(shellCoreTestHooks.initialUrl, 'https://before.example.com/home');
expect(
shellCoreTestHooks.configuredPreferredOrientations,
<DeviceOrientation>[DeviceOrientation.landscapeLeft],
);
});
testWidgets('reloadUpgradeRuntimeConfigAndCheckVersion 先重载配置再检查升级', (
tester,
) async {
shellCoreTestHooks.initializeEnvironment(
_testEnvironment.copyWith(
initialUrl: 'https://before.example.com/home',
),
);
addTearDown(() {
shellCoreTestHooks.initializeEnvironment(_testEnvironment);
shellCoreTestHooks.setupUpgradeConfigUrl(null);
});
late BuildContext pageContext;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) {
pageContext = context;
return const SizedBox.shrink();
},
),
),
);
await tester.runAsync(() async {
final upgradeServer = await HttpServer.bind(
InternetAddress.loopbackIPv4,
0,
);
var upgradeCheckCount = 0;
upgradeServer.listen((request) async {
upgradeCheckCount += 1;
request.response.statusCode = HttpStatus.ok;
request.response.headers.set(
HttpHeaders.contentTypeHeader,
'application/json; charset=utf-8',
);
request.response.write(
jsonEncode({
'success': true,
'data': {
'installOssUrl': 'https://example.com/app.apk',
'config': jsonEncode({
'upgrade': {
'versionName': '1.0.0',
'version': 1,
},
}),
},
}),
);
await request.response.close();
});
final configServer = await HttpServer.bind(
InternetAddress.loopbackIPv4,
0,
);
configServer.listen((request) async {
request.response.statusCode = HttpStatus.ok;
request.response.headers.set(
HttpHeaders.contentTypeHeader,
'application/json; charset=utf-8',
);
request.response.write(
jsonEncode({
'success': true,
'data': {
'config': jsonEncode({
'upgrade': {
'versionName': '1.0.0',
'version': 1,
},
'config': {
'initialUrl': 'https://remote.example.com/runtime',
'preferredOrientations': ['portraitUp', 'portraitDown'],
'upgradeConfigUrl':
'http://127.0.0.1:${upgradeServer.port}/upgrade.json',
},
}),
},
}),
);
await request.response.close();
});
try {
shellCoreTestHooks.setupUpgradeConfigUrl(
'http://127.0.0.1:${configServer.port}/config.json',
);
final remoteConfig = await _runWithRealHttpClient(
() => shellCoreTestHooks
.reloadUpgradeRuntimeConfigAndCheckVersionForTest(
pageContext,
),
);
expect(remoteConfig, isNotNull);
expect(
shellCoreTestHooks.configuredInitialUrl,
'https://remote.example.com/runtime',
);
expect(
shellCoreTestHooks.upgradeConfigUrl,
'http://127.0.0.1:${upgradeServer.port}/upgrade.json',
);
expect(upgradeCheckCount, 1);
} finally {
await configServer.close(force: true);
await upgradeServer.close(force: true);
}
});
});
test('升级配置解析仅支持新版接口响应', () {
final wrappedContent = jsonEncode({
'success': true,
'code': null,
'msg': null,
'data': {
'id': 787171620319301,
'upgradeFileId': 0,
'installFileId': 787171620307013,
'isLatest': true,
'appId': 787154824761413,
'code': '1.0.1',
'releaseTime': '2026-03-24 09:51:23',
'releaseStatus': true,
'upgradeOssUrl': '',
'installOssUrl':
'https://umsapi.23544.com/api/biz/Action/download-app?appId=787154824761413&environment=DevelopmentEnvironment&fileType=install&versionCode=1.0.1',
'isAllowRollback': true,
'config': jsonEncode({
'upgrade': {
'versionName': '1.0.0',
'version': 100,
'isForce': 0,
'remark': '1. 修复已知问题\n2. 测试升级弹窗功能是否正常加载',
'filePath':
'https://gitee.com/mr_koi/static_host/raw/master/app-release.apk',
'fileSize': 30000,
},
'config': {
'initialUrl': 'http://xszy.lzzneng.com/login.html',
'preferredOrientations': ['portraitUp', 'portraitDown'],
},
}),
'configDescribe': '',
'describe': '首次更新',
'describeType': 1,
'environment': 1,
'createdTime': '2026-03-24 09:49:32.581',
},
});
final structured = shellCoreTestHooks.parseUpgradeConfigString(
wrappedContent,
);
expect(structured, isNotNull);
expect(structured!.success, isTrue);
expect(structured.id, 787171620319301);
expect(structured.installFileId, 787171620307013);
expect(structured.isLatest, isTrue);
expect(structured.appId, 787154824761413);
expect(structured.releaseCode, '1.0.1');
expect(structured.releaseStatus, isTrue);
expect(structured.isAllowRollback, isTrue);
expect(structured.describe, '首次更新');
expect(structured.describeType, 1);
expect(structured.environment, 1);
expect(structured.createdTime, '2026-03-24 09:49:32.581');
expect(structured.upgrade, isNotNull);
expect(structured.versionName, '1.0.0');
expect(structured.version, 100);
expect(structured.isForce, 0);
expect(structured.remark, contains('修复已知问题'));
expect(
structured.filePath,
'https://umsapi.23544.com/api/biz/Action/download-app?appId=787154824761413&environment=DevelopmentEnvironment&fileType=install&versionCode=1.0.1',
);
expect(structured.fileSize, 30000);
expect(structured.config, isNotNull);
expect(
structured.config?.initialUrl,
'http://xszy.lzzneng.com/login.html',
);
expect(
structured.config?.preferredOrientations,
<DeviceOrientation>[
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
],
);
expect(
shellCoreTestHooks.parseUpgradeConfigString(
'{"versionName":"1.0.2","version":102}',
),
isNull,
);
expect(shellCoreTestHooks.parseUpgradeConfigString('{'), isNull);
});
test('升级配置转换为 AppUpgradeVersion 时仅支持顶层 upgrade', () {
expect(
shellCoreTestHooks.convertUpgradeConfig(ShellUpgradeConfig(version: 1)),
shellCoreTestHooks.convertUpgradeConfig(const ShellUpgradeConfig()),
isNull,
);
expect(
shellCoreTestHooks.convertUpgradeConfig(
const ShellUpgradeConfig(
upgrade: ShellUpgradeReleaseConfig(version: 1),
),
),
isNull,
);
final converted = shellCoreTestHooks.convertUpgradeConfig(
ShellUpgradeConfig(
const ShellUpgradeConfig(
upgrade: ShellUpgradeReleaseConfig(
versionName: '1.2.3',
version: 123,
isForce: 1,
remark: 'important update',
filePath: '',
fileSize: 2048,
),
),
);
expect(converted, isNotNull);
@ -1992,29 +2296,74 @@ void main() {
expect(converted.supportedMethods, hasLength(3));
final convertedWithPath = shellCoreTestHooks.convertUpgradeConfig(
ShellUpgradeConfig(
const ShellUpgradeConfig(
upgrade: ShellUpgradeReleaseConfig(
versionName: '2.0.0',
version: 200,
filePath: 'https://example.com/app.apk',
),
),
);
expect(convertedWithPath?.downloadUrl, 'https://example.com/app.apk');
expect(convertedWithPath?.appStoreUrl, 'https://example.com/app.apk');
final convertedFromStructuredConfig = shellCoreTestHooks
.convertUpgradeConfig(
const ShellUpgradeConfig(
upgrade: ShellUpgradeReleaseConfig(
versionName: '3.0.0',
version: 300,
isForce: 0,
remark: '1. 修复已知问题\n2. 测试升级弹窗功能是否正常加载',
filePath: 'https://download.example.com/app.apk',
fileSize: 25000,
),
),
);
expect(convertedFromStructuredConfig, isNotNull);
expect(convertedFromStructuredConfig!.versionName, '3.0.0');
expect(convertedFromStructuredConfig.versionBuildNumber, 300);
expect(convertedFromStructuredConfig.isForce, isFalse);
expect(convertedFromStructuredConfig.updateContent, contains('修复已知问题'));
expect(
convertedFromStructuredConfig.downloadUrl,
'https://download.example.com/app.apk',
);
expect(
convertedFromStructuredConfig.appStoreUrl,
'https://download.example.com/app.apk',
);
expect(convertedFromStructuredConfig.apkSize, 25000 * 1024);
});
test('获取升级配置支持成功、非法地址、非 200 和异常', () async {
final successUri = await _startJsonServer(
(_) async => '{"versionName":"2.0.0","version":200}',
(_) async => jsonEncode({
'success': true,
'data': {
'installOssUrl': 'https://example.com/app.apk',
'config': jsonEncode({
'upgrade': {
'versionName': '2.0.0',
'version': 200,
},
}),
},
}),
);
final success = await _runWithRealHttpClient(() => shellCoreTestHooks.fetchUpgradeConfig(successUri.toString()));
final success = await _runWithRealHttpClient(
() => shellCoreTestHooks.fetchUpgradeConfig(successUri.toString()),
);
expect(success?.versionName, '2.0.0');
expect(await shellCoreTestHooks.fetchUpgradeConfig('%%%'), isNull);
final notFoundUri = await _startJsonServer((_) async => throw 404);
expect(
await _runWithRealHttpClient(() => shellCoreTestHooks.fetchUpgradeConfig(notFoundUri.toString())),
await _runWithRealHttpClient(
() => shellCoreTestHooks.fetchUpgradeConfig(notFoundUri.toString()),
),
isNull,
);
@ -2037,7 +2386,8 @@ void main() {
test('applyBootstrapConfig 会用远程启动配置覆盖本地配置', () async {
final bootstrapUri = await _startJsonServer(
(_) async => '{"data":{"initialUrl":"https://remote.example.com/home","preferredOrientations":["portraitDown"],"upgradeConfigUrl":" https://remote.example.com/upgrade.json "}}',
(_) async =>
'{"data":{"initialUrl":"https://remote.example.com/home","preferredOrientations":["portraitDown"],"upgradeConfigUrl":" https://remote.example.com/upgrade.json "}}',
);
shellCoreTestHooks.initializeEnvironment(
const ShellEnvironment(
@ -2096,8 +2446,7 @@ void main() {
assetContents['assets/config/fallback_bootstrap.json'] = jsonEncode(
<String, Object?>{
'initialUrl': 'https://asset.example.com/start',
'bootstrapConfigUrl':
'http://127.0.0.1:$unavailablePort/config.json',
'bootstrapConfigUrl': 'http://127.0.0.1:$unavailablePort/config.json',
'upgradeConfigUrl': ' https://asset.example.com/upgrade.json ',
},
);
@ -2128,13 +2477,82 @@ void main() {
shellCoreTestHooks.initializeEnvironment(_testEnvironment);
});
test('reloadUpgradeRuntimeConfig 会应用升级配置中的运行时启动配置', () async {
shellCoreTestHooks.initializeEnvironment(
_testEnvironment.copyWith(
initialUrl: 'https://before.example.com/home',
preferredOrientations: <DeviceOrientation>[
DeviceOrientation.landscapeLeft,
],
),
);
addTearDown(() {
shellCoreTestHooks.initializeEnvironment(_testEnvironment);
shellCoreTestHooks.setupUpgradeConfigUrl(null);
});
final successUri = await _startJsonServer(
(_) async => jsonEncode({
'success': true,
'data': {
'config': jsonEncode({
'upgrade': {
'versionName': '2.0.0',
'version': 200,
},
'config': {
'initialUrl': 'https://remote.example.com/runtime',
'preferredOrientations': ['portraitUp', 'portraitDown'],
'upgradeConfigUrl': 'https://remote.example.com/upgrade.json',
},
}),
},
}),
);
shellCoreTestHooks.setupUpgradeConfigUrl(successUri.toString());
final remoteConfig = await _runWithRealHttpClient(
shellCoreTestHooks.reloadUpgradeRuntimeConfigForTest,
);
expect(remoteConfig, isNotNull);
expect(
shellCoreTestHooks.configuredInitialUrl,
'https://remote.example.com/runtime',
);
expect(
shellCoreTestHooks.initialUrl,
'https://remote.example.com/runtime',
);
expect(
shellCoreTestHooks.configuredPreferredOrientations,
<DeviceOrientation>[
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
],
);
expect(
shellCoreTestHooks.preferredOrientations,
<DeviceOrientation>[
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
],
);
expect(
shellCoreTestHooks.upgradeConfigUrl,
'https://remote.example.com/upgrade.json',
);
});
testWidgets('LaunchOverlay 配置品牌图片时渲染 Image', (tester) async {
shellCoreTestHooks.initializeEnvironment(
_testEnvironment.copyWith(
splashImage: MemoryImage(Uint8List.fromList(kTransparentImage)),
),
);
addTearDown(() => shellCoreTestHooks.initializeEnvironment(_testEnvironment));
addTearDown(
() => shellCoreTestHooks.initializeEnvironment(_testEnvironment),
);
await tester.pumpWidget(
const MaterialApp(
@ -2213,18 +2631,53 @@ void main() {
expect(find.text('长提示'), findsNothing);
});
test('升级配置异步解析闭包可返回版本信息', () async {
test('升级配置异步解析闭包按本地版本决定是否返回升级信息', () async {
addTearDown(() => shellCoreTestHooks.setLocalBuildNumberResolver(null));
shellCoreTestHooks.setLocalBuildNumberResolver(() async => 200);
final version = await shellCoreTestHooks.resolveUpgradeConfig(
ShellUpgradeConfig(
const ShellUpgradeConfig(
upgrade: ShellUpgradeReleaseConfig(
versionName: '3.0.0',
version: 300,
filePath: 'https://example.com/app.apk',
),
),
);
expect(version?.versionName, '3.0.0');
expect(version?.versionBuildNumber, 300);
expect(version?.downloadUrl, 'https://example.com/app.apk');
shellCoreTestHooks.setLocalBuildNumberResolver(() async => 300);
final sameVersion = await shellCoreTestHooks.resolveUpgradeConfig(
const ShellUpgradeConfig(
upgrade: ShellUpgradeReleaseConfig(
versionName: '3.0.0',
version: 300,
filePath: 'https://example.com/app.apk',
),
),
);
expect(sameVersion, isNull);
shellCoreTestHooks.setLocalBuildNumberResolver(() async => null);
final fallbackVersion = await shellCoreTestHooks.resolveUpgradeConfig(
const ShellUpgradeConfig(
upgrade: ShellUpgradeReleaseConfig(
versionName: '3.0.0',
version: 300,
filePath: 'https://example.com/app.apk',
),
),
);
expect(fallbackVersion?.versionName, '3.0.0');
expect(fallbackVersion?.versionBuildNumber, 300);
final hiddenVersion = await shellCoreTestHooks.resolveUpgradeConfig(
const ShellUpgradeConfig(),
);
expect(hiddenVersion, isNull);
});
});
}
@ -2467,6 +2920,7 @@ class _FakePlatformWebViewController extends PlatformWebViewController {
_FakePlatformWebViewController(super.params) : super.implementation();
final javaScriptCalls = <String>[];
final javaScriptChannels = <String, JavaScriptChannelParams>{};
PlatformNavigationDelegate? delegate;
bool throwOnRunJavaScript = false;
bool canGoBackValue = false;
@ -2474,7 +2928,20 @@ class _FakePlatformWebViewController extends PlatformWebViewController {
Uri? lastLoadedUri;
@override
Future<void> addJavaScriptChannel(JavaScriptChannelParams params) async {}
Future<void> addJavaScriptChannel(JavaScriptChannelParams params) async {
javaScriptChannels[params.name] = params;
}
Future<void> dispatchJavaScriptMessage(
String channelName,
String message,
) async {
final channel = javaScriptChannels[channelName];
if (channel == null) {
throw StateError('JavaScript channel not found: $channelName');
}
channel.onMessageReceived(JavaScriptMessage(message: message));
}
@override
Future<void> clearCache() async {}