feat(dev): add notification debug panel and native pending inspector

Add a dev-only Notification Debug Panel at /dev/notifications for testing
predictive refresh and WAKEUP_PING without a backend.

- Gate route and Advanced Settings entry on import.meta.env.DEV
- NotificationDebugService drives mock refresh, flood test, clear, and
  wake simulation via existing handleCapacitorPushNotificationReceived and
  applyNotificationRefreshPayload (shared with refreshNotifications)
- Add NotificationInspector Capacitor plugin: iOS lists pending
  UNNotificationRequest identifiers and next trigger; Android stub returns
  empty pending for safe registration
This commit is contained in:
Jose Olarte III
2026-05-07 18:52:59 +08:00
parent 320e55912b
commit fd0b8ce6d0
11 changed files with 596 additions and 51 deletions

View File

@@ -16,6 +16,7 @@ import android.webkit.WebViewClient;
import com.getcapacitor.BridgeActivity;
import app.timesafari.safearea.SafeAreaPlugin;
import app.timesafari.sharedimage.SharedImagePlugin;
import app.timesafari.notifications.NotificationInspectorPlugin;
//import com.getcapacitor.community.sqlite.SQLite;
import android.content.SharedPreferences;
@@ -66,6 +67,9 @@ public class MainActivity extends BridgeActivity {
// Register SharedImage plugin
registerPlugin(SharedImagePlugin.class);
// Register NotificationInspector plugin (dev tooling; safe no-op on Android)
registerPlugin(NotificationInspectorPlugin.class);
// Register DailyNotification plugin
// Plugin is written in Kotlin but compiles to Java-compatible bytecode

View File

@@ -0,0 +1,19 @@
package app.timesafari.notifications;
import com.getcapacitor.JSArray;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
@CapacitorPlugin(name = "NotificationInspector")
public class NotificationInspectorPlugin extends Plugin {
@PluginMethod
public void getPendingNotifications(PluginCall call) {
JSObject result = new JSObject();
result.put("pending", new JSArray());
call.resolve(result);
}
}

View File

@@ -29,6 +29,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
attempts += 1
if registerSharedImagePlugin() {
print("[AppDelegate] ✅ Plugin registration successful on attempt \(attempts)")
_ = registerNotificationInspectorPlugin()
} else if attempts < maxAttempts {
DispatchQueue.main.asyncAfter(deadline: .now() + Double(attempts) * 0.5) {
tryRegister()
@@ -64,6 +65,20 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
return true
}
@discardableResult
private func registerNotificationInspectorPlugin() -> Bool {
guard let window = self.window,
let bridgeVC = window.rootViewController as? CAPBridgeViewController,
let bridge = bridgeVC.bridge else {
return false
}
let pluginInstance = NotificationInspectorPlugin()
bridge.registerPluginInstance(pluginInstance)
print("[AppDelegate] ✅ Registered NotificationInspectorPlugin (exposed as 'NotificationInspector')")
return true
}
func applicationWillResignActive(_ application: UIApplication) {
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.

View File

@@ -0,0 +1,51 @@
import Foundation
import Capacitor
import UserNotifications
@objc(NotificationInspector)
public class NotificationInspectorPlugin: CAPPlugin, CAPBridgedPlugin {
public var identifier: String { "NotificationInspector" }
public var jsName: String { "NotificationInspector" }
public var pluginMethods: [CAPPluginMethod] {
[
CAPPluginMethod(#selector(getPendingNotifications(_:)), returnType: .promise)
]
}
@objc public func getPendingNotifications(_ call: CAPPluginCall) {
UNUserNotificationCenter.current().getPendingNotificationRequests { requests in
let pending: [[String: Any]] = requests.map { req in
var nextTriggerMs: NSNumber? = nil
var triggerType: String? = nil
if let trigger = req.trigger as? UNCalendarNotificationTrigger {
triggerType = "calendar"
if let next = trigger.nextTriggerDate() {
nextTriggerMs = NSNumber(value: Int64(next.timeIntervalSince1970 * 1000))
}
} else if let trigger = req.trigger as? UNTimeIntervalNotificationTrigger {
triggerType = "timeInterval"
if let next = trigger.nextTriggerDate() {
nextTriggerMs = NSNumber(value: Int64(next.timeIntervalSince1970 * 1000))
}
} else if req.trigger != nil {
triggerType = "other"
} else {
triggerType = nil
}
var obj: [String: Any] = [
"identifier": req.identifier
]
obj["nextTriggerDate"] = nextTriggerMs ?? NSNull()
obj["triggerType"] = triggerType ?? NSNull()
return obj
}
call.resolve([
"pending": pending
])
}
}
}

View File

@@ -0,0 +1,210 @@
<template>
<section class="bg-slate-100 rounded-md overflow-hidden px-4 py-4">
<!-- SECTION F: Mock Timing Presets -->
<div class="mb-6">
<h2 class="mb-2 font-bold">Mock Timing Presets</h2>
<div class="flex flex-wrap gap-2">
<button
v-for="preset in presets"
:key="preset.ms"
class="px-3 py-2 rounded border border-slate-300 bg-white text-sm"
:class="{
'border-blue-500 ring-1 ring-blue-300': intervalMs === preset.ms,
}"
@click="intervalMs = preset.ms"
>
{{ preset.label }}
</button>
</div>
<div class="text-xs text-slate-500 mt-2">
Selected interval: <b>{{ intervalLabel }}</b>
</div>
</div>
<!-- SECTION A: Mock Refresh Controls -->
<div class="mb-6">
<h2 class="mb-2 font-bold">Mock Refresh Controls</h2>
<button
class="w-full text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
:disabled="busy"
:class="{ 'opacity-50 cursor-not-allowed': busy }"
@click="onMockRefresh"
>
Trigger Mock Refresh
</button>
</div>
<!-- SECTION B: Wakeup Ping Simulator -->
<div class="mb-6">
<h2 class="mb-2 font-bold">Wakeup Ping Simulator</h2>
<button
class="w-full text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
:disabled="busy"
:class="{ 'opacity-50 cursor-not-allowed': busy }"
@click="onWakeupPing"
>
Simulate WAKEUP_PING
</button>
</div>
<!-- SECTION C: Flood Test -->
<div class="mb-6">
<h2 class="mb-2 font-bold">Flood Test</h2>
<button
class="w-full text-md bg-gradient-to-b from-rose-400 to-rose-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
:disabled="busy"
:class="{ 'opacity-50 cursor-not-allowed': busy }"
@click="onFloodTest"
>
Run 20 Refreshes
</button>
</div>
<!-- SECTION D: Pending Notification Inspector -->
<div class="mb-6">
<div class="flex items-center gap-3 mb-2">
<h2 class="font-bold">Pending Notification Inspector</h2>
<button
class="ms-auto text-sm px-3 py-2 rounded border border-slate-300 bg-white"
:disabled="busy"
:class="{ 'opacity-50 cursor-not-allowed': busy }"
@click="refreshPending"
>
Refresh
</button>
</div>
<div
v-if="pending.length === 0"
class="text-sm text-slate-500 bg-white rounded px-3 py-2 border border-slate-200"
>
(none)
</div>
<ul v-else class="bg-white rounded border border-slate-200 divide-y">
<li
v-for="p in pending"
:key="p.identifier"
class="px-3 py-2 text-sm flex gap-3 items-center"
>
<code class="truncate">{{ p.identifier }}</code>
<span class="ms-auto text-xs text-slate-500">
{{
p.nextTriggerDate ? new Date(p.nextTriggerDate).toISOString() : ""
}}
</span>
</li>
</ul>
</div>
<!-- SECTION E: Clear Notifications -->
<div class="mb-6">
<h2 class="mb-2 font-bold">Clear Notifications</h2>
<button
class="w-full text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
:disabled="busy"
:class="{ 'opacity-50 cursor-not-allowed': busy }"
@click="onClearNotifications"
>
Clear Notifications
</button>
</div>
<!-- SECTION G: Event Log -->
<div>
<div class="flex items-center gap-3 mb-2">
<h2 class="font-bold">Event Log</h2>
<button
class="ms-auto text-sm px-3 py-2 rounded border border-slate-300 bg-white"
@click="NotificationDebugService.clearDebugLogs()"
>
Clear Log
</button>
</div>
<div
class="bg-white rounded border border-slate-200 px-3 py-2 text-xs font-mono whitespace-pre-wrap min-h-[8rem]"
>
<div v-if="eventLog.length === 0" class="text-slate-400">(empty)</div>
<div v-for="(line, idx) in eventLog" v-else :key="idx">
{{ line }}
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { NotificationDebugService } from "@/services/notifications/NotificationDebugService";
type PendingInfo = {
identifier: string;
nextTriggerDate?: number | null;
triggerType?: string | null;
};
const presets = [
{ label: "30 sec", ms: 30_000 },
{ label: "1 min", ms: 60_000 },
{ label: "5 min", ms: 5 * 60_000 },
{ label: "10 min", ms: 10 * 60_000 },
];
const intervalMs = ref<number>(60_000);
const busy = ref(false);
const pending = ref<PendingInfo[]>([]);
const eventLog = computed(() => NotificationDebugService.eventLog.value);
const intervalLabel = computed(() => {
const preset = presets.find((p) => p.ms === intervalMs.value);
return preset?.label ?? `${intervalMs.value}ms`;
});
async function withBusy(fn: () => Promise<void>): Promise<void> {
if (busy.value) return;
busy.value = true;
try {
await fn();
} finally {
busy.value = false;
}
}
async function refreshPending(): Promise<void> {
await withBusy(async () => {
pending.value = await NotificationDebugService.getPendingNotifications();
});
}
async function onMockRefresh(): Promise<void> {
await withBusy(async () => {
await NotificationDebugService.triggerMockRefresh(intervalMs.value);
await refreshPending();
});
}
async function onWakeupPing(): Promise<void> {
await withBusy(async () => {
await NotificationDebugService.simulateWakeupPing();
await refreshPending();
});
}
async function onFloodTest(): Promise<void> {
await withBusy(async () => {
await NotificationDebugService.runFloodTest(intervalMs.value);
await refreshPending();
});
}
async function onClearNotifications(): Promise<void> {
await withBusy(async () => {
await NotificationDebugService.clearNotifications();
await refreshPending();
});
}
onMounted(() => {
void refreshPending();
});
</script>

View File

@@ -0,0 +1,14 @@
import { registerPlugin } from "@capacitor/core";
export type PendingNotificationInfo = {
identifier: string;
nextTriggerDate?: number | null;
triggerType?: string | null;
};
export interface NotificationInspectorPlugin {
getPendingNotifications(): Promise<{ pending: PendingNotificationInfo[] }>;
}
export const NotificationInspector =
registerPlugin<NotificationInspectorPlugin>("NotificationInspector");

View File

@@ -290,6 +290,19 @@ const routes: Array<RouteRecordRaw> = [
name: "user-profile",
component: () => import("../views/UserProfileView.vue"),
},
...(import.meta.env.DEV
? ([
{
path: "/dev/notifications",
name: "dev-notifications",
component: () => import("../views/dev/NotificationDebugView.vue"),
meta: {
title: "Notification Debug",
requiresAuth: false,
},
},
] satisfies Array<RouteRecordRaw>)
: []),
// Catch-all route for 404 errors - must be last
{
path: "/:pathMatch(.*)*",

View File

@@ -573,62 +573,85 @@ export async function refreshNotifications(): Promise<void> {
}
const data: unknown = await res.json();
const nextNotifications = (data as { nextNotifications?: unknown })
?.nextNotifications;
if (!Array.isArray(nextNotifications)) {
return;
}
const timestamps = nextNotifications
.map((n) => (n as { timestamp?: unknown })?.timestamp)
.filter((t): t is number => typeof t === "number" && Number.isFinite(t));
if (timestamps.length === 0) {
return;
}
// Keep existing behavior: ensure background worker credentials are current.
await configureNativeFetcherIfReady();
// Backend is source of truth: apply exact timestamps (no HH:mm / recurring conversion).
// Only clear after we have a valid API response payload.
// eslint-disable-next-line no-console
console.log("[Notifications] Applying timestamps:", nextNotifications);
const plugin = DailyNotification as unknown as {
clearAllNotifications?: () => Promise<void>;
scheduleNotifications?: (options: {
timestamps: number[];
}) => Promise<void>;
cancelAllNotifications?: () => Promise<void>;
};
if (typeof plugin.clearAllNotifications === "function") {
await plugin.clearAllNotifications();
} else if (typeof plugin.cancelAllNotifications === "function") {
// Back-compat: older builds expose cancelAllNotifications.
await plugin.cancelAllNotifications();
} else {
logger.warn(
"[NativeNotificationService] No clearAllNotifications/cancelAllNotifications on plugin; cannot replace schedule",
);
return;
}
if (typeof plugin.scheduleNotifications !== "function") {
logger.warn(
"[NativeNotificationService] scheduleNotifications not available on plugin; cannot apply timestamps",
);
return;
}
await plugin.scheduleNotifications({ timestamps });
await applyNotificationRefreshPayload(data);
} catch (err) {
logger.error("[NativeNotificationService] Refresh failed", err);
}
}
export type NotificationRefreshPayload = {
shouldNotify?: boolean;
nextNotifications?: Array<{ timestamp?: number }>;
};
/**
* Apply a "refresh notifications" payload by clearing and scheduling timestamps via the native plugin.
*
* This is the shared implementation used by:
* - production refresh flow (`refreshNotifications` fetching from backend)
* - dev-only debug flows (mock refresh with local payloads)
*
* Important: This function intentionally mirrors production behavior and does not introduce
* any scheduling logic in UI layers.
*/
export async function applyNotificationRefreshPayload(
payload: unknown,
): Promise<void> {
if (!Capacitor.isNativePlatform()) {
return;
}
const data = payload as NotificationRefreshPayload;
const nextNotifications = data?.nextNotifications;
if (!Array.isArray(nextNotifications)) {
return;
}
const timestamps = nextNotifications
.map((n) => (n as { timestamp?: unknown })?.timestamp)
.filter((t): t is number => typeof t === "number" && Number.isFinite(t));
if (timestamps.length === 0) {
return;
}
// Keep existing behavior: ensure background worker credentials are current.
await configureNativeFetcherIfReady();
// Backend (or debug harness) is the source of truth: apply exact timestamps.
// eslint-disable-next-line no-console
console.log("[Notifications] Applying timestamps:", nextNotifications);
const plugin = DailyNotification as unknown as {
clearAllNotifications?: () => Promise<void>;
scheduleNotifications?: (options: {
timestamps: number[];
}) => Promise<void>;
cancelAllNotifications?: () => Promise<void>;
};
if (typeof plugin.clearAllNotifications === "function") {
await plugin.clearAllNotifications();
} else if (typeof plugin.cancelAllNotifications === "function") {
// Back-compat: older builds expose cancelAllNotifications.
await plugin.cancelAllNotifications();
} else {
logger.warn(
"[NativeNotificationService] No clearAllNotifications/cancelAllNotifications on plugin; cannot replace schedule",
);
return;
}
if (typeof plugin.scheduleNotifications !== "function") {
logger.warn(
"[NativeNotificationService] scheduleNotifications not available on plugin; cannot apply timestamps",
);
return;
}
await plugin.scheduleNotifications({ timestamps });
}
/**
* Silent FCM/APNs data push: refresh native notification pipeline when requested by backend.
*/

View File

@@ -0,0 +1,153 @@
import { Capacitor } from "@capacitor/core";
import { ref, readonly, type Ref } from "vue";
import type { PushNotificationSchema } from "@capacitor/push-notifications";
import { logger } from "@/utils/logger";
import {
applyNotificationRefreshPayload,
handleCapacitorPushNotificationReceived,
type NotificationRefreshPayload,
} from "./NativeNotificationService";
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
import { NotificationInspector } from "@/plugins/NotificationInspectorPlugin";
type PendingNotificationInfo = {
identifier: string;
nextTriggerDate?: number | null;
triggerType?: string | null;
};
const LOG = "[NotificationDebugService]";
function formatTime(d: Date): string {
const hh = d.getHours().toString().padStart(2, "0");
const mm = d.getMinutes().toString().padStart(2, "0");
const ss = d.getSeconds().toString().padStart(2, "0");
return `${hh}:${mm}:${ss}`;
}
function logLine(message: string): string {
return `[${formatTime(new Date())}] ${message}`;
}
const _eventLog: Ref<string[]> = ref([]);
function append(message: string): void {
const line = logLine(message);
_eventLog.value = [..._eventLog.value, line].slice(-250);
logger.debug(`${LOG} ${message}`);
}
export const NotificationDebugService = {
eventLog: readonly(_eventLog),
clearDebugLogs(): void {
_eventLog.value = [];
},
generateMockNotifications(
intervalMs: number = 60_000,
): NotificationRefreshPayload {
const now = Date.now();
const future1 = now + intervalMs;
const future2 = now + intervalMs * 2;
return {
shouldNotify: true,
nextNotifications: [{ timestamp: future1 }, { timestamp: future2 }],
};
},
async triggerMockRefresh(intervalMs?: number): Promise<void> {
append("Refresh requested (mock)");
const payload = this.generateMockNotifications(intervalMs);
const timestamps = payload.nextNotifications?.map((n) => n.timestamp) ?? [];
append(`Mock payload generated (${timestamps.length} timestamps)`);
for (const ts of timestamps) {
if (typeof ts === "number") {
append(`Scheduling timestamp ${ts}`);
}
}
if (!Capacitor.isNativePlatform()) {
append("Skipped: not running on native platform");
return;
}
await applyNotificationRefreshPayload(payload);
append("Mock refresh applied");
},
async simulateWakeupPing(): Promise<void> {
append("Simulating WAKEUP_PING");
if (!Capacitor.isNativePlatform()) {
append("Skipped: not running on native platform");
return;
}
const notification = {
title: "WAKEUP_PING",
body: "",
id: "dev_wakeup_ping",
data: { type: "WAKEUP_PING" },
} as unknown as PushNotificationSchema;
await handleCapacitorPushNotificationReceived(notification);
append("WAKEUP_PING handled (production handler)");
},
async runFloodTest(intervalMs?: number): Promise<void> {
append("Flood test started (20 sequential refreshes)");
for (let i = 0; i < 20; i++) {
append(`Flood iteration ${i + 1}/20`);
await this.triggerMockRefresh(intervalMs);
}
append("Flood test completed");
},
async clearNotifications(): Promise<void> {
append("Clearing notifications");
if (!Capacitor.isNativePlatform()) {
append("Skipped: not running on native platform");
return;
}
const plugin = DailyNotification as unknown as {
clearAllNotifications?: () => Promise<void>;
cancelAllNotifications?: () => Promise<void>;
};
if (typeof plugin.clearAllNotifications === "function") {
await plugin.clearAllNotifications();
} else if (typeof plugin.cancelAllNotifications === "function") {
await plugin.cancelAllNotifications();
} else {
append("Clear not available (plugin method missing)");
return;
}
append("Cleared notifications");
},
async getPendingNotifications(): Promise<PendingNotificationInfo[]> {
append("Fetching pending notifications");
if (!Capacitor.isNativePlatform()) {
append("Skipped: not running on native platform");
return [];
}
try {
const res = await NotificationInspector.getPendingNotifications();
const items = (res?.pending ?? []) as PendingNotificationInfo[];
append(`Pending fetched (${items.length})`);
return items;
} catch (e) {
append("Pending fetch failed");
logger.warn(`${LOG} getPendingNotifications failed`, e);
return [];
}
},
};

View File

@@ -742,6 +742,13 @@
>
Logs
</router-link>
<router-link
v-if="isDev"
:to="{ name: 'dev-notifications' }"
class="block w-fit text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md mt-2"
>
Notification Debug Panel
</router-link>
<router-link
:to="{ name: 'test' }"
class="block w-fit text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md mt-2"
@@ -888,6 +895,7 @@ export default class AccountViewView extends Vue {
readonly DEFAULT_IMAGE_API_SERVER: string = DEFAULT_IMAGE_API_SERVER;
readonly DEFAULT_PARTNER_API_SERVER: string = DEFAULT_PARTNER_API_SERVER;
readonly PASSKEYS_ENABLED: boolean = PASSKEYS_ENABLED;
readonly isDev: boolean = import.meta.env.DEV === true;
// Identity and settings properties
activeDid: string = "";

View File

@@ -0,0 +1,35 @@
<template>
<main class="p-6 pb-24 max-w-3xl mx-auto" role="main">
<div class="flex items-center gap-4 mb-6">
<h1 class="text-2xl font-bold leading-none">Notification Debug</h1>
<router-link
:to="{ name: 'account' }"
class="ms-auto text-sm text-blue-600"
>
Back to Account
</router-link>
</div>
<div
v-if="!isDev"
class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border rounded-md overflow-hidden px-4 py-3"
role="alert"
>
This view is only available in development builds.
</div>
<NotificationDebugPanel v-else />
</main>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import NotificationDebugPanel from "@/components/dev/NotificationDebugPanel.vue";
@Component({
components: { NotificationDebugPanel },
})
export default class NotificationDebugView extends Vue {
readonly isDev: boolean = import.meta.env.DEV === true;
}
</script>