WIP: new-activity notification #231

Draft
trentlarson wants to merge 80 commits from notify-api into master
4 changed files with 377 additions and 6 deletions
Showing only changes of commit a4453c0b1b - Show all commits

View File

@@ -1,5 +1,114 @@
<template>
<section class="bg-slate-100 rounded-md overflow-hidden px-4 py-4">
<!-- Backend testing -->
<div class="mb-6">
<h2 class="mb-2 font-bold">Backend Testing</h2>
<label class="block text-sm font-medium text-slate-700 mb-1">
Notification Backend URL
</label>
<input
v-model="backendUrlDraft"
type="url"
class="w-full text-sm px-3 py-2 rounded border border-slate-300 bg-white mb-1"
placeholder="Leave empty for default (APP_SERVER)"
:disabled="busy"
@keydown.enter="onSaveBackendUrl"
/>
<p class="text-xs text-slate-500 mb-2">
Active:
<code class="text-[11px] break-all">{{ activeBackendUrl }}</code>
</p>
<button
class="w-full text-sm mb-4 px-3 py-2 rounded border border-slate-300 bg-white"
:disabled="busy"
:class="{ 'opacity-50 cursor-not-allowed': busy }"
@click="onSaveBackendUrl"
>
Save Backend URL
</button>
<label class="flex items-center gap-2 text-sm mb-4 cursor-pointer">
<input
v-model="testModeEnabled"
type="checkbox"
class="rounded border-slate-300"
:disabled="busy"
@change="onTestModeChange"
/>
<span>Test Mode</span>
</label>
<div class="flex flex-col gap-2 mb-4">
<button
class="w-full text-md bg-gradient-to-b from-emerald-400 to-emerald-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="onRegisterToken"
>
Register Token Now
</button>
<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="onBackendRefresh"
>
Refresh Notifications
</button>
<button
class="w-full text-md bg-gradient-to-b from-violet-400 to-violet-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="onSimulateWakeupRefresh"
>
Simulate WAKEUP_PING
</button>
<p class="text-xs text-slate-500">
Local simulation only calls the refresh API directly (no FCM push).
</p>
</div>
<div class="mb-4">
<h3 class="text-sm font-bold mb-1">Current FCM Token</h3>
<div
v-if="fcmToken"
class="bg-white rounded border border-slate-200 px-3 py-2 text-xs font-mono break-all flex gap-2 items-start"
>
<span class="min-w-0 flex-1">{{ truncatedFcmToken }}</span>
<button
type="button"
class="shrink-0 text-sm px-2 py-1 rounded border border-slate-300 bg-slate-50"
@click="onCopyFcmToken"
>
Copy
</button>
</div>
<p
v-else
class="text-sm text-slate-500 bg-white rounded px-3 py-2 border border-slate-200"
>
(not available try Register Token Now on native)
</p>
</div>
<div class="mb-2">
<h3 class="text-sm font-bold mb-1">Backend Status</h3>
<dl
class="bg-white rounded border border-slate-200 px-3 py-2 text-xs space-y-1"
>
<div class="flex gap-2">
<dt class="text-slate-500 shrink-0">URL</dt>
<dd class="break-all font-mono">{{ activeBackendUrl }}</dd>
</div>
<div class="flex gap-2">
<dt class="text-slate-500 shrink-0">testMode</dt>
<dd>{{ testModeEnabled ? "true" : "false" }}</dd>
</div>
</dl>
</div>
</div>
<!-- SECTION F: Mock Timing Presets -->
<div class="mb-6">
<h2 class="mb-2 font-bold">Mock Timing Presets</h2>
@@ -37,6 +146,10 @@
<!-- SECTION B: Wakeup Ping Simulator -->
<div class="mb-6">
<h2 class="mb-2 font-bold">Wakeup Ping Simulator</h2>
<p class="text-xs text-slate-500 mb-2">
Exercises the production push handler (not the refresh API shortcut
above).
</p>
<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"
@@ -165,6 +278,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { copyToClipboard } from "@/services/ClipboardService";
import { NotificationDebugService } from "@/services/notifications/NotificationDebugService";
type PendingInfo = {
@@ -186,6 +300,24 @@ const intervalMs = ref<number>(60_000);
const busy = ref(false);
const pending = ref<PendingInfo[]>([]);
const pendingInspectorMessage = ref<string | null>(null);
const backendUrlDraft = ref("");
const testModeEnabled = ref(NotificationDebugService.isTestModeEnabled());
const fcmToken = ref<string | null>(NotificationDebugService.getFcmToken());
const activeBackendUrl = computed(() =>
NotificationDebugService.getActiveBackendUrl(),
);
const truncatedFcmToken = computed(() => {
const t = fcmToken.value?.trim() ?? "";
if (!t) {
return "";
}
if (t.length <= 24) {
return t;
}
return `${t.slice(0, 12)}${t.slice(-8)}`;
});
const eventLog = computed(() => NotificationDebugService.eventLog.value);
@@ -245,7 +377,58 @@ async function onClearNotifications(): Promise<void> {
});
}
function syncBackendState(): void {
backendUrlDraft.value =
NotificationDebugService.getBackendUrlOverride() ?? "";
testModeEnabled.value = NotificationDebugService.isTestModeEnabled();
fcmToken.value = NotificationDebugService.getFcmToken();
}
function onSaveBackendUrl(): void {
NotificationDebugService.saveBackendBaseUrl(backendUrlDraft.value);
syncBackendState();
}
function onTestModeChange(): void {
NotificationDebugService.setTestModeEnabled(testModeEnabled.value);
}
async function onRegisterToken(): Promise<void> {
await withBusy(async () => {
try {
await NotificationDebugService.registerTokenNow();
} catch {
// logged in panel
} finally {
fcmToken.value = NotificationDebugService.getFcmToken();
}
});
}
async function onBackendRefresh(): Promise<void> {
await withBusy(async () => {
await NotificationDebugService.triggerBackendRefresh();
await refreshPending();
});
}
async function onSimulateWakeupRefresh(): Promise<void> {
await withBusy(async () => {
await NotificationDebugService.simulateWakeupViaRefresh();
await refreshPending();
});
}
async function onCopyFcmToken(): Promise<void> {
const token = fcmToken.value?.trim();
if (!token) {
return;
}
await copyToClipboard(token);
}
onMounted(() => {
syncBackendState();
void refreshPending();
});
</script>

View File

@@ -549,13 +549,24 @@ export class NativeNotificationService implements NotificationServiceInterface {
}
}
export type RefreshNotificationsResult = {
ok: boolean;
scheduledCount: number;
status?: number;
errorMessage?: string;
};
/**
* Re-applies native API fetcher credentials (JWT pool, active DID) so background
* notification workers can run. No UI; safe from push handlers while backgrounded.
*/
export async function refreshNotifications(): Promise<void> {
export async function refreshNotificationsWithDiagnostics(): Promise<RefreshNotificationsResult> {
if (!Capacitor.isNativePlatform()) {
return;
return {
ok: false,
scheduledCount: 0,
errorMessage: "not a native platform",
};
}
try {
@@ -574,16 +585,32 @@ export async function refreshNotifications(): Promise<void> {
status: res.status,
statusText: res.statusText,
});
return;
return {
ok: false,
scheduledCount: 0,
status: res.status,
errorMessage: res.statusText || `HTTP ${res.status}`,
};
}
const data: unknown = await res.json();
const payload = data as NotificationRefreshPayload;
const scheduledCount = Array.isArray(payload?.nextNotifications)
? payload.nextNotifications.length
: 0;
await applyNotificationRefreshPayload(data);
return { ok: true, scheduledCount };
} catch (err) {
logger.error("[NativeNotificationService] Refresh failed", err);
const message = err instanceof Error ? err.message : String(err);
return { ok: false, scheduledCount: 0, errorMessage: message };
}
}
export async function refreshNotifications(): Promise<void> {
await refreshNotificationsWithDiagnostics();
}
export type NotificationRefreshPayload = {
shouldNotify?: boolean;
nextNotifications?: Array<{ timestamp?: number }>;

View File

@@ -11,9 +11,21 @@ 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 {
getBackendBaseUrl,
getNotificationApiBaseUrl,
getTestMode,
setBackendBaseUrl,
setTestMode,
} from "./NotificationDebugConfig";
import {
getLastKnownFcmToken,
reregisterFcmTokenNow,
} from "./firebaseMessagingClient";
import {
applyNotificationRefreshPayload,
handleCapacitorPushNotificationReceived,
refreshNotificationsWithDiagnostics,
type NotificationRefreshPayload,
} from "./NativeNotificationService";
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
@@ -55,11 +67,21 @@ function logLine(message: string): string {
return `[${formatTime(new Date())}] ${message}`;
}
function truncateToken(token: string): string {
const t = token.trim();
if (t.length <= 24) {
return t;
}
return `${t.slice(0, 12)}${t.slice(-8)}`;
}
const _eventLog: Ref<string[]> = ref([]);
const EVENT_LOG_MAX = 100;
function append(message: string): void {
const line = logLine(message);
_eventLog.value = [..._eventLog.value, line].slice(-250);
_eventLog.value = [..._eventLog.value, line].slice(-EVENT_LOG_MAX);
logger.debug(`${LOG} ${message}`);
}
@@ -70,6 +92,67 @@ export const NotificationDebugService = {
_eventLog.value = [];
},
getActiveBackendUrl(): string {
return getNotificationApiBaseUrl();
},
getBackendUrlOverride(): string | null {
return getBackendBaseUrl();
},
saveBackendBaseUrl(url: string): void {
setBackendBaseUrl(url);
append(
url.trim()
? `Backend URL saved (${getNotificationApiBaseUrl()})`
: "Backend URL cleared (using default)",
);
},
setTestModeEnabled(enabled: boolean): void {
setTestMode(enabled);
append(`Test mode ${enabled ? "enabled" : "disabled"}`);
},
isTestModeEnabled(): boolean {
return getTestMode();
},
getFcmToken(): string | null {
return getLastKnownFcmToken();
},
async registerTokenNow(): Promise<void> {
append("Register token started");
try {
const token = await reregisterFcmTokenNow();
append(`Register token success (${truncateToken(token)})`);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
append(`Register token failure: ${msg}`);
throw e;
}
},
async triggerBackendRefresh(): Promise<void> {
append("Refresh started");
const result = await refreshNotificationsWithDiagnostics();
if (result.ok) {
append(`Refresh success`);
append(`Scheduled ${result.scheduledCount} notifications`);
} else {
const detail = result.errorMessage ?? "unknown error";
const status = result.status != null ? ` (HTTP ${result.status})` : "";
append(`Refresh failure: ${detail}${status}`);
}
},
/** Local simulation: same API call as a WAKEUP_PING handler (no push payload). */
async simulateWakeupViaRefresh(): Promise<void> {
append("WAKEUP_PING simulation started (local refresh API only)");
await this.triggerBackendRefresh();
},
generateMockNotifications(
intervalMs: number = 60_000,
): NotificationRefreshPayload {

View File

@@ -30,19 +30,94 @@ let firebaseAppSingleton: FirebaseApp | null = null;
let nativeInitPromise: Promise<void> | null = null;
/** Avoid duplicate POSTs when the same token is delivered more than once. */
let lastRegisteredFcmToken: string | null = null;
/** Last token received from Capacitor/Firebase (may match registered). */
let lastSeenFcmToken: string | null = null;
async function registerRetrievedToken(token: string): Promise<void> {
async function registerRetrievedToken(
token: string,
options?: { force?: boolean },
): Promise<void> {
const trimmed = token.trim();
if (!trimmed) {
return;
}
if (trimmed === lastRegisteredFcmToken) {
lastSeenFcmToken = trimmed;
if (!options?.force && trimmed === lastRegisteredFcmToken) {
return;
}
await registerToken(trimmed);
lastRegisteredFcmToken = trimmed;
}
/** Most recent FCM token from native/web push registration (for debug UI). */
export function getLastKnownFcmToken(): string | null {
return lastSeenFcmToken ?? lastRegisteredFcmToken;
}
/**
* Re-runs token registration immediately (debug). Bypasses duplicate-token skip.
*/
export async function reregisterFcmTokenNow(): Promise<string> {
if (!Capacitor.isNativePlatform()) {
throw new Error("FCM registration is only available on native platforms");
}
lastRegisteredFcmToken = null;
const cached = lastSeenFcmToken?.trim();
if (cached) {
await registerRetrievedToken(cached, { force: true });
return cached;
}
const app = ensureFirebaseApp();
if (app && (await isSupported())) {
const messaging = getMessaging(app);
const vapidKey = import.meta.env.VITE_FIREBASE_VAPID_KEY as
| string
| undefined;
const token = await getToken(
messaging,
vapidKey ? { vapidKey } : undefined,
);
if (!token?.trim()) {
throw new Error("Firebase getToken returned an empty token");
}
await registerRetrievedToken(token, { force: true });
return token.trim();
}
return new Promise<string>((resolve, reject) => {
const timeoutMs = 15_000;
const timeoutId = window.setTimeout(() => {
void listenerPromise.then((h) => h.remove());
reject(new Error("Timed out waiting for push registration token"));
}, timeoutMs);
const listenerPromise = PushNotifications.addListener(
"registration",
(token) => {
window.clearTimeout(timeoutId);
void listenerPromise.then((h) => h.remove());
const value = token.value?.trim() ?? "";
if (!value) {
reject(new Error("Capacitor registration returned an empty token"));
return;
}
void registerRetrievedToken(value, { force: true })
.then(() => resolve(value))
.catch(reject);
},
);
void PushNotifications.register().catch((err) => {
window.clearTimeout(timeoutId);
void listenerPromise.then((h) => h.remove());
reject(err);
});
});
}
function readFirebaseOptions(): FirebaseOptions | null {
const env = import.meta.env;
const apiKey = env.VITE_FIREBASE_API_KEY as string | undefined;
@@ -149,6 +224,9 @@ async function initializeNativePushAndFirebaseMessagingImpl(): Promise<void> {
const app = ensureFirebaseApp();
await PushNotifications.addListener("registration", (token) => {
if (token.value?.trim()) {
lastSeenFcmToken = token.value.trim();
}
logger.info(`${LOG} Capacitor registration token`, {
valuePrefix: token.value ? `${token.value.slice(0, 12)}` : "(empty)",
});