diff --git a/README.md b/README.md index bbbe68c..9e88aa4 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # TPSchedule -当前Android版本0.7.8+27:Dock增加乳白透光层、弧形反光和边缘折射,见[材质优化记录](docs/tpschedule-0.7.8-dock-refinement.md)。本科和研究生课表/考试均可选附带HTML反馈,保留原签名,个人页展示蜀ICP备2025169276号-5A。详见[0.7.8功能记录](docs/tpschedule-0.7.8-validation.md)及[反馈服务更新说明](server/feedback/html-diagnostic-update.md)。0.7.6起支持启动/手动检查更新与浏览器下载,附带[独立发布后台](server/updates/README.md)。 +当前Android版本0.7.8+28:提前建立通知类别、区分通知总开关和类别状态,见[通知排查记录](docs/tpschedule-0.7.8-notification-permissions.md)。用户手机已通过开启通知权限并重测确认恢复后台通知。Dock乳白透光层和边缘折射继续保留,见[材质优化记录](docs/tpschedule-0.7.8-dock-refinement.md)。本科/研究生HTML反馈见[0.7.8功能记录](docs/tpschedule-0.7.8-validation.md)及[反馈服务说明](server/feedback/html-diagnostic-update.md),更新管理后台见[部署说明](server/updates/README.md)。 0.7.0补充:考试页查询类型与卡片保持16像素间距;反馈问题和建议可自选附带最近10分钟的登录/读取/提醒诊断日志。默认不发送,不含密码、Cookie或课表内容,单独作为邮件文本附件。服务端需按[日志附件更新说明](server/feedback/diagnostic-log-update.md)手动更新;真实学校登录及SMTP未由本次离线测试代替验证。玻璃取样偏移仍在调查,未纳入本次修复结论。 -学生课表 APP,Flutter 实现,支持电子科技大学研究生和本科生系统。Android 0.7.8+27,Android 7.0 及以上。 +学生课表 APP,Flutter 实现,支持电子科技大学研究生和本科生系统。Android 0.7.8+28,Android 7.0 及以上。 使用用户提供的原始 SVG 图标,蓝色渐变、黄色日历框及白色笔画保持一致;启动画面使用相同配色。0.5.1修复重复启动页闪烁:初始化期间保留原生启动屏,数据就绪后直接显示课表首帧,不再叠加Flutter品牌页或淡出动画。Android12+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。 diff --git a/docs/android-mvp-validation.md b/docs/android-mvp-validation.md index 74e82d3..7feb574 100644 --- a/docs/android-mvp-validation.md +++ b/docs/android-mvp-validation.md @@ -1,6 +1,6 @@ # Android 首版验证记录 -当前版本为0.7.8+27,Dock乳白透光材质见[优化记录](tpschedule-0.7.8-dock-refinement.md)。本科/研究生HTML反馈及签名、备案信息见[0.7.8功能记录](tpschedule-0.7.8-validation.md)。应用更新检查与后台说明见[0.7.6记录](tpschedule-0.7.6-updates.md)。本科考试兼容的验证见[0.7.5记录](tpschedule-0.7.5-validation.md)。两例真实课表缺失字段问题待新页面定位。本科接入记录见[0.7.0记录](tpschedule-0.7.0-validation.md)。下文为首版历史记录。 +当前版本为0.7.8+28,通知权限恢复、通道初始化及真实后台触发见[通知记录](tpschedule-0.7.8-notification-permissions.md)。Dock乳白透光材质见[优化记录](tpschedule-0.7.8-dock-refinement.md)。本科/研究生HTML反馈及签名、备案信息见[0.7.8功能记录](tpschedule-0.7.8-validation.md)。应用更新检查与后台说明见[0.7.6记录](tpschedule-0.7.6-updates.md)。本科考试兼容的验证见[0.7.5记录](tpschedule-0.7.5-validation.md)。两例真实课表缺失字段问题待新页面定位。本科接入记录见[0.7.0记录](tpschedule-0.7.0-validation.md)。下文为首版历史记录。 日期:2026-10-13。测试环境:Flutter 3.47.7 / Dart 3.13.5 / JDK 17;Android 16 API 36,Pixel 7 模拟器,1080×2400。首版版本 0.1.0+1,包 ID com.example.hepan_everyday,最低 Android 7.0(API 24)。 diff --git a/docs/tpschedule-0.7.8-notification-permissions.md b/docs/tpschedule-0.7.8-notification-permissions.md new file mode 100644 index 0000000..bcd8c8c --- /dev/null +++ b/docs/tpschedule-0.7.8-notification-permissions.md @@ -0,0 +1,32 @@ +# TPSchedule 0.7.8+28:通知权限排查与通道初始化 + +日期2026-10-27。用户反馈vivo X200 Pro(V2405A)、Android16上后台没有课前通知,先前已允许精确闹钟及后台运行。本次先收集设备状态并用正式APK验证,未凭手机品牌归因。 + +## 已确认的直接原因及恢复 + +用户0.7.8+27首份诊断返回notifications=false、permissionDenied、scheduledCount=0和pendingCount=0;exactAlarm=true,backgroundRestricted=false,batteryOptimizationsIgnored=true、powerSaveMode=false、勿扰过滤正常。此时程序因为通知未获允许而没有安排课程提醒,不能归因精确闹钟或省电限制。 + +用户进入APP“通知设置”检查后,第二份诊断为notifications=true、exact、scheduledCount=23及pendingCount=23。随后重测“10秒后台测试”,用户明确确认“这次收到了”。因此现有构建27已经在用户手机恢复后台通知,本次没有证据认定Android16/厂商固件或Gson/R8本身存在故障。 + +同签名正式构建27也在独立Android16/API36测试设备完成原生验证:dumpsys alarm显示RTC_WAKEUP精确任务、约10秒后的请求时间、ScheduledNotificationReceiver;返回桌面12秒后dumpsys notification包含id=-1002、tag=hepan-test的通知记录。立即通知id=-1001也存在,未见接收器异常。测试设备使用软件渲染启动参数,不涉及真实学校帐号。 + +## 本次小修补 + +- 通知初始化成功后就创建hepan_lessons“课前提醒”类别,再查询权限/登记任务;此前仅在首次实际发出通知时才创建,诊断和设置中可能看不到类别。 +- 使用原类别ID、原重要级别和配置,Android保留用户对已有类别的选择,不自动开启用户关闭的通知。 +- 手动诊断增加appNotificationsEnabled、channelExists和courseChannelEnabled,分别显示通知总开关与提醒类别。导出的诊断使用相同显式字段白名单,不含课程、帐号或认证信息。 +- 权限不足的提示明确说明当前没有安排课前提醒、应检查总开关与类别后重新检查。结构化日志记录允许/应用通知关闭/类别关闭三类结果。 +- 原pendingNotificationRequests读取的是插件本机缓存,单凭pendingCount不能证明系统到点派发。本次以真实系统闹钟/通知记录和用户复测作为恢复证据;登记校验提示不再称为系统保存证明。 +- 调度模式、课程时间、提醒多选、原receiver和权限清单不变,未引入系统闹钟兼容模式、常驻服务或新增省电豁免请求。 + +## 验证与交付 + +新增“首次检查之前已有类别”和“总开关/类别分别显示”回归,修改前均观察到失败;修改后通过,原拒绝类别、初始化重试及后台测试隔离等测试继续执行。独立检查发现Android7没有类别却显示缺失提示的问题,追加API24回归先失败后通过,旧系统不再显示该提示。 + +最终306项Flutter测试通过、静态分析无问题,正式APK构建成功。独立Android16/API36测试设备上安装构建28、清空该专用测试APP的数据并重新授予测试权限,首次进入APP即查到hepan_lessons类别、importance4、默认声音及振动,未先发送通知。随后精确登记后台测试并收到通知。 + +另一次正式包测试登记后回桌面,以测试设备root权限向已核对包名的APP进程发SIGKILL(不执行force-stop):原进程4158退出,10秒后系统创建接收器进程4461,通知记录出现id=-1002/tag=hepan-test,未见AndroidRuntime致命错误。证明该环境不依赖Flutter进程常驻即可投递;不是对所有厂商限制的无条件保证。测试设备使用软件渲染,通知触发仍为真实AlarmManager/receiver/native notify。 + +apksigner验证构建28签名有效,证书与构建27逐字节一致;MD5为78DE9119AD14727FA1C5E5E4E68145EE,RSA公钥与用户提供值一致。密钥、旧APK及0.5.4登记冻结材料保持。 + +版本名0.7.8、构建号28,com.tpschedule.app;原证书MD5、公钥、私钥和备案号保持。旧构建27保留。未改服务器或SMTP,未访问或修改用户手机配置,手机系统设置由用户自行操作完成。 diff --git a/mobile/lib/app/app_version.dart b/mobile/lib/app/app_version.dart index ae0aab7..393162f 100644 --- a/mobile/lib/app/app_version.dart +++ b/mobile/lib/app/app_version.dart @@ -1,3 +1,3 @@ const appVersion = '0.7.8'; -const appBuildNumber = 27; +const appBuildNumber = 28; const appFilingNumber = '蜀ICP备2025169276号-5A'; diff --git a/mobile/lib/features/profile/reminder_tools.dart b/mobile/lib/features/profile/reminder_tools.dart index 2e95cbc..9bd5eca 100644 --- a/mobile/lib/features/profile/reminder_tools.dart +++ b/mobile/lib/features/profile/reminder_tools.dart @@ -69,6 +69,9 @@ class _ReminderToolsState extends State { 'reminderStatus': c.reminderResult.status.name, for (final key in [ 'notifications', + 'appNotificationsEnabled', + 'channelExists', + 'courseChannelEnabled', 'exactAlarm', 'pendingCount', 'channelImportance', @@ -125,8 +128,16 @@ class _ReminderToolsState extends State { final rawSystem = diagnosis['system']; final system = rawSystem is Map ? rawSystem : const {}; final pending = diagnosis['pendingCount']; + final sdk = system['sdk']; + final channelsSupported = sdk is! int || sdk >= 26; return [ '通知权限:${_permission(diagnosis['notifications'])}', + if (diagnosis['appNotificationsEnabled'] is bool) + '系统通知总开关:${_permission(diagnosis['appNotificationsEnabled'])}', + if (channelsSupported && diagnosis['channelExists'] == true) + '课前提醒类别:${_permission(diagnosis['courseChannelEnabled'])}', + if (channelsSupported && diagnosis['channelExists'] == false) + '课前提醒类别尚未建立,请重新打开应用后检查。', '精确提醒:${_permission(diagnosis['exactAlarm'])}', if (pending is int) '待发送提醒:$pending 条', if (diagnosis['notifications'] == false) '系统通知已关闭,请在通知设置中开启。', diff --git a/mobile/lib/services/local_notification_service.dart b/mobile/lib/services/local_notification_service.dart index c2f1385..3c8857e 100644 --- a/mobile/lib/services/local_notification_service.dart +++ b/mobile/lib/services/local_notification_service.dart @@ -8,6 +8,7 @@ import 'package:flutter/services.dart'; import '../domain/course.dart'; import '../domain/app_settings.dart'; import '../domain/course_occurrence.dart'; +import 'diagnostic_log.dart'; import 'notification_service.dart'; class ReminderPermissions { @@ -72,7 +73,8 @@ class LocalNotificationService extends NotificationService { await cancelOwned(); return const ReminderResult( ReminderStatus.permissionDenied, - message: '请允许通知,并检查系统中的“课前提醒”通知类别。', + message: + '通知未获允许,当前没有安排课前提醒。请在“通知设置”开启TPSchedule的通知总开关和“课前提醒”类别,再点“检查提醒”。', ); } final upcoming = @@ -144,7 +146,7 @@ class LocalNotificationService extends NotificationService { if (!saved.containsAll(desired.keys)) { throw const ReminderSetupException( 'schedule_not_saved', - '系统未保存全部提醒,请点击“检查提醒”后重试。', + '部分提醒尚未完成登记,请点击“检查提醒”后重试。', ); } final message = [ @@ -191,6 +193,12 @@ class PluginReminderGateway extends ReminderGateway { final void Function(String key)? onTap; final _plugin = FlutterLocalNotificationsPlugin(); static const _system = MethodChannel('com.tpschedule.app/reminders'); + static const _courseChannel = AndroidNotificationChannel( + 'hepan_lessons', + '课前提醒', + description: '课程开始前的提醒', + importance: Importance.high, + ); String? _activeQueryWarning; Future? _initializing; Future initialize() => _initializing ??= Future(() async { @@ -205,6 +213,9 @@ class PluginReminderGateway extends ReminderGateway { if (ready != true) { throw StateError('通知服务初始化失败'); } + // Make the category visible before checking access or scheduling. + // Reusing its ID preserves the user's existing Android channel choices. + await _android?.createNotificationChannel(_courseChannel); final launch = await _plugin.getNotificationAppLaunchDetails(); if (launch?.didNotificationLaunchApp == true) { _select(launch?.notificationResponse?.payload); @@ -239,9 +250,20 @@ class PluginReminderGateway extends ReminderGateway { channel.id == 'hepan_lessons' && channel.importance == Importance.none, ); + final appEnabled = await _android?.areNotificationsEnabled() ?? false; + DiagnosticLog.instance.add( + 'reminder_access_checked', + fields: { + 'result': appEnabled && courseChannelEnabled, + 'code': !appEnabled + ? 'app_notifications_disabled' + : !courseChannelEnabled + ? 'course_channel_disabled' + : 'allowed', + }, + ); return ReminderPermissions( - (await _android?.areNotificationsEnabled() ?? false) && - courseChannelEnabled, + appEnabled && courseChannelEnabled, await _android?.canScheduleExactNotifications() ?? false, ); } @@ -305,7 +327,7 @@ class PluginReminderGateway extends ReminderGateway { ); final saved = await _plugin.pendingNotificationRequests(); if (!saved.any((item) => item.id == -1002)) { - throw const ReminderSetupException('test_not_saved', '系统未保存测试提醒。'); + throw const ReminderSetupException('test_not_saved', '测试提醒登记未完成。'); } return '已安排测试,请按手机 Home 键回到桌面,10 秒后查看通知栏。'; } @@ -327,6 +349,7 @@ class PluginReminderGateway extends ReminderGateway { final channel = channels ?.where((item) => item.id == 'hepan_lessons') .firstOrNull; + final appEnabled = await _android?.areNotificationsEnabled(); Map? system; String? systemError; try { @@ -338,6 +361,11 @@ class PluginReminderGateway extends ReminderGateway { } return { 'notifications': access.notifications, + 'appNotificationsEnabled': appEnabled, + 'channelExists': channels == null ? null : channel != null, + 'courseChannelEnabled': channel == null + ? null + : channel.importance != Importance.none, 'exactAlarm': access.exactAlarm, 'pendingCount': pending.length, 'channelImportance': channel?.importance.value, diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 97466a5..87c9702 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -1,7 +1,7 @@ name: hepan_everyday description: TPSchedule 学生课表 publish_to: none -version: 0.7.8+27 +version: 0.7.8+28 environment: sdk: ^3.13.5 dependencies: diff --git a/mobile/test/services/notification_gateway_test.dart b/mobile/test/services/notification_gateway_test.dart index 8912098..68edab0 100644 --- a/mobile/test/services/notification_gateway_test.dart +++ b/mobile/test/services/notification_gateway_test.dart @@ -7,6 +7,54 @@ import 'package:hepan_everyday/services/local_notification_service.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); + test('fresh notification initialization creates the course category before checks', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + AndroidFlutterLocalNotificationsPlugin.registerWith(); + const channel = MethodChannel('dexterous.com/flutter/local_notifications'); + const system = MethodChannel('com.tpschedule.app/reminders'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + final categories = >[]; + messenger.setMockMethodCallHandler(channel, (call) async { + switch (call.method) { + case 'initialize': + return true; + case 'createNotificationChannel': + final data = call.arguments as Map; + categories.add({ + ...Map.from(data), + // Android returns a numeric color even when no LED was set. + 'ledColor': data['ledColor'] ?? 0, + }); + return null; + case 'getNotificationAppLaunchDetails': + return {'notificationLaunchedApp': false}; + case 'areNotificationsEnabled': + case 'canScheduleExactNotifications': + return true; + case 'getNotificationChannels': + return categories; + case 'pendingNotificationRequests': + return []; + } + return null; + }); + messenger.setMockMethodCallHandler( + system, + (_) async => {}, + ); + addTearDown(() { + debugDefaultTargetPlatformOverride = null; + messenger.setMockMethodCallHandler(channel, null); + messenger.setMockMethodCallHandler(system, null); + }); + final gateway = PluginReminderGateway(); + final diagnosis = await gateway.diagnose(); + expect(diagnosis['channelImportance'], Importance.high.value); + expect(diagnosis['appNotificationsEnabled'], isTrue); + expect(diagnosis['channelExists'], isTrue); + expect(categories.single['id'], 'hepan_lessons'); + }); test( 'querying future reminders does not depend on active notification access', () async { diff --git a/mobile/test/widgets/reminder_tools_test.dart b/mobile/test/widgets/reminder_tools_test.dart index 681272f..bad7fd3 100644 --- a/mobile/test/widgets/reminder_tools_test.dart +++ b/mobile/test/widgets/reminder_tools_test.dart @@ -70,6 +70,42 @@ Future _open(WidgetTester tester, _Notifications notifications) async { } void main() { + testWidgets( + 'Android 7 does not ask the user to create an unsupported category', + (tester) async { + final notifications = _Notifications() + ..diagnosis = Completer>(); + notifications.diagnosis!.complete({ + 'notifications': true, + 'appNotificationsEnabled': true, + 'channelExists': false, + 'system': {'sdk': 24}, + }); + await _open(tester, notifications); + await tester.tap(find.text('检查提醒')); + await tester.pumpAndSettle(); + expect(find.textContaining('类别尚未建立'), findsNothing); + expect(find.text('系统通知总开关:已允许'), findsOneWidget); + }, + ); + testWidgets( + 'diagnosis separates the application switch from the reminder category', + (tester) async { + final notifications = _Notifications() + ..diagnosis = Completer>(); + notifications.diagnosis!.complete({ + 'notifications': false, + 'appNotificationsEnabled': false, + 'channelExists': true, + 'courseChannelEnabled': true, + }); + await _open(tester, notifications); + await tester.tap(find.text('检查提醒')); + await tester.pumpAndSettle(); + expect(find.text('系统通知总开关:未允许'), findsOneWidget); + expect(find.text('课前提醒类别:已允许'), findsOneWidget); + }, + ); testWidgets('test controls distinguish immediate and background reminders', ( tester, ) async {