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.
758 lines
25 KiB
TypeScript
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}`);
|
|
}
|