diff --git a/android/app/src/main/java/app/timesafari/MainActivity.java b/android/app/src/main/java/app/timesafari/MainActivity.java index d5261913..fed494f2 100644 --- a/android/app/src/main/java/app/timesafari/MainActivity.java +++ b/android/app/src/main/java/app/timesafari/MainActivity.java @@ -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 diff --git a/android/app/src/main/java/app/timesafari/notifications/NotificationInspectorPlugin.java b/android/app/src/main/java/app/timesafari/notifications/NotificationInspectorPlugin.java new file mode 100644 index 00000000..e3371ef6 --- /dev/null +++ b/android/app/src/main/java/app/timesafari/notifications/NotificationInspectorPlugin.java @@ -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); + } +} + diff --git a/ios/App/App/AppDelegate.swift b/ios/App/App/AppDelegate.swift index f050ab0c..c60baaed 100644 --- a/ios/App/App/AppDelegate.swift +++ b/ios/App/App/AppDelegate.swift @@ -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. diff --git a/ios/App/App/NotificationInspectorPlugin.swift b/ios/App/App/NotificationInspectorPlugin.swift new file mode 100644 index 00000000..cee28658 --- /dev/null +++ b/ios/App/App/NotificationInspectorPlugin.swift @@ -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 + ]) + } + } +} + diff --git a/src/components/dev/NotificationDebugPanel.vue b/src/components/dev/NotificationDebugPanel.vue new file mode 100644 index 00000000..a2e58742 --- /dev/null +++ b/src/components/dev/NotificationDebugPanel.vue @@ -0,0 +1,210 @@ + + + + + Mock Timing Presets + + + {{ preset.label }} + + + + Selected interval: {{ intervalLabel }} + + + + + + Mock Refresh Controls + + Trigger Mock Refresh + + + + + + Wakeup Ping Simulator + + Simulate WAKEUP_PING + + + + + + Flood Test + + Run 20 Refreshes + + + + + + + Pending Notification Inspector + + Refresh + + + + + (none) + + + + {{ p.identifier }} + + {{ + p.nextTriggerDate ? new Date(p.nextTriggerDate).toISOString() : "" + }} + + + + + + + + Clear Notifications + + Clear Notifications + + + + + + + Event Log + + Clear Log + + + + (empty) + + {{ line }} + + + + + + + diff --git a/src/plugins/NotificationInspectorPlugin.ts b/src/plugins/NotificationInspectorPlugin.ts new file mode 100644 index 00000000..02ed9551 --- /dev/null +++ b/src/plugins/NotificationInspectorPlugin.ts @@ -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("NotificationInspector"); diff --git a/src/router/index.ts b/src/router/index.ts index 2c039010..367bb7e3 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -290,6 +290,19 @@ const routes: Array = [ 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) + : []), // Catch-all route for 404 errors - must be last { path: "/:pathMatch(.*)*", diff --git a/src/services/notifications/NativeNotificationService.ts b/src/services/notifications/NativeNotificationService.ts index a1694b59..092cd8b5 100644 --- a/src/services/notifications/NativeNotificationService.ts +++ b/src/services/notifications/NativeNotificationService.ts @@ -573,62 +573,85 @@ export async function refreshNotifications(): Promise { } 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; - scheduleNotifications?: (options: { - timestamps: number[]; - }) => Promise; - cancelAllNotifications?: () => Promise; - }; - - 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 { + 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; + scheduleNotifications?: (options: { + timestamps: number[]; + }) => Promise; + cancelAllNotifications?: () => Promise; + }; + + 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. */ diff --git a/src/services/notifications/NotificationDebugService.ts b/src/services/notifications/NotificationDebugService.ts new file mode 100644 index 00000000..d3278808 --- /dev/null +++ b/src/services/notifications/NotificationDebugService.ts @@ -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 = 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 { + 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 { + 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 { + 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 { + append("Clearing notifications"); + + if (!Capacitor.isNativePlatform()) { + append("Skipped: not running on native platform"); + return; + } + + const plugin = DailyNotification as unknown as { + clearAllNotifications?: () => Promise; + cancelAllNotifications?: () => Promise; + }; + + 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 { + 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 []; + } + }, +}; diff --git a/src/views/AccountViewView.vue b/src/views/AccountViewView.vue index 2f510c4a..36999371 100644 --- a/src/views/AccountViewView.vue +++ b/src/views/AccountViewView.vue @@ -742,6 +742,13 @@ > Logs + + Notification Debug Panel + + + + Notification Debug + + Back to Account + + + + + This view is only available in development builds. + + + + + + +
{{ p.identifier }}