Fix cached reminder recovery and retain pre-reconcile idle diagnostics
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
# TPSchedule
|
# TPSchedule
|
||||||
|
|
||||||
当前Android版本0.7.8+32:点按先放大再弹跳、底座方向跟随、边缘外溢及柔软光泽,见[本次记录](docs/tpschedule-0.7.8-dock-bloom.md)。构建31提供滑块移动中持续Q弹、前缘牵拉和弹性跟随,玻璃材质减薄,见[本次记录](docs/tpschedule-0.7.8-dock-drag.md)。构建30提供增强点按挤压回弹、整条Dock随拖动方向弹动、图标到位动画及乳白折射光泽,见[本次记录](docs/tpschedule-0.7.8-dock-spring.md)。构建29提供Dock点按/拖动弹性与Android平板横竖屏、分屏适配,见[本次验证记录](docs/tpschedule-0.7.8-dock-tablet.md)。构建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)。
|
当前Android版本0.7.8+33:修复缓存仍在但系统触发信息丢失时跳过提醒重建的问题,保留启动/暖启动恢复前的诊断;Android 16深度Doze及rare分组测试已实际投递,vivo几小时长待机根因仍待新诊断确认。见[本次记录](docs/tpschedule-0.7.8-reminder-idle.md)。构建32:点按先放大再弹跳、底座方向跟随、边缘外溢及柔软光泽,见[本次记录](docs/tpschedule-0.7.8-dock-bloom.md)。构建31提供滑块移动中持续Q弹、前缘牵拉和弹性跟随,玻璃材质减薄,见[本次记录](docs/tpschedule-0.7.8-dock-drag.md)。构建30提供增强点按挤压回弹、整条Dock随拖动方向弹动、图标到位动画及乳白折射光泽,见[本次记录](docs/tpschedule-0.7.8-dock-spring.md)。构建29提供Dock点按/拖动弹性与Android平板横竖屏、分屏适配,见[本次验证记录](docs/tpschedule-0.7.8-dock-tablet.md)。构建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未由本次离线测试代替验证。玻璃取样偏移仍在调查,未纳入本次修复结论。
|
0.7.0补充:考试页查询类型与卡片保持16像素间距;反馈问题和建议可自选附带最近10分钟的登录/读取/提醒诊断日志。默认不发送,不含密码、Cookie或课表内容,单独作为邮件文本附件。服务端需按[日志附件更新说明](server/feedback/diagnostic-log-update.md)手动更新;真实学校登录及SMTP未由本次离线测试代替验证。玻璃取样偏移仍在调查,未纳入本次修复结论。
|
||||||
|
|
||||||
学生课表 APP,Flutter 实现,支持电子科技大学研究生和本科生系统。Android 0.7.8+32,Android 7.0 及以上。
|
学生课表 APP,Flutter 实现,支持电子科技大学研究生和本科生系统。Android 0.7.8+33,Android 7.0 及以上。
|
||||||
|
|
||||||
使用用户提供的原始 SVG 图标,蓝色渐变、黄色日历框及白色笔画保持一致;启动画面使用相同配色。0.5.1修复重复启动页闪烁:初始化期间保留原生启动屏,数据就绪后直接显示课表首帧,不再叠加Flutter品牌页或淡出动画。Android12+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。
|
使用用户提供的原始 SVG 图标,蓝色渐变、黄色日历框及白色笔画保持一致;启动画面使用相同配色。0.5.1修复重复启动页闪烁:初始化期间保留原生启动屏,数据就绪后直接显示课表首帧,不再叠加Flutter品牌页或淡出动画。Android12+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Android 首版验证记录
|
# Android 首版验证记录
|
||||||
|
|
||||||
当前版本为0.7.8+32,点按放大、外溢及柔软材质见[本次记录](tpschedule-0.7.8-dock-bloom.md)。构建31的移动中Q弹、牵拉及减薄见[本次记录](tpschedule-0.7.8-dock-drag.md)。构建30的点按/方向弹动及乳白材质见[本次记录](tpschedule-0.7.8-dock-spring.md)。构建29的Dock弹性与Android平板验证见[本次记录](tpschedule-0.7.8-dock-tablet.md)。构建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)。下文为首版历史记录。
|
当前版本为0.7.8+33,提醒恢复与待机诊断见[本次记录](tpschedule-0.7.8-reminder-idle.md)。构建32的点按放大、外溢及柔软材质见[本次记录](tpschedule-0.7.8-dock-bloom.md)。构建31的移动中Q弹、牵拉及减薄见[本次记录](tpschedule-0.7.8-dock-drag.md)。构建30的点按/方向弹动及乳白材质见[本次记录](tpschedule-0.7.8-dock-spring.md)。构建29的Dock弹性与Android平板验证见[本次记录](tpschedule-0.7.8-dock-tablet.md)。构建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)。
|
日期: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)。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# 0.7.8+33:长时间后台提醒排查与恢复
|
||||||
|
|
||||||
|
日期:2026-10-28。问题设备为 vivo X200 Pro / Android 16。用户确认通知、精确提醒、自启动、关联启动和后台高耗电均已允许;没有重启或划掉应用,只是几小时或半天未打开后漏提醒。此前通知总开关关闭的问题已恢复,本次不能沿用那个结论。
|
||||||
|
|
||||||
|
## 已复现并修复的程序问题
|
||||||
|
|
||||||
|
提醒协调原先只比较插件中的缓存 ID 和内存中的课程指纹。如果系统 PendingIntent 已丢失而插件缓存仍在,同一个应用进程会跳过重新登记。回归测试先复现“应重新登记但没有登记”,修复后通过:相同课程、时间、提醒内容下,只要系统触发信息缺失,也会重新安排。
|
||||||
|
|
||||||
|
新增只读原生检查,使用和 flutter_local_notifications 22.3.2 一致的显式 Receiver、ID 和 immutable 标记,通过 FLAG_NO_CREATE 查询,不创建替代触发任务。检查失败不阻断原有安排流程。PendingIntent 存在本身不证明 AlarmManager 中仍有有效任务,更不能证明已投递;不存在则可作为恢复依据。
|
||||||
|
|
||||||
|
## 诊断保留
|
||||||
|
|
||||||
|
- Activity 创建时,在 Dart 自动恢复前记录插件课程缓存数量、现存/缺失触发信息数量,以及超过预计提醒时间两分钟的缓存数量。
|
||||||
|
- 暖启动/返回前台时,先检查全部缓存,再删除已过期提醒;有异常的摘要保留在当前 Activity 中,供随后“检查提醒”复制。避免恢复动作覆盖现场。摘要不跨 Activity 重建持久保存。
|
||||||
|
- “检查提醒”先记录检查前的状态,再协调和读取检查后状态。
|
||||||
|
- 增加系统待机分组、Doze 状态、最近一次进程退出原因/时间、启动前停止状态和仍显示的最近课程通知时间。API 不支持或系统拒绝读取时保留未知值。
|
||||||
|
- 复制出的报告不包含课程名称、教师、教室、提醒 ID、帐号、密码、Cookie 或原始错误正文。诊断只在用户主动复制后对外提供。
|
||||||
|
|
||||||
|
进程退出原因不能单独证明强行停止;当前待机分组也可能已因打开应用恢复为活跃。已消失的通知无法用“仍显示的最近通知时间”判断是否曾投递。过期缓存也可能来自时间设置变更等情况,不能直接认定厂商丢弃任务。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- Flutter 全量 326 项测试通过;静态分析无问题;正式 APK 构建通过。
|
||||||
|
- Android 原生 JUnit 两项通过:只读计数、去重、排除测试 ID 和空列表;不输出课程 ID。
|
||||||
|
- 新增回归覆盖:缓存和指纹相同但触发丢失时重建;暖启动过期记录在取消前检查;原生返回 ID 限定于请求范围;可选检查失败不阻断;诊断字段白名单与检查顺序。
|
||||||
|
- 正式 33 包安装至 Android 16 API 36 专用模拟器。仅向测试设备加入两条合成课程缓存,未登记对应系统任务,其中一条已过期。启动自动清理后,“检查提醒”仍显示缺失 2 条、过期 1 条,待发送 0 条,证明恢复前信息得以保留。
|
||||||
|
- 正式 33 包通过应用内“10 秒后台测试”,Home 后强制进入深度 Doze,并设置 rare 待机分组(40)。系统记录确认存在精确 RTC_WAKEUP,随后实际收到 id=-1002、tag=hepan-test 的通知。测试后恢复模拟器待机与电池状态。
|
||||||
|
- 前序正式 32 包还验证过普通进程被杀后由原生 Receiver 唤起投递,以及设备真正重启后未打开应用即恢复并投递。它们是基础原生路径的证据,不能替代 vivo 真机长待机复测。
|
||||||
|
- 独立审查未发现需要修复的缺陷;审查指出的暖启动证据缺失已补上并以回归验证。
|
||||||
|
|
||||||
|
本地验证日志位于 .tools/033-*.log、033-audit-result.xml、033-idle-*.txt;测试使用合成数据,不进入公开源码包。
|
||||||
|
|
||||||
|
## 使用与边界
|
||||||
|
|
||||||
|
同签名覆盖安装 0.7.8+33,打开一次以安排当前课程提醒。若之后几小时未打开又漏提醒,请进入“我的 → 课表设置 → 检查提醒 → 复制提醒诊断”,将报告和漏提醒的大致时间提供给开发者。可以正常打开应用,新诊断会尽量保留自动恢复前的证据。
|
||||||
|
|
||||||
|
本轮修复的是已复现的恢复判断错误,并增加取证能力。尚未在 vivo 真机复现几小时后的失效,不能声称已确定或彻底解决该手机上的根因。未添加常驻前台服务、持续后台计时器或厂商私有接口。
|
||||||
|
|
||||||
|
## 平台依据
|
||||||
|
|
||||||
|
[Android 定时提醒](https://developer.android.com/develop/background-work/services/alarms)说明系统闹钟可独立于进程生命周期运行;[Android 15 停止状态行为](https://developer.android.com/about/versions/15/behavior-changes-all)说明强行停止与 PendingIntent 取消的关系。[待机分组](https://developer.android.com/topic/performance/appstandby)及[电源资源限制](https://developer.android.com/topic/performance/power/power-details)用于解释诊断字段;不据此推断本机已经触发某项限制。
|
||||||
|
|
||||||
|
包名 com.tpschedule.app、原签名密钥/证书、公钥和备案号蜀ICP备2025169276号-5A保持。0.5.4 软著登记冻结材料和旧版本产物保持。用户的服务器无需为本次提醒修复更新代码;更新后台发布时使用版本 0.7.8、构建号 33。
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.tpschedule.app;
|
||||||
|
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.function.IntPredicate;
|
||||||
|
|
||||||
|
/** Read-only counts. A surviving PendingIntent alone does not prove alarm delivery. */
|
||||||
|
public final class ReminderHandleAudit {
|
||||||
|
private ReminderHandleAudit() {}
|
||||||
|
|
||||||
|
public static Map<String, Object> inspect(List<Integer> ids, IntPredicate exists) {
|
||||||
|
LinkedHashSet<Integer> courses = new LinkedHashSet<>();
|
||||||
|
for (Integer id : ids) if (id != null && id >= 0) courses.add(id);
|
||||||
|
int present = 0;
|
||||||
|
for (int id : courses) if (exists.test(id)) present++;
|
||||||
|
Map<String, Object> counts = new LinkedHashMap<>();
|
||||||
|
counts.put("cachedCourseCount", courses.size());
|
||||||
|
counts.put("existingHandleCount", present);
|
||||||
|
counts.put("missingHandleCount", courses.size() - present);
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ package com.tpschedule.app
|
|||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.app.ActivityManager
|
import android.app.ActivityManager
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.usage.UsageStatsManager
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
@@ -10,14 +12,77 @@ import android.os.PowerManager
|
|||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import io.flutter.plugin.common.BinaryMessenger
|
import io.flutter.plugin.common.BinaryMessenger
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver
|
||||||
|
import org.json.JSONArray
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
class ReminderSystemBridge(private val activity: Activity, messenger: BinaryMessenger) {
|
class ReminderSystemBridge(private val activity: Activity, messenger: BinaryMessenger) {
|
||||||
private val channel = MethodChannel(messenger, "com.tpschedule.app/reminders")
|
private val channel = MethodChannel(messenger, "com.tpschedule.app/reminders")
|
||||||
|
// Captured before Dart's startup/resume reconciliation can repair the cache.
|
||||||
|
private val startupAudit = captureAlarmAudit()
|
||||||
|
private var lastProblemAudit: Map<String, Any?>? = startupAudit.takeIf {
|
||||||
|
((it["missingHandleCount"] as? Number)?.toInt() ?: 0) > 0 ||
|
||||||
|
((it["overdueCachedCount"] as? Number)?.toInt() ?: 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasHandle(id: Int): Boolean = PendingIntent.getBroadcast(activity, id,
|
||||||
|
Intent(activity, ScheduledNotificationReceiver::class.java),
|
||||||
|
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE) != null
|
||||||
|
|
||||||
|
private fun captureAlarmAudit(): Map<String, Any?> {
|
||||||
|
val at = System.currentTimeMillis()
|
||||||
|
return try {
|
||||||
|
val raw = activity.getSharedPreferences("scheduled_notifications", Activity.MODE_PRIVATE)
|
||||||
|
.getString("scheduled_notifications", "[]") ?: "[]"
|
||||||
|
if (raw.length > 2 * 1024 * 1024) return mapOf(
|
||||||
|
"capturedAtEpochMillis" to at, "errorCode" to "cache_too_large")
|
||||||
|
val rows = JSONArray(raw)
|
||||||
|
val ids = mutableListOf<Int>()
|
||||||
|
val overdue = mutableSetOf<Int>()
|
||||||
|
for (i in 0 until minOf(rows.length(), 500)) {
|
||||||
|
val row = rows.optJSONObject(i) ?: continue
|
||||||
|
if (!row.optString("payload").startsWith("hepan:")) continue
|
||||||
|
val id = row.optInt("id", -1)
|
||||||
|
if (id < 0) continue
|
||||||
|
ids.add(id)
|
||||||
|
val fireAt = runCatching {
|
||||||
|
LocalDateTime.parse(row.optString("scheduledDateTime"))
|
||||||
|
.atZone(ZoneId.of(row.optString("timeZoneName")))
|
||||||
|
.toInstant().toEpochMilli()
|
||||||
|
}.getOrNull()
|
||||||
|
if (fireAt != null && fireAt < at - 120_000) overdue.add(id)
|
||||||
|
}
|
||||||
|
val counts = ReminderHandleAudit.inspect(ids) { id ->
|
||||||
|
hasHandle(id)
|
||||||
|
}
|
||||||
|
counts + mapOf("capturedAtEpochMillis" to at,
|
||||||
|
"overdueCachedCount" to overdue.size, "partial" to (rows.length() > 500))
|
||||||
|
} catch (_: Exception) {
|
||||||
|
mapOf("capturedAtEpochMillis" to at, "errorCode" to "snapshot_unavailable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
channel.setMethodCallHandler { call, result ->
|
channel.setMethodCallHandler { call, result ->
|
||||||
try {
|
try {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
|
"findMissingAlarmHandles" -> {
|
||||||
|
// Preserve overdue evidence on warm resume, before Dart removes it.
|
||||||
|
val audit = captureAlarmAudit()
|
||||||
|
val auditHasProblem = ((audit["missingHandleCount"] as? Number)?.toInt() ?: 0) > 0 ||
|
||||||
|
((audit["overdueCachedCount"] as? Number)?.toInt() ?: 0) > 0
|
||||||
|
if (auditHasProblem) lastProblemAudit = audit
|
||||||
|
val ids = (call.arguments as? List<*>)?.filterIsInstance<Int>()
|
||||||
|
?.filter { it >= 0 }?.distinct()?.take(400) ?: emptyList()
|
||||||
|
val missing = ids.filterNot { hasHandle(it) }
|
||||||
|
if (missing.isNotEmpty() && !auditHasProblem) lastProblemAudit = mapOf(
|
||||||
|
"capturedAtEpochMillis" to System.currentTimeMillis(),
|
||||||
|
"cachedCourseCount" to ids.size,
|
||||||
|
"existingHandleCount" to (ids.size - missing.size),
|
||||||
|
"missingHandleCount" to missing.size)
|
||||||
|
result.success(missing)
|
||||||
|
}
|
||||||
"getStatus" -> result.success(status())
|
"getStatus" -> result.success(status())
|
||||||
"openNotificationSettings" -> {
|
"openNotificationSettings" -> {
|
||||||
openNotificationSettings()
|
openNotificationSettings()
|
||||||
@@ -39,6 +104,12 @@ class ReminderSystemBridge(private val activity: Activity, messenger: BinaryMess
|
|||||||
val activityManager = activity.getSystemService(Activity.ACTIVITY_SERVICE) as ActivityManager
|
val activityManager = activity.getSystemService(Activity.ACTIVITY_SERVICE) as ActivityManager
|
||||||
val powerManager = activity.getSystemService(Activity.POWER_SERVICE) as PowerManager
|
val powerManager = activity.getSystemService(Activity.POWER_SERVICE) as PowerManager
|
||||||
val notificationManager = activity.getSystemService(Activity.NOTIFICATION_SERVICE) as NotificationManager
|
val notificationManager = activity.getSystemService(Activity.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
val lastExit = if (Build.VERSION.SDK_INT >= 30) runCatching {
|
||||||
|
activityManager.getHistoricalProcessExitReasons(activity.packageName, 0, 1).firstOrNull()
|
||||||
|
}.getOrNull() else null
|
||||||
|
val wasStopped = if (Build.VERSION.SDK_INT >= 35) runCatching {
|
||||||
|
activityManager.getHistoricalProcessStartReasons(1).firstOrNull()?.wasForceStopped()
|
||||||
|
}.getOrNull() else null
|
||||||
return mapOf(
|
return mapOf(
|
||||||
"manufacturer" to Build.MANUFACTURER,
|
"manufacturer" to Build.MANUFACTURER,
|
||||||
"model" to Build.MODEL,
|
"model" to Build.MODEL,
|
||||||
@@ -50,6 +121,20 @@ class ReminderSystemBridge(private val activity: Activity, messenger: BinaryMess
|
|||||||
"batteryOptimizationsIgnored" to powerManager.isIgnoringBatteryOptimizations(activity.packageName),
|
"batteryOptimizationsIgnored" to powerManager.isIgnoringBatteryOptimizations(activity.packageName),
|
||||||
"powerSaveMode" to powerManager.isPowerSaveMode,
|
"powerSaveMode" to powerManager.isPowerSaveMode,
|
||||||
"interruptionFilter" to notificationManager.currentInterruptionFilter,
|
"interruptionFilter" to notificationManager.currentInterruptionFilter,
|
||||||
|
"deviceIdle" to powerManager.isDeviceIdleMode,
|
||||||
|
"standbyBucket" to if (Build.VERSION.SDK_INT >= 28) runCatching {
|
||||||
|
(activity.getSystemService(Activity.USAGE_STATS_SERVICE) as UsageStatsManager).appStandbyBucket
|
||||||
|
}.getOrNull() else null,
|
||||||
|
"startupAlarmAudit" to startupAudit,
|
||||||
|
"lastProblemAlarmAudit" to lastProblemAudit,
|
||||||
|
"lastExitReason" to lastExit?.reason,
|
||||||
|
"lastExitAtEpochMillis" to lastExit?.timestamp,
|
||||||
|
"wasForceStoppedBeforeStart" to wasStopped,
|
||||||
|
// Only still-visible notifications from this app; absence is unknown.
|
||||||
|
"lastVisibleCourseNotificationAt" to runCatching {
|
||||||
|
notificationManager.activeNotifications.filter { it.tag == "hepan-course" }
|
||||||
|
.maxOfOrNull { it.postTime }
|
||||||
|
}.getOrNull(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.tpschedule.app;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
public class ReminderHandleAuditTest {
|
||||||
|
@Test public void countsMissingHandlesWithoutLeakingIdsOrChangingTheInput() {
|
||||||
|
List<Integer> input = new ArrayList<>(Arrays.asList(11, 22, 22, -1002, null));
|
||||||
|
List<Integer> queried = new ArrayList<>();
|
||||||
|
Map<String, Object> result = ReminderHandleAudit.inspect(input, id -> {
|
||||||
|
queried.add(id); return id == 11;
|
||||||
|
});
|
||||||
|
assertEquals(2, result.get("cachedCourseCount"));
|
||||||
|
assertEquals(1, result.get("existingHandleCount"));
|
||||||
|
assertEquals(1, result.get("missingHandleCount"));
|
||||||
|
assertEquals(Arrays.asList(11, 22), queried);
|
||||||
|
assertEquals(5, input.size());
|
||||||
|
assertEquals(3, result.size());
|
||||||
|
}
|
||||||
|
@Test public void emptyScheduleDoesNotQueryTheSystem() {
|
||||||
|
Map<String, Object> result = ReminderHandleAudit.inspect(Arrays.asList(-1001, -1002), id -> {
|
||||||
|
fail("Test IDs must not be inspected as courses"); return false;
|
||||||
|
});
|
||||||
|
assertEquals(0, result.get("cachedCourseCount"));
|
||||||
|
assertEquals(0, result.get("missingHandleCount"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
const appVersion = '0.7.8';
|
const appVersion = '0.7.8';
|
||||||
const appBuildNumber = 32;
|
const appBuildNumber = 33;
|
||||||
const appFilingNumber = '蜀ICP备2025169276号-5A';
|
const appFilingNumber = '蜀ICP备2025169276号-5A';
|
||||||
|
|||||||
@@ -49,6 +49,16 @@ class _ReminderToolsState extends State<ReminderTools> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Future<void> _check() => _run(() async {
|
Future<void> _check() => _run(() async {
|
||||||
|
Map<String, Object?> before;
|
||||||
|
try {
|
||||||
|
before = await widget.controller.notifications.diagnose();
|
||||||
|
} catch (error) {
|
||||||
|
before = {
|
||||||
|
'errorCode': error is PlatformException
|
||||||
|
? error.code
|
||||||
|
: 'diagnosis_unavailable',
|
||||||
|
};
|
||||||
|
}
|
||||||
await widget.controller.refreshServices();
|
await widget.controller.refreshServices();
|
||||||
final diagnosis = await widget.controller.notifications.diagnose();
|
final diagnosis = await widget.controller.notifications.diagnose();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -67,6 +77,15 @@ class _ReminderToolsState extends State<ReminderTools> {
|
|||||||
'courseCount': c.snapshot.courses.length,
|
'courseCount': c.snapshot.courses.length,
|
||||||
'scheduledCount': c.reminderResult.scheduledCount,
|
'scheduledCount': c.reminderResult.scheduledCount,
|
||||||
'reminderStatus': c.reminderResult.status.name,
|
'reminderStatus': c.reminderResult.status.name,
|
||||||
|
'beforeCheck': {
|
||||||
|
for (final key in [
|
||||||
|
'notifications',
|
||||||
|
'exactAlarm',
|
||||||
|
'pendingCount',
|
||||||
|
'errorCode',
|
||||||
|
])
|
||||||
|
key: _scalar(before[key]),
|
||||||
|
},
|
||||||
for (final key in [
|
for (final key in [
|
||||||
'notifications',
|
'notifications',
|
||||||
'appNotificationsEnabled',
|
'appNotificationsEnabled',
|
||||||
@@ -91,8 +110,18 @@ class _ReminderToolsState extends State<ReminderTools> {
|
|||||||
'batteryOptimizationsIgnored',
|
'batteryOptimizationsIgnored',
|
||||||
'powerSaveMode',
|
'powerSaveMode',
|
||||||
'interruptionFilter',
|
'interruptionFilter',
|
||||||
|
'deviceIdle',
|
||||||
|
'standbyBucket',
|
||||||
|
'lastExitReason',
|
||||||
|
'lastExitAtEpochMillis',
|
||||||
|
'wasForceStoppedBeforeStart',
|
||||||
|
'lastVisibleCourseNotificationAt',
|
||||||
])
|
])
|
||||||
key: _scalar(system[key]),
|
key: _scalar(system[key]),
|
||||||
|
'startupAlarmAudit': _safeStartupAudit(system['startupAlarmAudit']),
|
||||||
|
'lastProblemAlarmAudit': _safeStartupAudit(
|
||||||
|
system['lastProblemAlarmAudit'],
|
||||||
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -104,6 +133,22 @@ class _ReminderToolsState extends State<ReminderTools> {
|
|||||||
Object? _scalar(Object? value) =>
|
Object? _scalar(Object? value) =>
|
||||||
value is String || value is bool || value is num ? value : null;
|
value is String || value is bool || value is num ? value : null;
|
||||||
|
|
||||||
|
Map<String, Object?>? _safeStartupAudit(Object? raw) {
|
||||||
|
if (raw is! Map) return null;
|
||||||
|
return {
|
||||||
|
for (final key in [
|
||||||
|
'capturedAtEpochMillis',
|
||||||
|
'cachedCourseCount',
|
||||||
|
'existingHandleCount',
|
||||||
|
'missingHandleCount',
|
||||||
|
'overdueCachedCount',
|
||||||
|
'partial',
|
||||||
|
'errorCode',
|
||||||
|
])
|
||||||
|
key: _scalar(raw[key]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
String _errorCode(Object? value) {
|
String _errorCode(Object? value) {
|
||||||
if (value is String && RegExp(r'^[a-zA-Z0-9_]{1,64}$').hasMatch(value)) {
|
if (value is String && RegExp(r'^[a-zA-Z0-9_]{1,64}$').hasMatch(value)) {
|
||||||
return value;
|
return value;
|
||||||
@@ -129,6 +174,11 @@ class _ReminderToolsState extends State<ReminderTools> {
|
|||||||
final system = rawSystem is Map ? rawSystem : const {};
|
final system = rawSystem is Map ? rawSystem : const {};
|
||||||
final pending = diagnosis['pendingCount'];
|
final pending = diagnosis['pendingCount'];
|
||||||
final sdk = system['sdk'];
|
final sdk = system['sdk'];
|
||||||
|
final audit =
|
||||||
|
_safeStartupAudit(system['lastProblemAlarmAudit']) ??
|
||||||
|
_safeStartupAudit(system['startupAlarmAudit']);
|
||||||
|
final missing = audit?['missingHandleCount'];
|
||||||
|
final overdue = audit?['overdueCachedCount'];
|
||||||
final channelsSupported = sdk is! int || sdk >= 26;
|
final channelsSupported = sdk is! int || sdk >= 26;
|
||||||
return [
|
return [
|
||||||
'通知权限:${_permission(diagnosis['notifications'])}',
|
'通知权限:${_permission(diagnosis['notifications'])}',
|
||||||
@@ -140,6 +190,14 @@ class _ReminderToolsState extends State<ReminderTools> {
|
|||||||
'课前提醒类别尚未建立,请重新打开应用后检查。',
|
'课前提醒类别尚未建立,请重新打开应用后检查。',
|
||||||
'精确提醒:${_permission(diagnosis['exactAlarm'])}',
|
'精确提醒:${_permission(diagnosis['exactAlarm'])}',
|
||||||
if (pending is int) '待发送提醒:$pending 条',
|
if (pending is int) '待发送提醒:$pending 条',
|
||||||
|
if (missing is num && missing > 0)
|
||||||
|
'发现 $missing 条提醒的系统触发信息缺失,已保留恢复前的诊断记录。',
|
||||||
|
if (overdue is num && overdue > 0) '发现 $overdue 条缓存提醒已超过预计时间,请复制诊断以便排查。',
|
||||||
|
if (system['standbyBucket'] is num &&
|
||||||
|
(system['standbyBucket'] as num) >= 40)
|
||||||
|
'系统待机限制:当前应用处于低活跃或受限分组,可能延后提醒。',
|
||||||
|
if (system['wasForceStoppedBeforeStart'] == true)
|
||||||
|
'系统记录本次启动前应用曾处于停止状态,先前的定时触发可能已被系统取消。',
|
||||||
if (diagnosis['notifications'] == false) '系统通知已关闭,请在通知设置中开启。',
|
if (diagnosis['notifications'] == false) '系统通知已关闭,请在通知设置中开启。',
|
||||||
if (diagnosis['channelImportance'] == 0) '提醒通道已关闭,请在通知设置中开启。',
|
if (diagnosis['channelImportance'] == 0) '提醒通道已关闭,请在通知设置中开启。',
|
||||||
if (diagnosis['channelSound'] == false) '提醒通道处于静音,请在通知设置中检查声音。',
|
if (diagnosis['channelSound'] == false) '提醒通道处于静音,请在通知设置中检查声音。',
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class ReminderRequest {
|
|||||||
abstract class ReminderGateway {
|
abstract class ReminderGateway {
|
||||||
Future<ReminderPermissions> permissions({required bool request});
|
Future<ReminderPermissions> permissions({required bool request});
|
||||||
Future<Set<int>> pendingIds();
|
Future<Set<int>> pendingIds();
|
||||||
|
Future<Set<int>> missingSystemIds(Set<int> ids) async => {};
|
||||||
Future<Set<int>> ownedIds() => pendingIds();
|
Future<Set<int>> ownedIds() => pendingIds();
|
||||||
Future<String> sendTestReminder({required bool scheduled}) async =>
|
Future<String> sendTestReminder({required bool scheduled}) async =>
|
||||||
'当前环境不支持通知测试';
|
'当前环境不支持通知测试';
|
||||||
@@ -124,6 +125,8 @@ class LocalNotificationService extends NotificationService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
final pending = await gateway.pendingIds();
|
final pending = await gateway.pendingIds();
|
||||||
|
// Include overdue entries so warm-resume diagnostics survive their removal.
|
||||||
|
final missing = await gateway.missingSystemIds(pending);
|
||||||
for (final old in (await gateway.ownedIds()).difference(
|
for (final old in (await gateway.ownedIds()).difference(
|
||||||
desired.keys.toSet(),
|
desired.keys.toSet(),
|
||||||
)) {
|
)) {
|
||||||
@@ -135,6 +138,7 @@ class LocalNotificationService extends NotificationService {
|
|||||||
final fingerprint =
|
final fingerprint =
|
||||||
'${request.fireAt.millisecondsSinceEpoch}/${request.title}/${request.body}/${request.exact}';
|
'${request.fireAt.millisecondsSinceEpoch}/${request.title}/${request.body}/${request.exact}';
|
||||||
if (pending.contains(entry.key) &&
|
if (pending.contains(entry.key) &&
|
||||||
|
!missing.contains(entry.key) &&
|
||||||
_fingerprints[entry.key] == fingerprint) {
|
_fingerprints[entry.key] == fingerprint) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -297,6 +301,20 @@ class PluginReminderGateway extends ReminderGateway {
|
|||||||
return owned;
|
return owned;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Set<int>> missingSystemIds(Set<int> ids) async {
|
||||||
|
if (ids.isEmpty) return {};
|
||||||
|
try {
|
||||||
|
final missing = await _system
|
||||||
|
.invokeListMethod<int>('findMissingAlarmHandles', ids.toList())
|
||||||
|
.timeout(const Duration(seconds: 2));
|
||||||
|
return missing?.where(ids.contains).toSet() ?? {};
|
||||||
|
} catch (_) {
|
||||||
|
// Availability of this extra check must not block normal scheduling.
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<String> sendTestReminder({required bool scheduled}) async {
|
Future<String> sendTestReminder({required bool scheduled}) async {
|
||||||
final access = await permissions(request: false);
|
final access = await permissions(request: false);
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: hepan_everyday
|
name: hepan_everyday
|
||||||
description: TPSchedule 学生课表
|
description: TPSchedule 学生课表
|
||||||
publish_to: none
|
publish_to: none
|
||||||
version: 0.7.8+32
|
version: 0.7.8+33
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.13.5
|
sdk: ^3.13.5
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@@ -7,6 +7,39 @@ import 'package:hepan_everyday/services/local_notification_service.dart';
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
test(
|
||||||
|
'system handle audit only accepts IDs from the requested cache',
|
||||||
|
() async {
|
||||||
|
const system = MethodChannel('com.tpschedule.app/reminders');
|
||||||
|
final messenger =
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||||
|
messenger.setMockMethodCallHandler(system, (call) async {
|
||||||
|
expect(call.method, 'findMissingAlarmHandles');
|
||||||
|
expect((call.arguments as List).toSet(), {11, 22});
|
||||||
|
return [22, 999];
|
||||||
|
});
|
||||||
|
addTearDown(() => messenger.setMockMethodCallHandler(system, null));
|
||||||
|
expect(await PluginReminderGateway().missingSystemIds({11, 22}), {22});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
'optional handle audit failure does not prevent reconciliation',
|
||||||
|
() async {
|
||||||
|
const system = MethodChannel('com.tpschedule.app/reminders');
|
||||||
|
final messenger =
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||||
|
var calls = 0;
|
||||||
|
messenger.setMockMethodCallHandler(system, (_) async {
|
||||||
|
calls++;
|
||||||
|
throw PlatformException(code: 'reminder_system_unavailable');
|
||||||
|
});
|
||||||
|
addTearDown(() => messenger.setMockMethodCallHandler(system, null));
|
||||||
|
final gateway = PluginReminderGateway();
|
||||||
|
expect(await gateway.missingSystemIds({11}), isEmpty);
|
||||||
|
expect(await gateway.missingSystemIds({}), isEmpty);
|
||||||
|
expect(calls, 1);
|
||||||
|
},
|
||||||
|
);
|
||||||
test('fresh notification initialization creates the course category before checks', () async {
|
test('fresh notification initialization creates the course category before checks', () async {
|
||||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||||
AndroidFlutterLocalNotificationsPlugin.registerWith();
|
AndroidFlutterLocalNotificationsPlugin.registerWith();
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ class AlarmGateway extends ReminderGateway {
|
|||||||
final alarms = <int, ReminderRequest>{};
|
final alarms = <int, ReminderRequest>{};
|
||||||
int permissionRequests = 0;
|
int permissionRequests = 0;
|
||||||
bool dropWrites = false;
|
bool dropWrites = false;
|
||||||
|
final missingHandles = <int>{};
|
||||||
|
Set<int> lastAuditedIds = {};
|
||||||
|
int scheduleCalls = 0;
|
||||||
|
@override
|
||||||
|
Future<Set<int>> missingSystemIds(Set<int> ids) async {
|
||||||
|
lastAuditedIds = {...ids};
|
||||||
|
return missingHandles.intersection(ids);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<ReminderPermissions> permissions({required bool request}) async {
|
Future<ReminderPermissions> permissions({required bool request}) async {
|
||||||
if (request) permissionRequests++;
|
if (request) permissionRequests++;
|
||||||
@@ -23,7 +32,9 @@ class AlarmGateway extends ReminderGateway {
|
|||||||
Future<Set<int>> pendingIds() async => alarms.keys.toSet();
|
Future<Set<int>> pendingIds() async => alarms.keys.toSet();
|
||||||
@override
|
@override
|
||||||
Future<void> schedule(ReminderRequest value) async {
|
Future<void> schedule(ReminderRequest value) async {
|
||||||
|
scheduleCalls++;
|
||||||
if (!dropWrites) alarms[value.id] = value;
|
if (!dropWrites) alarms[value.id] = value;
|
||||||
|
missingHandles.remove(value.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -48,6 +59,32 @@ void main() {
|
|||||||
return AppSnapshot.fromJson(json).settings;
|
return AppSnapshot.fromJson(json).settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test(
|
||||||
|
'overdue cached reminders are audited before removal on warm resume',
|
||||||
|
() async {
|
||||||
|
final events = ScheduleEngine.occurrences([course()], term);
|
||||||
|
await service.reconcile(events, settings, DateTime.utc(2026, 9, 9));
|
||||||
|
final cached = gateway.alarms.keys.toSet();
|
||||||
|
expect(cached, isNotEmpty);
|
||||||
|
await service.reconcile(events, settings, DateTime.utc(2027, 1, 1));
|
||||||
|
expect(gateway.lastAuditedIds, cached);
|
||||||
|
expect(gateway.alarms, isEmpty);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('cached fingerprint does not hide a missing system trigger', () async {
|
||||||
|
final events = ScheduleEngine.occurrences([course()], term);
|
||||||
|
final now = DateTime.utc(2026, 9, 9);
|
||||||
|
await service.reconcile(events, settings, now);
|
||||||
|
final previous = gateway.scheduleCalls;
|
||||||
|
gateway.missingHandles.addAll(gateway.alarms.keys);
|
||||||
|
await service.reconcile(events, settings, now);
|
||||||
|
expect(gateway.scheduleCalls, previous + 1);
|
||||||
|
expect(gateway.missingHandles, isEmpty);
|
||||||
|
await service.reconcile(events, settings, now);
|
||||||
|
expect(gateway.scheduleCalls, previous + 1);
|
||||||
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'does not report success when native scheduling did not persist',
|
'does not report success when native scheduling did not persist',
|
||||||
() async {
|
() async {
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ class _Notifications extends FakeNotifications {
|
|||||||
'model': 'TestModel',
|
'model': 'TestModel',
|
||||||
'sdk': 35,
|
'sdk': 35,
|
||||||
'backgroundRestricted': true,
|
'backgroundRestricted': true,
|
||||||
|
'standbyBucket': 45,
|
||||||
|
'startupAlarmAudit': {
|
||||||
|
'capturedAtEpochMillis': 123,
|
||||||
|
'cachedCourseCount': 3,
|
||||||
|
'existingHandleCount': 1,
|
||||||
|
'missingHandleCount': 2,
|
||||||
|
'courseName': 'Private course name',
|
||||||
|
},
|
||||||
'account': 'private@example.com',
|
'account': 'private@example.com',
|
||||||
},
|
},
|
||||||
'courseNames': ['Private course name'],
|
'courseNames': ['Private course name'],
|
||||||
@@ -129,7 +137,7 @@ void main() {
|
|||||||
final previous = notifications.reconciliations;
|
final previous = notifications.reconciliations;
|
||||||
await tester.tap(find.text('检查提醒'));
|
await tester.tap(find.text('检查提醒'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(notifications.reconciliationsAtDiagnosis, previous + 1);
|
expect(notifications.reconciliationsAtDiagnosis, previous);
|
||||||
for (final button in tester.widgetList<TextButton>(
|
for (final button in tester.widgetList<TextButton>(
|
||||||
find.byType(TextButton),
|
find.byType(TextButton),
|
||||||
)) {
|
)) {
|
||||||
@@ -174,5 +182,12 @@ void main() {
|
|||||||
expect(report['courseCount'], 0);
|
expect(report['courseCount'], 0);
|
||||||
expect(report['appVersion'], isNotEmpty);
|
expect(report['appVersion'], isNotEmpty);
|
||||||
expect((report['system'] as Map)['model'], 'TestModel');
|
expect((report['system'] as Map)['model'], 'TestModel');
|
||||||
|
expect(
|
||||||
|
((report['system'] as Map)['startupAlarmAudit']
|
||||||
|
as Map)['missingHandleCount'],
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
expect((report['beforeCheck'] as Map)['pendingCount'], 2);
|
||||||
|
expect(find.textContaining('系统待机限制'), findsOneWidget);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user