Files
crowd-funder-for-time-pwa/src/services/notifications/NativeNotificationService.ts
Jose Olarte III 693bfacc1e feat(notifications): include refresh source in completion and failure logs
Thread options.source through logRefreshSuccess and logRefreshFailure so
WAKEUP_PING and debug-panel refreshes are grep-friendly end-to-end in
Logcat and the Event Log without changing refresh behavior.
2026-06-12 17:12:21 +08:00

758 lines
25 KiB
TypeScript

/**
* Native Notification Service
*
* Implementation of notification service using the DailyNotificationPlugin
* for native iOS and Android platforms. Provides full native notification
* capabilities including background delivery, exact alarm scheduling, and
* offline operation.
*
* @author Matthew Raymer
* @version 1.0.0
* @since 2026-01-21
*/
import { Capacitor } from "@capacitor/core";
import type { PushNotificationSchema } from "@capacitor/push-notifications";
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
import { getOrCreateDeviceId } from "./deviceId";
import { REMINDER_ID_DAILY_REMINDER } from "./reminderIds";
import { configureNativeFetcherIfReady } from "./nativeFetcherConfig";
import {
getNotificationApiBaseUrl,
getTestMode,
} from "./NotificationDebugConfig";
import {
logRefreshFailure,
logRefreshStarted,
logRefreshSuccess,
logScheduleReplacement,
} from "./notificationLog";
import {
getNotificationApiHeaders,
httpAuthErrorMessage,
logSkippingRefreshDueToMissingAuth,
} from "./notificationApiAuth";
import { logNotification } from "./NotificationDebugEvents";
/**
* Extended type for DailyNotification that includes the actual Swift implementation
* signature for cancelDailyReminder (which expects an object, not a string)
*/
interface DailyNotificationWithObjectCancel {
cancelDailyReminder(options: { reminderId: string }): Promise<void>;
}
import type {
NotificationServiceInterface,
DailyNotificationOptions,
NotificationStatus,
PermissionStatus,
} from "./NotificationService";
import { logger } from "@/utils/logger";
/**
* Native notification implementation using DailyNotificationPlugin
* Used for iOS and Android builds
*
* Features:
* - Full background notification support
* - Survives app restarts and device reboots
* - Exact alarm scheduling (Android 12+)
* - No network dependency for delivery
* - Native OS notification UI
*/
export class NativeNotificationService implements NotificationServiceInterface {
private readonly platformName = "native";
/**
* Stable schedule/reminder ID for the Daily Reminder feature only.
* New Activity uses the dual schedule (scheduleDualNotification) and does not use this ID.
*/
private readonly reminderId = REMINDER_ID_DAILY_REMINDER;
/**
* Ensures only one scheduleDailyNotification runs at a time (no rapid successive plugin calls).
* Each new call waits for the previous to complete before running.
*/
private scheduleLock: Promise<boolean> = Promise.resolve(true);
/**
* Native notifications are always supported on iOS/Android
*/
isSupported(): boolean {
return true;
}
/**
* Request notification permissions from the OS
* Shows native permission dialog on first call
*/
/**
* Request notification permissions from the OS
* Shows native permission dialog on first call
*/
async requestPermissions(): Promise<boolean> {
try {
logger.debug("[NativeNotificationService] Requesting permissions");
// Check if plugin is available
if (!DailyNotification) {
logger.error(
"[NativeNotificationService] DailyNotification plugin is not available. " +
"Make sure the plugin is registered in MainActivity.",
);
return false;
}
// Check if the method exists
if (
typeof DailyNotification.requestNotificationPermissions !== "function"
) {
logger.error(
"[NativeNotificationService] requestNotificationPermissions method not found on plugin. " +
"Available methods: " +
Object.keys(DailyNotification).join(", "),
);
return false;
}
logger.debug(
"[NativeNotificationService] Calling requestNotificationPermissions...",
);
// Use requestNotificationPermissions() which is the method exposed by iOS
// For Android, this should also work via the plugin bridge
const result = await DailyNotification.requestNotificationPermissions();
logger.debug(
"[NativeNotificationService] Permission result:",
JSON.stringify(result, null, 2),
);
logger.debug(
"[NativeNotificationService] Result type:",
typeof result,
"Keys:",
result ? Object.keys(result) : "null",
);
// The result is PermissionStatus which has:
// - granted?: boolean
// - notifications: PermissionState ("granted" | "denied" | "prompt")
// Handle all possible formats for compatibility
if (
result &&
"granted" in result &&
typeof result.granted === "boolean"
) {
logger.debug(
"[NativeNotificationService] Using 'granted' field:",
result.granted,
);
return result.granted;
}
// Check the notifications PermissionState field
if (result && "notifications" in result) {
const notificationsState = result.notifications;
logger.debug(
"[NativeNotificationService] Notifications state:",
notificationsState,
);
// PermissionState can be "granted", "denied", or "prompt"
if (notificationsState === "granted") {
return true;
}
if (notificationsState === "denied") {
return false;
}
// If "prompt", the user hasn't decided yet, so return false
if (notificationsState === "prompt") {
logger.warn(
"[NativeNotificationService] Permission still in prompt state after request",
);
return false;
}
}
// Check for PermissionStatusResult format (from checkPermissionStatus)
if (
result &&
"allPermissionsGranted" in result &&
typeof result.allPermissionsGranted === "boolean"
) {
logger.debug(
"[NativeNotificationService] Using 'allPermissionsGranted' field:",
result.allPermissionsGranted,
);
return result.allPermissionsGranted;
}
// If result is a boolean directly (some plugin versions might return this)
if (typeof result === "boolean") {
logger.debug("[NativeNotificationService] Result is boolean:", result);
return result;
}
// Fallback: check status after requesting
logger.debug(
"[NativeNotificationService] Falling back to checkPermissionStatus...",
);
const status = await DailyNotification.checkPermissionStatus();
logger.debug(
"[NativeNotificationService] Status check result:",
JSON.stringify(status, null, 2),
);
return status.allPermissionsGranted;
} catch (error) {
logger.error(
"[NativeNotificationService] Permission request failed:",
error,
);
// Log additional error details for debugging
if (error instanceof Error) {
logger.error("[NativeNotificationService] Error details:", {
message: error.message,
stack: error.stack,
name: error.name,
});
} else {
logger.error(
"[NativeNotificationService] Non-Error exception:",
JSON.stringify(error, null, 2),
);
}
return false;
}
}
/**
* Check current permission status without prompting
*/
async checkPermissions(): Promise<PermissionStatus> {
try {
const status = await DailyNotification.checkPermissionStatus();
// Calculate granted status from individual permissions
const allGranted =
status.allPermissionsGranted ||
(status.notificationsEnabled && status.exactAlarmEnabled);
return {
granted: allGranted,
details: {
notifications: status.notificationsEnabled,
exactAlarm: status.exactAlarmEnabled,
backgroundRefresh: true, // Native always has background capability
},
};
} catch (error) {
logger.error(
"[NativeNotificationService] Permission check failed:",
error,
);
return {
granted: false,
details: {
notifications: false,
exactAlarm: false,
backgroundRefresh: false,
},
};
}
}
/**
* Schedule a daily notification using native alarms.
* Serialized so only one schedule runs at a time (avoids rapid successive plugin calls on Android).
*/
async scheduleDailyNotification(
options: DailyNotificationOptions,
): Promise<boolean> {
const run = (): Promise<boolean> =>
this.doScheduleDailyNotification(options);
this.scheduleLock = this.scheduleLock.then(() => run());
return this.scheduleLock;
}
/**
* Internal implementation of schedule; called under scheduleLock.
*/
private async doScheduleDailyNotification(
options: DailyNotificationOptions,
): Promise<boolean> {
try {
logger.info(
"[NativeNotificationService] Scheduling daily notification:",
{
time: options.time,
title: options.title,
body: options.body,
},
);
// Note: The notification channel should be created automatically by the plugin
// when the notification is delivered. The "Channel does not exist" warning
// during permission checks is expected and not a problem - the channel will
// be created when the receiver tries to show the notification.
// Check permissions before scheduling to ensure everything is set up correctly
logger.debug(
"[NativeNotificationService] Checking permissions before scheduling",
);
const permissionStatus = await this.checkPermissions();
logger.info(
"[NativeNotificationService] Permission status:",
JSON.stringify(permissionStatus, null, 2),
);
// Check if permissions are actually granted (all details must be true)
const allPermissionsGranted =
permissionStatus.details?.notifications &&
permissionStatus.details?.exactAlarm &&
permissionStatus.details?.backgroundRefresh;
if (!allPermissionsGranted) {
logger.warn(
"[NativeNotificationService] Permissions not fully granted. Details:",
permissionStatus.details,
);
// Continue anyway - the plugin might handle permission requests internally
// but log a warning for debugging
} else {
logger.debug(
"[NativeNotificationService] All permissions granted:",
permissionStatus.details,
);
}
// On iOS only: cancel existing reminder before rescheduling (removes from notification center).
// On Android we skip pre-cancel to match the test app; the plugin cancels the previous alarm
// for this scheduleId inside scheduleDailyNotification before scheduling the new one.
if (Capacitor.getPlatform() === "ios") {
try {
logger.debug(
"[NativeNotificationService] Canceling existing notification before rescheduling",
);
await (
DailyNotification as unknown as DailyNotificationWithObjectCancel
).cancelDailyReminder({
reminderId: this.reminderId,
});
} catch (cancelError) {
logger.debug(
"[NativeNotificationService] No existing notification to cancel (or cancel failed):",
cancelError,
);
}
}
// Log current time and scheduled time for debugging
const now = new Date();
const [hours, minutes] = options.time.split(":").map(Number);
const scheduledTime = new Date();
scheduledTime.setHours(hours, minutes, 0, 0);
// If scheduled time is in the past, it should be scheduled for tomorrow
if (scheduledTime < now) {
scheduledTime.setDate(scheduledTime.getDate() + 1);
logger.info(
"[NativeNotificationService] Scheduled time is in the past, will schedule for tomorrow:",
{
requestedTime: options.time,
currentTime: now.toISOString(),
scheduledFor: scheduledTime.toISOString(),
},
);
} else {
logger.info("[NativeNotificationService] Scheduling notification:", {
requestedTime: options.time,
currentTime: now.toISOString(),
scheduledFor: scheduledTime.toISOString(),
minutesUntilNotification: Math.round(
(scheduledTime.getTime() - now.getTime()) / 1000 / 60,
),
});
}
const scheduleOptions: {
time: string;
title: string;
body: string;
sound: boolean;
priority: "low" | "default" | "high";
id: string;
rolloverIntervalMinutes?: number;
} = {
time: options.time,
title: options.title,
body: options.body,
sound: true,
priority: (options.priority || "normal") as "low" | "default" | "high",
id: this.reminderId,
...(options.rolloverIntervalMinutes != null &&
options.rolloverIntervalMinutes > 0
? { rolloverIntervalMinutes: options.rolloverIntervalMinutes }
: {}),
};
logger.debug(
"[NativeNotificationService] Calling scheduleDailyNotification with options:",
scheduleOptions,
);
await DailyNotification.scheduleDailyNotification(scheduleOptions);
logger.info(
"[NativeNotificationService] scheduleDailyNotification call completed successfully",
{
requestedTime: options.time,
},
);
// Verify the notification was actually scheduled (if method is available)
// Note: getScheduledReminders() is not implemented on Android, so we
// only verify on iOS. On Android, we assume success if no error was thrown.
try {
logger.debug(
"[NativeNotificationService] Attempting to verify notification was scheduled",
);
const remindersResult = await DailyNotification.getScheduledReminders();
// Handle both array and object with reminders property
const reminders = Array.isArray(remindersResult)
? remindersResult
: (remindersResult as { reminders: typeof remindersResult })
.reminders || [];
const scheduledReminder = reminders.find(
(r) => r.id === this.reminderId,
);
if (scheduledReminder && scheduledReminder.isScheduled) {
// Verify the time matches what we scheduled
if (scheduledReminder.time !== options.time) {
logger.error(
"[NativeNotificationService] Notification time mismatch!",
{
scheduled: scheduledReminder.time,
requested: options.time,
reminderId: this.reminderId,
},
);
return false;
}
logger.info(
"[NativeNotificationService] Daily notification verified as scheduled:",
{
id: scheduledReminder.id,
time: scheduledReminder.time,
requestedTime: options.time,
nextTriggerTime: scheduledReminder.nextTriggerTime,
},
);
return true;
} else {
logger.warn(
"[NativeNotificationService] Notification was not found in scheduled reminders after scheduling",
{
reminderId: this.reminderId,
requestedTime: options.time,
allReminders: reminders.map((r) => ({
id: r.id,
time: r.time,
isScheduled: r.isScheduled,
})),
},
);
// Schedule call succeeded; verification may fail if plugin returns stale data (e.g. old id).
// Return true so we don't show "Error Setting Notification Permissions"; getStatus will reflect once plugin state updates.
return true;
}
} catch (verifyError) {
// If getScheduledReminders() is not implemented (Android), assume success
// since scheduleDailyReminder() completed without error
if (
verifyError instanceof Error &&
verifyError.message.includes("not implemented")
) {
logger.debug(
"[NativeNotificationService] getScheduledReminders() not available on this platform (expected on Android). " +
"Assuming success since scheduleDailyNotification() completed without error.",
);
return true;
}
// For other errors, log but still assume success since the schedule call succeeded
logger.warn(
"[NativeNotificationService] Verification failed, but schedule call succeeded:",
verifyError,
);
return true;
}
} catch (error) {
logger.error("[NativeNotificationService] Schedule failed:", error);
return false;
}
}
/**
* Cancel the daily notification
*/
async cancelDailyNotification(): Promise<void> {
try {
logger.info("[NativeNotificationService] Cancelling daily notification");
// The Swift plugin expects an object with reminderId property
// Even though TypeScript definition says string, we need to pass an object
await (
DailyNotification as unknown as DailyNotificationWithObjectCancel
).cancelDailyReminder({
reminderId: this.reminderId,
});
logger.info(
"[NativeNotificationService] Daily notification cancelled successfully",
);
} catch (error) {
logger.error("[NativeNotificationService] Cancel failed:", error);
// Don't throw - cancellation failures are non-critical
}
}
/**
* Get current notification status from the plugin
*/
async getStatus(): Promise<NotificationStatus> {
try {
const remindersResult = await DailyNotification.getScheduledReminders();
// Handle both array and object with reminders property
const reminders = Array.isArray(remindersResult)
? remindersResult
: (remindersResult as { reminders: typeof remindersResult })
.reminders || [];
const reminder = reminders.find((r) => r.id === this.reminderId);
if (reminder) {
logger.debug("[NativeNotificationService] Found active reminder:", {
time: reminder.time,
isScheduled: reminder.isScheduled,
});
return {
enabled: reminder.isScheduled,
scheduledTime: reminder.time,
message: reminder.body,
notificationType: "native",
};
}
logger.debug("[NativeNotificationService] No active reminder found");
return {
enabled: false,
notificationType: "native",
};
} catch (error) {
logger.error("[NativeNotificationService] Get status failed:", error);
return {
enabled: false,
notificationType: "native",
};
}
}
/**
* Get platform identifier
*/
getPlatformName(): string {
return this.platformName;
}
}
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 refreshNotificationsWithDiagnostics(options?: {
source?: string;
}): Promise<RefreshNotificationsResult> {
const startedAt = performance.now();
const source = options?.source;
logRefreshStarted(source);
if (!Capacitor.isNativePlatform()) {
const errorMessage = "not a native platform";
logRefreshFailure(startedAt, errorMessage, undefined, source);
return {
ok: false,
scheduledCount: 0,
errorMessage,
};
}
try {
const auth = await getNotificationApiHeaders("refresh");
if (!auth.ok) {
logSkippingRefreshDueToMissingAuth();
logRefreshFailure(startedAt, auth.message, undefined, source);
return {
ok: false,
scheduledCount: 0,
errorMessage: auth.message,
};
}
let deviceId: string | undefined;
try {
deviceId = await getOrCreateDeviceId();
} catch (err) {
logger.warn(
"[NativeNotificationService] Could not obtain deviceId; refresh proceeding without deviceId",
err,
);
}
const baseUrl = getNotificationApiBaseUrl();
const res = await fetch(`${baseUrl}/notifications/refresh`, {
method: "POST",
headers: auth.headers,
body: JSON.stringify({
deviceId,
platform: Capacitor.getPlatform(),
testMode: getTestMode(),
}),
});
if (!res.ok) {
const errorMessage =
res.status === 401 || res.status === 403
? httpAuthErrorMessage(res.status)
: res.statusText || `HTTP ${res.status}`;
logger.warn("[NativeNotificationService] refreshNotifications failed", {
status: res.status,
statusText: res.statusText,
errorMessage,
});
logRefreshFailure(startedAt, errorMessage, res.status, source);
return {
ok: false,
scheduledCount: 0,
status: res.status,
errorMessage,
};
}
const data: unknown = await res.json();
const payload = data as NotificationRefreshPayload;
const scheduledCount = Array.isArray(payload?.nextNotifications)
? payload.nextNotifications.length
: 0;
await applyNotificationRefreshPayload(data);
logRefreshSuccess(startedAt, scheduledCount, source);
return { ok: true, scheduledCount };
} catch (err) {
logger.error("[NativeNotificationService] Refresh failed", err);
const message = err instanceof Error ? err.message : String(err);
logRefreshFailure(startedAt, message, undefined, source);
return { ok: false, scheduledCount: 0, errorMessage: message };
}
}
export async function refreshNotifications(): Promise<void> {
await refreshNotificationsWithDiagnostics();
}
export type NotificationRefreshPayload = {
shouldNotify?: boolean;
nextNotifications?: Array<{ timestamp?: number }>;
};
// `handleCapacitorPushNotificationReceived` and `applyNotificationRefreshPayload` are used by
// DEV notification simulation tooling; they must stay production-safe because that tooling
// exercises real flows. (`applyNotificationRefreshPayload` is also used by production refresh.)
/**
* 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) {
logNotification("Schedule replacement skipped (no valid timestamps)");
return;
}
// Keep existing behavior: ensure background worker credentials are current.
await configureNativeFetcherIfReady();
logScheduleReplacement(timestamps.length);
if (typeof DailyNotification.clearApiNotifications !== "function") {
logger.warn(
"[NativeNotificationService] API notification clear unavailable (plugin clearApiNotifications missing); cannot replace schedule",
);
logNotification(
"Schedule replacement aborted (API notification clear unavailable on plugin)",
);
return;
}
logNotification("Clearing API notifications before refresh");
await DailyNotification.clearApiNotifications();
logNotification("Cleared API notifications");
if (typeof DailyNotification.scheduleApiNotifications !== "function") {
logger.warn(
"[NativeNotificationService] scheduleApiNotifications not available on plugin; cannot apply timestamps",
);
logNotification(
"Schedule replacement aborted (scheduleApiNotifications unavailable)",
);
return;
}
await DailyNotification.scheduleApiNotifications({ timestamps });
logNotification(
`Schedule replacement applied (${timestamps.length} timestamp(s))`,
);
}
/**
* Silent FCM/APNs data push: refresh native notification pipeline when requested by backend.
*/
export async function handleCapacitorPushNotificationReceived(
notification: PushNotificationSchema,
): Promise<void> {
if (notification.data?.type === "WAKEUP_PING") {
logNotification("WAKEUP_PING handler — invoking refresh");
await refreshNotificationsWithDiagnostics({ source: "WAKEUP_PING" });
return;
}
const type =
typeof notification.data?.type === "string"
? notification.data.type
: "(none)";
logNotification(`push handler ignored type=${type}`);
}