Fix cached reminder recovery and retain pre-reconcile idle diagnostics
This commit is contained in:
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user