feat: add app update checks and standalone release publishing panel

This commit is contained in:
Codex
2026-10-24 13:50:42 +08:00
parent 437ed14dbe
commit 75e0b5587d
27 changed files with 1566 additions and 7 deletions
+3 -1
View File
@@ -1,6 +1,6 @@
# TPSchedule Flutter工程
当前版本0.7.5+23,安卓包名 `com.tpschedule.app`。Dart包名保留 `hepan_everyday`,兼容既有代码引用。
当前版本0.7.6+24,安卓包名 `com.tpschedule.app`。Dart包名保留 `hepan_everyday`,兼容既有代码引用。
0.7.5兼容本科考试考场和座位合并为尚未发布的记录;新增可选HTML邮件附件及具体课表缺失字段诊断。HTML协议需要同步更新反馈服务器,见 `../server/feedback/html-diagnostic-update.md`。真实账号课表缺失字段根因尚待排查,见 `../docs/tpschedule-0.7.5-validation.md`。
@@ -17,3 +17,5 @@
本科连接模块在 `lib/features/school/undergraduate/`,学校标识在 `lib/domain/school_identity.dart`。HTML解析不执行网页脚本,样例已脱敏;真实账号、验证码及在线SSO需本人在学校网页验证。
不要删除、重建或分发根目录 `.tools/android-user/debug.keystore`;0.5.4登记冻结和V1.0材料不得用当前源码重生成。
0.7.6新增启动后检查更新及“我的→检查更新”,从HTTPS版本接口读取构建号与说明,点击立即更新打开浏览器。更新后台部署说明位于 `../server/updates/README.md`;初次需手动覆盖安装这一版,后续才可接收更新提示。
@@ -9,6 +9,7 @@ class MainActivity : FlutterActivity() {
private var calendar: CalendarBridge? = null
private var reminders: ReminderSystemBridge? = null
private var schoolDocuments: SchoolDocumentBridge? = null
private var updateBrowser: UpdateBrowserBridge? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
@@ -23,10 +24,14 @@ class MainActivity : FlutterActivity() {
reminders = ReminderSystemBridge(this, flutterEngine.dartExecutor.binaryMessenger)
schoolDocuments?.close()
schoolDocuments = SchoolDocumentBridge(flutterEngine)
updateBrowser?.close()
updateBrowser = UpdateBrowserBridge(this, flutterEngine.dartExecutor.binaryMessenger)
}
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
schoolDocuments?.close()
schoolDocuments = null
updateBrowser?.close()
updateBrowser = null
super.cleanUpFlutterEngine(flutterEngine)
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
@@ -37,6 +42,7 @@ class MainActivity : FlutterActivity() {
calendar?.close()
reminders?.close()
schoolDocuments?.close()
updateBrowser?.close()
super.onDestroy()
}
}
@@ -0,0 +1,33 @@
package com.tpschedule.app
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
class UpdateBrowserBridge(private val activity: Activity, messenger: BinaryMessenger) {
private val channel = MethodChannel(messenger, "com.tpschedule.app/updates")
init {
channel.setMethodCallHandler { call, result ->
if (call.method != "openDownload") {
result.notImplemented()
} else {
val value = call.argument<String>("url")
val uri = value?.let { Uri.parse(it) }
if (value == null || value.length > 2048 || value.any { it.isWhitespace() || it.code < 32 } ||
uri?.scheme != "https" || uri.host.isNullOrBlank() || !uri.userInfo.isNullOrEmpty()) {
result.success(false)
} else {
try {
activity.startActivity(Intent(Intent.ACTION_VIEW, uri).addCategory(Intent.CATEGORY_BROWSABLE))
result.success(true)
} catch (_: ActivityNotFoundException) { result.success(false) }
catch (_: SecurityException) { result.success(false) }
}
}
}
}
fun close() { channel.setMethodCallHandler(null) }
}
+2 -2
View File
@@ -1,2 +1,2 @@
const appVersion = '0.7.5';
const appBuildNumber = 23;
const appVersion = '0.7.6';
const appBuildNumber = 24;
+61
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
@@ -18,6 +20,8 @@ import '../features/school/school_selection_page.dart';
import '../features/school/undergraduate/session.dart';
import '../features/school/undergraduate/exam_query_picker.dart';
import '../domain/undergraduate_exam_query.dart';
import '../services/app_update_service.dart';
import '../features/updates/update_prompt.dart';
class HepanApp extends StatelessWidget {
const HepanApp({
@@ -29,6 +33,8 @@ class HepanApp extends StatelessWidget {
this.undergraduateBuilder,
this.notificationSelection,
this.feedbackSender,
this.updateService,
this.openUpdateDownload = openUpdateInBrowser,
});
final AppController controller;
final Widget Function()? schoolBuilder;
@@ -37,6 +43,8 @@ class HepanApp extends StatelessWidget {
final Widget Function()? undergraduateBuilder;
final ValueNotifier<String?>? notificationSelection;
final FeedbackSender? feedbackSender;
final AppUpdateService? updateService;
final UpdateDownloadOpener openUpdateDownload;
@override
Widget build(BuildContext context) => MaterialApp(
debugShowCheckedModeBanner: false,
@@ -53,6 +61,8 @@ class HepanApp extends StatelessWidget {
undergraduateBuilder: undergraduateBuilder,
notificationSelection: notificationSelection,
feedbackSender: feedbackSender ?? configuredFeedbackSender(),
updateService: updateService,
openUpdateDownload: openUpdateDownload,
),
);
}
@@ -66,6 +76,8 @@ class _Shell extends StatefulWidget {
this.undergraduateBuilder,
this.notificationSelection,
required this.feedbackSender,
this.updateService,
required this.openUpdateDownload,
});
final AppController controller;
final Widget Function()? schoolBuilder;
@@ -74,6 +86,8 @@ class _Shell extends StatefulWidget {
final Widget Function()? undergraduateBuilder;
final ValueNotifier<String?>? notificationSelection;
final FeedbackSender feedbackSender;
final AppUpdateService? updateService;
final UpdateDownloadOpener openUpdateDownload;
@override
State<_Shell> createState() => _ShellState();
}
@@ -81,6 +95,40 @@ class _Shell extends StatefulWidget {
class _ShellState extends State<_Shell> with WidgetsBindingObserver {
int _tab = 0;
bool _openingSchool = false;
bool _checkingUpdate = false;
bool _updatePromptOpen = false;
Timer? _startupUpdate;
Future<void> _checkUpdate({bool silent = false}) async {
if (_checkingUpdate || _updatePromptOpen || widget.updateService == null) {
return;
}
setState(() => _checkingUpdate = true);
try {
final release = await widget.updateService!.check();
if (mounted) setState(() => _checkingUpdate = false);
if (!mounted || ModalRoute.of(context)?.isCurrent != true) return;
if (release != null) {
_updatePromptOpen = true;
await showUpdatePrompt(
context,
release,
openDownload: widget.openUpdateDownload,
);
} else if (!silent) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('当前已是最新版本')));
}
} on UpdateCheckException catch (error) {
if (!silent && mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(error.message)));
}
} finally {
_updatePromptOpen = false;
if (mounted) setState(() => _checkingUpdate = false);
}
}
// Restore the saved school; legacy graduate imports infer their source.
SupportedSchool? get _knownSchool =>
widget.controller.snapshot.selectedSchool ??
@@ -102,10 +150,19 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver {
WidgetsBinding.instance.addObserver(this);
widget.notificationSelection?.addListener(_selectNotification);
WidgetsBinding.instance.addPostFrameCallback((_) => _selectNotification());
if (widget.updateService != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_startupUpdate = Timer(const Duration(seconds: 2), () {
if (mounted) unawaited(_checkUpdate(silent: true));
});
});
}
}
@override
void dispose() {
_startupUpdate?.cancel();
WidgetsBinding.instance.removeObserver(this);
widget.notificationSelection?.removeListener(_selectNotification);
super.dispose();
@@ -400,6 +457,10 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver {
ProfilePage(
controller: widget.controller,
feedbackSender: widget.feedbackSender,
onCheckUpdates: widget.updateService == null
? null
: () => _checkUpdate(),
checkingUpdates: _checkingUpdate,
onLogin: () => _school(selectSchool: true),
schoolName: _knownSchool?.label,
onUpdateTerm: () => _school(chooseTerm: true),
@@ -24,6 +24,8 @@ class ProfilePage extends StatelessWidget {
this.logoutLabel = '退出学校登录',
this.onUpdateTerm,
this.feedbackSender = const UnconfiguredFeedbackSender(),
this.onCheckUpdates,
this.checkingUpdates = false,
});
final AppController controller;
final VoidCallback onLogin;
@@ -32,6 +34,8 @@ class ProfilePage extends StatelessWidget {
final VoidCallback? onUpdateTerm;
final String authStatus, logoutLabel;
final FeedbackSender feedbackSender;
final VoidCallback? onCheckUpdates;
final bool checkingUpdates;
Future<void> _import(BuildContext context) async {
try {
final file = await FilePicker.pickFile(
@@ -231,6 +235,32 @@ class ProfilePage extends StatelessWidget {
),
),
),
Divider(height: 1, color: ink.withValues(alpha: 0.06)),
ListTile(
key: const Key('check-app-update'),
contentPadding: EdgeInsets.zero,
leading: const Icon(
Icons.system_update_rounded,
color: river,
size: 23,
),
title: const Text(
'检查更新',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
subtitle: Text(
checkingUpdates ? '正在检查…' : '当前版本 $appVersion',
style: const TextStyle(fontSize: 11),
),
trailing: checkingUpdates
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.chevron_right_rounded),
onTap: checkingUpdates ? null : onCheckUpdates,
),
],
),
),
@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import '../../app/app_version.dart';
import '../../services/app_update_service.dart';
Future<void> showUpdatePrompt(
BuildContext context,
AppRelease release, {
UpdateDownloadOpener openDownload = openUpdateInBrowser,
}) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('发现新版本 ${release.version}'),
content: SizedBox(
width: 360,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('当前版本 $appVersion', style: TextStyle(fontSize: 12)),
const SizedBox(height: 16),
Text(release.notes, style: const TextStyle(height: 1.6)),
const SizedBox(height: 16),
const Text(
'将打开浏览器下载,安装时请保留现有应用和课表。',
style: TextStyle(fontSize: 12),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('稍后'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('立即更新'),
),
],
),
);
if (confirmed != true || !context.mounted) return;
bool opened;
try {
opened = await openDownload(release.downloadUri);
} catch (_) {
opened = false;
}
if (!opened && context.mounted) {
await showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('未能打开浏览器'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('可以复制下方链接到浏览器下载:'),
const SizedBox(height: 12),
SelectableText(release.downloadUri.toString()),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('知道了'),
),
],
),
);
}
}
+4
View File
@@ -7,6 +7,7 @@ import 'package:timezone/data/latest.dart' as tzdata;
import 'app/app_controller.dart';
import 'app/app_version.dart';
import 'services/diagnostic_log.dart';
import 'services/app_update_service.dart';
import 'app/hepan_app.dart';
import 'app/tp_startup.dart';
import 'data/file_snapshot_store.dart';
@@ -22,11 +23,13 @@ Widget buildApplication(
ValueNotifier<String?>? notificationSelection,
SchoolSession? school,
UndergraduateSession? undergraduate,
AppUpdateService? updateService,
}) => HepanApp(
controller: controller,
notificationSelection: notificationSelection,
school: school,
undergraduate: undergraduate,
updateService: updateService,
undergraduateBuilder: undergraduate == null
? null
: () => UndergraduateAuthPage(
@@ -63,6 +66,7 @@ Future<void> main() async {
notificationSelection: selection,
school: SchoolSession(),
undergraduate: UndergraduateSession(),
updateService: AppUpdateService(),
);
},
onHomeReady: () {
+182
View File
@@ -0,0 +1,182 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart'
show MethodChannel, PlatformException, MissingPluginException;
import '../app/app_version.dart';
class UpdateCheckException implements Exception {
const UpdateCheckException(this.message);
final String message;
}
Uri? updateDownloadUri(String value) {
if (value.length > 2048 || RegExp(r'[\s\x00-\x1f\x7f]').hasMatch(value)) {
return null;
}
final uri = Uri.tryParse(value);
return uri != null &&
uri.scheme == 'https' &&
uri.host.isNotEmpty &&
uri.userInfo.isEmpty
? uri
: null;
}
class AppRelease {
const AppRelease({
required this.version,
required this.buildNumber,
required this.notes,
required this.downloadUri,
});
final String version, notes;
final int buildNumber;
final Uri downloadUri;
}
class UpdateHttpResponse {
const UpdateHttpResponse(this.status, this.body);
final int status;
final List<int> body;
}
abstract interface class UpdateTransport {
Future<UpdateHttpResponse> get(Uri endpoint);
}
class IoUpdateTransport implements UpdateTransport {
const IoUpdateTransport();
@override
Future<UpdateHttpResponse> get(Uri endpoint) async {
final client = HttpClient()..connectionTimeout = const Duration(seconds: 8);
Future<UpdateHttpResponse> fetch() async {
final request = await client.getUrl(endpoint);
request.followRedirects = false;
request.headers.set('Accept', 'application/json');
request.headers.set('Cache-Control', 'no-cache');
final response = await request.close();
final bytes = <int>[];
await for (final chunk in response) {
if (bytes.length + chunk.length > AppUpdateService.maxBytes) {
throw const UpdateCheckException('更新信息过大,请稍后重试');
}
bytes.addAll(chunk);
}
return UpdateHttpResponse(response.statusCode, bytes);
}
try {
return await fetch().timeout(const Duration(seconds: 12));
} finally {
client.close(force: true);
}
}
}
class AppUpdateService {
AppUpdateService({
this.transport = const IoUpdateTransport(),
this.currentBuild = appBuildNumber,
this.endpoint = const String.fromEnvironment(
'UPDATE_ENDPOINT',
defaultValue: 'https://app.glowtps.cn/v1/updates/android',
),
});
static const maxBytes = 64 * 1024;
final UpdateTransport transport;
final int currentBuild;
final String endpoint;
Future<AppRelease?>? _pending;
Future<AppRelease?> check() {
if (_pending != null) return _pending!;
final future = _check();
_pending = future;
// Attach error handling here too, so cleanup never creates an unhandled
// future when startup intentionally suppresses an unavailable server.
unawaited(
future.then<void>(
(_) {
_pending = null;
},
onError: (Object _, StackTrace _) {
_pending = null;
},
),
);
return future;
}
Future<AppRelease?> _check() async {
try {
final uri = updateDownloadUri(endpoint);
if (uri == null || uri.fragment.isNotEmpty) {
throw const UpdateCheckException('更新服务地址无效');
}
final response = await transport
.get(uri)
.timeout(const Duration(seconds: 13));
if (response.status != 200 || response.body.length > maxBytes) {
throw const UpdateCheckException('暂时无法获取更新,请稍后重试');
}
final data = jsonDecode(utf8.decode(response.body));
if (data is! Map ||
data['schema_version'] != 1 ||
data['application_id'] != 'com.tpschedule.app' ||
!data.containsKey('latest')) {
throw const FormatException();
}
final latest = data['latest'];
if (latest == null) return null;
if (latest is! Map) throw const FormatException();
final version = latest['version'],
build = latest['build_number'],
notes = latest['notes'],
url = latest['download_url'];
if (version is! String ||
!RegExp(r'^\d{1,4}\.\d{1,4}\.\d{1,4}$').hasMatch(version) ||
build is! int ||
build < 1 ||
build > 2100000000 ||
notes is! String ||
notes.trim().isEmpty ||
notes.runes.length > 8000 ||
url is! String) {
throw const FormatException();
}
final download = updateDownloadUri(url);
if (download == null) throw const FormatException();
if (build <= currentBuild) return null;
return AppRelease(
version: version,
buildNumber: build,
notes: notes.trim(),
downloadUri: download,
);
} on UpdateCheckException {
rethrow;
} on FormatException {
throw const UpdateCheckException('更新信息格式异常,请稍后重试');
} catch (_) {
throw const UpdateCheckException('无法连接更新服务,请检查网络后重试');
}
}
}
typedef UpdateDownloadOpener = Future<bool> Function(Uri uri);
Future<bool> openUpdateInBrowser(Uri uri) async {
if (updateDownloadUri(uri.toString()) == null) return false;
try {
return await const MethodChannel('com.tpschedule.app/updates')
.invokeMethod<bool>('openDownload', {'url': uri.toString()}) ??
false;
} on PlatformException {
return false;
} on MissingPluginException {
return false;
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: hepan_everyday
description: TPSchedule 学生课表
publish_to: none
version: 0.7.5+23
version: 0.7.6+24
environment:
sdk: ^3.13.5
dependencies:
@@ -0,0 +1,152 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/services.dart' show MethodCall, MethodChannel;
import 'package:hepan_everyday/services/app_update_service.dart';
class TestUpdateTransport implements UpdateTransport {
int calls = 0;
Uri? endpoint;
Completer<UpdateHttpResponse>? pending;
UpdateHttpResponse response = UpdateHttpResponse(
200,
utf8.encode(
jsonEncode({
'schema_version': 1,
'application_id': 'com.tpschedule.app',
'latest': {
'version': '0.8.0',
'build_number': 25,
'notes': '新增更新检查\n修复导入',
'download_url': 'https://app.glowtps.cn/downloads/TPSchedule.apk',
},
}),
),
);
@override
Future<UpdateHttpResponse> get(Uri endpoint) async {
calls++;
this.endpoint = endpoint;
return pending == null ? response : pending!.future;
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('browser channel receives only validated HTTPS links', () async {
final calls = <MethodCall>[];
const channel = MethodChannel('com.tpschedule.app/updates');
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
return true;
});
addTearDown(
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null),
);
expect(
await openUpdateInBrowser(Uri.parse('javascript:alert(1)')),
isFalse,
);
expect(
await openUpdateInBrowser(
Uri.parse('https://name:password@example.com/app.apk'),
),
isFalse,
);
expect(calls, isEmpty);
expect(
await openUpdateInBrowser(Uri.parse('https://example.com/app.apk')),
isTrue,
);
expect(calls.single.method, 'openDownload');
expect(calls.single.arguments, {'url': 'https://example.com/app.apk'});
});
test(
'build number determines updates and displayed notes and URL survive',
() async {
final transport = TestUpdateTransport();
final client = AppUpdateService(transport: transport, currentBuild: 24);
final release = await client.check();
expect(release!.version, '0.8.0');
expect(release.notes, '新增更新检查\n修复导入');
expect(
release.downloadUri.toString(),
'https://app.glowtps.cn/downloads/TPSchedule.apk',
);
expect(
transport.endpoint.toString(),
'https://app.glowtps.cn/v1/updates/android',
);
expect(
await AppUpdateService(transport: transport, currentBuild: 25).check(),
isNull,
);
expect(
await AppUpdateService(transport: transport, currentBuild: 26).check(),
isNull,
);
},
);
test(
'simultaneous checks share a request and a later check refreshes',
() async {
final transport = TestUpdateTransport()..pending = Completer();
final service = AppUpdateService(transport: transport, currentBuild: 24);
final a = service.check(), b = service.check();
await Future<void>.value();
expect(transport.calls, 1);
transport.pending!.complete(transport.response);
expect((await a)!.buildNumber, 25);
expect((await b)!.buildNumber, 25);
transport.pending = null;
await service.check();
expect(transport.calls, 2);
},
);
test(
'unpublished server and equal versions are an ordinary no-update result',
() async {
final transport = TestUpdateTransport()
..response = UpdateHttpResponse(
200,
utf8.encode(
'{"schema_version":1,"application_id":"com.tpschedule.app","latest":null}',
),
);
expect(await AppUpdateService(transport: transport).check(), isNull);
},
);
test('bad identity, HTML response, unsafe URL and oversized responses fail honestly', () async {
final transport = TestUpdateTransport();
final good = utf8.decode(transport.response.body);
for (final body in [
good.replaceFirst('com.tpschedule.app', 'another.app'),
'<html>WAF challenge</html>',
good.replaceFirst(
'https://app.glowtps.cn/downloads/TPSchedule.apk',
'javascript:alert(1)',
),
good.replaceFirst(
'https://app.glowtps.cn/downloads/TPSchedule.apk',
'https://user:secret@example.com/app.apk',
),
good.replaceFirst('"build_number":25', '"build_number":0'),
good.replaceFirst('"notes":"', '"notes":123,"other":"'),
'x' * (64 * 1024 + 1),
]) {
transport.response = UpdateHttpResponse(200, utf8.encode(body));
await expectLater(
AppUpdateService(transport: transport).check(),
throwsA(isA<UpdateCheckException>()),
);
}
transport.response = UpdateHttpResponse(503, utf8.encode('{}'));
await expectLater(
AppUpdateService(transport: transport).check(),
throwsA(isA<UpdateCheckException>()),
);
});
}
+183
View File
@@ -0,0 +1,183 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:timezone/data/latest.dart' as tzdata;
import 'package:hepan_everyday/app/hepan_app.dart';
import 'package:hepan_everyday/services/app_update_service.dart';
import '../services/app_update_service_test.dart' show TestUpdateTransport;
import 'navigation_test.dart' show uiController;
Future<void> updateTile(WidgetTester tester) async {
await tester.tap(find.bySemanticsLabel('个人设置'));
await tester.pumpAndSettle();
await tester.scrollUntilVisible(
find.byKey(const Key('check-app-update')),
250,
scrollable: find.byType(Scrollable).first,
);
await tester.pumpAndSettle();
}
void main() {
setUpAll(tzdata.initializeTimeZones);
testWidgets(
'startup checks after home and update opens the supplied browser link',
(tester) async {
final controller = uiController();
await controller.initialize();
final transport = TestUpdateTransport();
Uri? opened;
await tester.pumpWidget(
HepanApp(
controller: controller,
updateService: AppUpdateService(
transport: transport,
currentBuild: 24,
),
openUpdateDownload: (uri) async {
opened = uri;
return true;
},
),
);
expect(transport.calls, 0);
expect(find.byType(AlertDialog), findsNothing);
await tester.pump(const Duration(seconds: 2));
await tester.pumpAndSettle();
expect(find.text('发现新版本 0.8.0'), findsOneWidget);
expect(find.text('新增更新检查\n修复导入'), findsOneWidget);
await tester.tap(find.text('立即更新'));
await tester.pumpAndSettle();
expect(
opened.toString(),
'https://app.glowtps.cn/downloads/TPSchedule.apk',
);
expect(transport.calls, 1);
await tester.pumpWidget(const SizedBox());
controller.dispose();
},
);
testWidgets(
'startup failure is quiet while manual checks report failure and latest version',
(tester) async {
final controller = uiController();
await controller.initialize();
final transport = TestUpdateTransport()
..response = const UpdateHttpResponse(503, []);
await tester.pumpWidget(
HepanApp(
controller: controller,
updateService: AppUpdateService(
transport: transport,
currentBuild: 26,
),
),
);
await tester.pump(const Duration(seconds: 2));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsNothing);
expect(find.byType(SnackBar), findsNothing);
await updateTile(tester);
await tester.tap(find.byKey(const Key('check-app-update')));
await tester.pumpAndSettle();
expect(find.textContaining('暂时无法获取更新'), findsOneWidget);
transport.response = TestUpdateTransport().response;
await tester.pump(const Duration(seconds: 5));
await tester.tap(find.byKey(const Key('check-app-update')));
await tester.pumpAndSettle();
expect(find.text('当前已是最新版本'), findsOneWidget);
expect(transport.calls, 3);
await tester.pumpWidget(const SizedBox());
controller.dispose();
},
);
testWidgets(
'later dismisses startup prompt and manual check can show it again',
(tester) async {
final controller = uiController();
await controller.initialize();
var launches = 0;
final transport = TestUpdateTransport();
await tester.pumpWidget(
HepanApp(
controller: controller,
updateService: AppUpdateService(
transport: transport,
currentBuild: 24,
),
openUpdateDownload: (_) async {
launches++;
return false;
},
),
);
await tester.pump(const Duration(seconds: 2));
await tester.pumpAndSettle();
await tester.tap(find.text('稍后'));
await tester.pumpAndSettle();
expect(launches, 0);
await updateTile(tester);
await tester.tap(find.byKey(const Key('check-app-update')));
await tester.pumpAndSettle();
await tester.tap(find.text('立即更新'));
await tester.pumpAndSettle();
expect(find.text('未能打开浏览器'), findsOneWidget);
expect(find.byType(SelectableText), findsOneWidget);
expect(launches, 1);
await tester.pumpWidget(const SizedBox());
controller.dispose();
},
);
testWidgets('disposing before the startup delay cancels checks', (
tester,
) async {
final controller = uiController();
await controller.initialize();
final transport = TestUpdateTransport();
await tester.pumpWidget(
HepanApp(
controller: controller,
updateService: AppUpdateService(transport: transport),
),
);
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(seconds: 3));
expect(transport.calls, 0);
controller.dispose();
});
testWidgets('long release notes remain scrollable on a small phone', (
tester,
) async {
tester.view.physicalSize = const Size(360, 640);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final controller = uiController();
await controller.initialize();
final transport = TestUpdateTransport();
final body = jsonDecode(
utf8.decode(transport.response.body),
) as Map<String, dynamic>;
(body['latest'] as Map)['notes'] = List.filled(
300,
'更新功能说明,修复课程显示问题。',
).join('\n');
transport.response = UpdateHttpResponse(200, utf8.encode(jsonEncode(body)));
await tester.pumpWidget(
HepanApp(
controller: controller,
updateService: AppUpdateService(transport: transport, currentBuild: 24),
),
);
await tester.pump(const Duration(seconds: 2));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('立即更新'), findsOneWidget);
await tester.tap(find.text('稍后'));
await tester.pumpAndSettle();
await tester.pumpWidget(const SizedBox());
controller.dispose();
});
}