feat: add app update checks and standalone release publishing panel
This commit is contained in:
+3
-1
@@ -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) }
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
const appVersion = '0.7.5';
|
||||
const appBuildNumber = 23;
|
||||
const appVersion = '0.7.6';
|
||||
const appBuildNumber = 24;
|
||||
|
||||
@@ -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('知道了'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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: () {
|
||||
|
||||
@@ -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
@@ -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>()),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user