Fix cached reminder recovery and retain pre-reconcile idle diagnostics

This commit is contained in:
david1465833828
2026-10-28 17:46:33 +08:00
parent 93bcfd534c
commit dc71e411e9
13 changed files with 350 additions and 6 deletions
@@ -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.ActivityManager
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.usage.UsageStatsManager
import android.content.Intent
import android.net.Uri
import android.os.Build
@@ -10,14 +12,77 @@ import android.os.PowerManager
import android.provider.Settings
import io.flutter.plugin.common.BinaryMessenger
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) {
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 {
channel.setMethodCallHandler { call, result ->
try {
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())
"openNotificationSettings" -> {
openNotificationSettings()
@@ -39,6 +104,12 @@ class ReminderSystemBridge(private val activity: Activity, messenger: BinaryMess
val activityManager = activity.getSystemService(Activity.ACTIVITY_SERVICE) as ActivityManager
val powerManager = activity.getSystemService(Activity.POWER_SERVICE) as PowerManager
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(
"manufacturer" to Build.MANUFACTURER,
"model" to Build.MODEL,
@@ -50,6 +121,20 @@ class ReminderSystemBridge(private val activity: Activity, messenger: BinaryMess
"batteryOptimizationsIgnored" to powerManager.isIgnoringBatteryOptimizations(activity.packageName),
"powerSaveMode" to powerManager.isPowerSaveMode,
"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 -1
View File
@@ -1,3 +1,3 @@
const appVersion = '0.7.8';
const appBuildNumber = 32;
const appBuildNumber = 33;
const appFilingNumber = '蜀ICP备2025169276号-5A';
@@ -49,6 +49,16 @@ class _ReminderToolsState extends State<ReminderTools> {
});
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();
final diagnosis = await widget.controller.notifications.diagnose();
if (!mounted) return;
@@ -67,6 +77,15 @@ class _ReminderToolsState extends State<ReminderTools> {
'courseCount': c.snapshot.courses.length,
'scheduledCount': c.reminderResult.scheduledCount,
'reminderStatus': c.reminderResult.status.name,
'beforeCheck': {
for (final key in [
'notifications',
'exactAlarm',
'pendingCount',
'errorCode',
])
key: _scalar(before[key]),
},
for (final key in [
'notifications',
'appNotificationsEnabled',
@@ -91,8 +110,18 @@ class _ReminderToolsState extends State<ReminderTools> {
'batteryOptimizationsIgnored',
'powerSaveMode',
'interruptionFilter',
'deviceIdle',
'standbyBucket',
'lastExitReason',
'lastExitAtEpochMillis',
'wasForceStoppedBeforeStart',
'lastVisibleCourseNotificationAt',
])
key: _scalar(system[key]),
'startupAlarmAudit': _safeStartupAudit(system['startupAlarmAudit']),
'lastProblemAlarmAudit': _safeStartupAudit(
system['lastProblemAlarmAudit'],
),
},
};
setState(() {
@@ -104,6 +133,22 @@ class _ReminderToolsState extends State<ReminderTools> {
Object? _scalar(Object? value) =>
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) {
if (value is String && RegExp(r'^[a-zA-Z0-9_]{1,64}$').hasMatch(value)) {
return value;
@@ -129,6 +174,11 @@ class _ReminderToolsState extends State<ReminderTools> {
final system = rawSystem is Map ? rawSystem : const {};
final pending = diagnosis['pendingCount'];
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;
return [
'通知权限:${_permission(diagnosis['notifications'])}',
@@ -140,6 +190,14 @@ class _ReminderToolsState extends State<ReminderTools> {
'课前提醒类别尚未建立,请重新打开应用后检查。',
'精确提醒:${_permission(diagnosis['exactAlarm'])}',
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['channelImportance'] == 0) '提醒通道已关闭,请在通知设置中开启。',
if (diagnosis['channelSound'] == false) '提醒通道处于静音,请在通知设置中检查声音。',
@@ -34,6 +34,7 @@ class ReminderRequest {
abstract class ReminderGateway {
Future<ReminderPermissions> permissions({required bool request});
Future<Set<int>> pendingIds();
Future<Set<int>> missingSystemIds(Set<int> ids) async => {};
Future<Set<int>> ownedIds() => pendingIds();
Future<String> sendTestReminder({required bool scheduled}) async =>
'当前环境不支持通知测试';
@@ -124,6 +125,8 @@ class LocalNotificationService extends NotificationService {
);
}
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(
desired.keys.toSet(),
)) {
@@ -135,6 +138,7 @@ class LocalNotificationService extends NotificationService {
final fingerprint =
'${request.fireAt.millisecondsSinceEpoch}/${request.title}/${request.body}/${request.exact}';
if (pending.contains(entry.key) &&
!missing.contains(entry.key) &&
_fingerprints[entry.key] == fingerprint) {
continue;
}
@@ -297,6 +301,20 @@ class PluginReminderGateway extends ReminderGateway {
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
Future<String> sendTestReminder({required bool scheduled}) async {
final access = await permissions(request: false);
+1 -1
View File
@@ -1,7 +1,7 @@
name: hepan_everyday
description: TPSchedule 学生课表
publish_to: none
version: 0.7.8+32
version: 0.7.8+33
environment:
sdk: ^3.13.5
dependencies:
@@ -7,6 +7,39 @@ import 'package:hepan_everyday/services/local_notification_service.dart';
void main() {
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 {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
AndroidFlutterLocalNotificationsPlugin.registerWith();
@@ -13,6 +13,15 @@ class AlarmGateway extends ReminderGateway {
final alarms = <int, ReminderRequest>{};
int permissionRequests = 0;
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
Future<ReminderPermissions> permissions({required bool request}) async {
if (request) permissionRequests++;
@@ -23,7 +32,9 @@ class AlarmGateway extends ReminderGateway {
Future<Set<int>> pendingIds() async => alarms.keys.toSet();
@override
Future<void> schedule(ReminderRequest value) async {
scheduleCalls++;
if (!dropWrites) alarms[value.id] = value;
missingHandles.remove(value.id);
}
@override
@@ -48,6 +59,32 @@ void main() {
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(
'does not report success when native scheduling did not persist',
() async {
+16 -1
View File
@@ -37,6 +37,14 @@ class _Notifications extends FakeNotifications {
'model': 'TestModel',
'sdk': 35,
'backgroundRestricted': true,
'standbyBucket': 45,
'startupAlarmAudit': {
'capturedAtEpochMillis': 123,
'cachedCourseCount': 3,
'existingHandleCount': 1,
'missingHandleCount': 2,
'courseName': 'Private course name',
},
'account': 'private@example.com',
},
'courseNames': ['Private course name'],
@@ -129,7 +137,7 @@ void main() {
final previous = notifications.reconciliations;
await tester.tap(find.text('检查提醒'));
await tester.pump();
expect(notifications.reconciliationsAtDiagnosis, previous + 1);
expect(notifications.reconciliationsAtDiagnosis, previous);
for (final button in tester.widgetList<TextButton>(
find.byType(TextButton),
)) {
@@ -174,5 +182,12 @@ void main() {
expect(report['courseCount'], 0);
expect(report['appVersion'], isNotEmpty);
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);
});
}