33 KiB
DailyNotification Plugin - Stack Improvement Plan
Author: Matthew Raymer
Date: October 20, 2025
Version: 1.0.0
Status: Implementation Roadmap
Executive Summary
This document outlines high-impact improvements for the DailyNotification plugin based on comprehensive analysis of current capabilities, strengths, and areas for enhancement. The improvements focus on reliability, user experience, and production readiness.
Current State Analysis
✅ What's Working Well
Core Capabilities
- Daily & reminder scheduling: Plugin API supports simple daily notifications, advanced reminders (IDs, repeat, vibration, priority, timezone), and combined flows (content fetch + user-facing notification)
- API-driven change alerts: Planned
TimeSafariApiService
with DID/JWT authentication, configurable polling, history, and preferences - WorkManager offload on Android: Heavy lifting in
DailyNotificationWorker
with JIT freshness checks, soft prefetch, DST-aware rescheduling, duplicate suppression, and action buttons
Architectural Strengths
- Background resilience: WorkManager provides battery/network constraints and retry semantics
- Freshness strategy: Pragmatic "borderline" soft refetch and "stale" hard refresh approach
- DST safety & dedupe: Next-day scheduling with DST awareness and duplicate prevention
- End-to-end planning: Comprehensive UX, store integration, testing, and deployment coverage
High-Impact Improvements
🔧 Android / Native Side Improvements
1. Exact-Time Reliability (Doze & Android 12+)
-
Priority: Critical
-
Impact: Notification delivery accuracy
-
Current Issue: Notifications may not fire at exact times due to Doze mode and Android 12+ restrictions
-
Implementation:
// In DailyNotificationScheduler.java
public void scheduleExactNotification(NotificationContent content) {
AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
// Use setExactAndAllowWhileIdle for precise timing
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
content.getScheduledTime(),
createNotificationPendingIntent(content)
);
} else {
alarmManager.setExact(AlarmManager.RTC_WAKEUP, content.getScheduledTime(), createNotificationPendingIntent(content));
}
}
// Add exact alarm permission request
public void requestExactAlarmPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
if (!alarmManager.canScheduleExactAlarms()) {
Intent intent = new Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(intent);
}
}
}
- Testing: Verify notifications fire within 1 minute of scheduled time across Doze cycles
2. DST-Safe Time Calculation
-
Priority: High
-
Impact: Prevents notification drift across DST boundaries
-
Current Issue:
plusHours(24)
can drift across DST boundaries -
Implementation:
// In DailyNotificationScheduler.java
public long calculateNextScheduledTime(int hour, int minute, String timezone) {
ZoneId zone = ZoneId.of(timezone);
LocalTime targetTime = LocalTime.of(hour, minute);
// Get current time in user's timezone
ZonedDateTime now = ZonedDateTime.now(zone);
LocalDate today = now.toLocalDate();
// Calculate next occurrence at same local time
ZonedDateTime nextScheduled = ZonedDateTime.of(today, targetTime, zone);
// If time has passed today, schedule for tomorrow
if (nextScheduled.isBefore(now)) {
nextScheduled = nextScheduled.plusDays(1);
}
return nextScheduled.toInstant().toEpochMilli();
}
- Testing: Test across DST transitions (spring forward/fall back)
3. Work Deduplication & Idempotence
-
Priority: High
-
Impact: Prevents duplicate notifications and race conditions
-
Current Issue: Repeat enqueues can cause race conditions
-
Implementation:
// In DailyNotificationWorker.java
public static void enqueueDisplayWork(Context context, String notificationId) {
String workName = "display_notification_" + notificationId;
OneTimeWorkRequest displayWork = new OneTimeWorkRequest.Builder(DailyNotificationWorker.class)
.setInputData(createNotificationData(notificationId))
.setConstraints(getNotificationConstraints())
.build();
WorkManager.getInstance(context)
.enqueueUniqueWork(workName, ExistingWorkPolicy.REPLACE, displayWork);
}
public static void enqueueSoftRefetchWork(Context context, String notificationId) {
String workName = "soft_refetch_" + notificationId;
OneTimeWorkRequest refetchWork = new OneTimeWorkRequest.Builder(SoftRefetchWorker.class)
.setInputData(createRefetchData(notificationId))
.setConstraints(getRefetchConstraints())
.build();
WorkManager.getInstance(context)
.enqueueUniqueWork(workName, ExistingWorkPolicy.KEEP, refetchWork);
}
4. Notification Channel Discipline
- Priority: Medium
- Impact: Consistent notification behavior and user control
Implementation:
// New class: NotificationChannelManager.java
public class NotificationChannelManager {
private static final String DAILY_NOTIFICATIONS_CHANNEL = "daily_notifications";
private static final String CHANGE_NOTIFICATIONS_CHANNEL = "change_notifications";
private static final String SYSTEM_NOTIFICATIONS_CHANNEL = "system_notifications";
public static void createChannels(Context context) {
NotificationManager notificationManager =
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
// Daily notifications channel
NotificationChannel dailyChannel = new NotificationChannel(
DAILY_NOTIFICATIONS_CHANNEL,
"Daily Notifications",
NotificationManager.IMPORTANCE_DEFAULT
);
dailyChannel.setDescription("Scheduled daily reminders and updates");
dailyChannel.enableLights(true);
dailyChannel.enableVibration(true);
dailyChannel.setShowBadge(true);
// Change notifications channel
NotificationChannel changeChannel = new NotificationChannel(
CHANGE_NOTIFICATIONS_CHANNEL,
"Change Notifications",
NotificationManager.IMPORTANCE_HIGH
);
changeChannel.setDescription("Notifications about project changes and updates");
changeChannel.enableLights(true);
changeChannel.enableVibration(true);
changeChannel.setShowBadge(true);
notificationManager.createNotificationChannels(Arrays.asList(dailyChannel, changeChannel));
}
public static NotificationCompat.Builder createNotificationBuilder(
Context context,
String channelId,
String title,
String body
) {
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(body)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true);
// Add BigTextStyle for long content
if (body.length() > 100) {
builder.setStyle(new NotificationCompat.BigTextStyle().bigText(body));
}
return builder;
}
}
5. Click Analytics & Deep-Link Safety
- Priority: Medium
- Impact: User engagement tracking and security
Implementation:
// New class: NotificationClickReceiver.java
public class NotificationClickReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
String action = intent.getStringExtra("action");
String notificationId = intent.getStringExtra("notification_id");
String deepLink = intent.getStringExtra("deep_link");
// Record analytics
recordClickAnalytics(notificationId, action);
// Handle deep link safely
if (deepLink != null && isValidDeepLink(deepLink)) {
handleDeepLink(context, deepLink);
} else {
// Fallback to main activity
openMainActivity(context);
}
}
private void recordClickAnalytics(String notificationId, String action) {
// Record click-through rate and user engagement
AnalyticsService.recordEvent("notification_clicked", Map.of(
"notification_id", notificationId,
"action", action,
"timestamp", System.currentTimeMillis()
));
}
private boolean isValidDeepLink(String deepLink) {
// Validate deep link format and domain
return deepLink.startsWith("timesafari://") ||
deepLink.startsWith("https://endorser.ch/");
}
}
6. Storage Hardening
- Priority: High
- Impact: Data integrity and performance
Implementation:
// Migrate to Room database
@Entity(tableName = "notification_content")
public class NotificationContentEntity {
@PrimaryKey
public String id;
public String title;
public String body;
public long scheduledTime;
public String mediaUrl;
public long fetchTime;
public long ttlSeconds;
public boolean encrypted;
@ColumnInfo(name = "created_at")
public long createdAt;
@ColumnInfo(name = "updated_at")
public long updatedAt;
}
@Dao
public interface NotificationContentDao {
@Query("SELECT * FROM notification_content WHERE scheduledTime > :currentTime ORDER BY scheduledTime ASC")
List<NotificationContentEntity> getUpcomingNotifications(long currentTime);
@Query("DELETE FROM notification_content WHERE createdAt < :cutoffTime")
void deleteOldNotifications(long cutoffTime);
@Query("SELECT COUNT(*) FROM notification_content")
int getNotificationCount();
}
// Add encryption for sensitive content
public class NotificationContentEncryption {
private static final String ALGORITHM = "AES/GCM/NoPadding";
public String encrypt(String content, String key) {
// Implementation for encrypting sensitive notification content
}
public String decrypt(String encryptedContent, String key) {
// Implementation for decrypting sensitive notification content
}
}
7. Permission UX (Android 13+)
- Priority: High
- Impact: User experience and notification delivery
Implementation:
// Enhanced permission handling
public class NotificationPermissionManager {
public static boolean hasPostNotificationsPermission(Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
return ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
== PackageManager.PERMISSION_GRANTED;
}
return true; // Permission not required for older versions
}
public static void requestPostNotificationsPermission(Activity activity) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (!hasPostNotificationsPermission(activity)) {
ActivityCompat.requestPermissions(activity,
new String[]{Manifest.permission.POST_NOTIFICATIONS},
REQUEST_POST_NOTIFICATIONS);
}
}
}
public static void showPermissionEducationDialog(Context context) {
new AlertDialog.Builder(context)
.setTitle("Enable Notifications")
.setMessage("To receive daily updates and project change notifications, please enable notifications in your device settings.")
.setPositiveButton("Open Settings", (dialog, which) -> {
Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
intent.setData(Uri.parse("package:" + context.getPackageName()));
context.startActivity(intent);
})
.setNegativeButton("Later", null)
.show();
}
}
🔧 Plugin / JS Side Improvements
8. Schema-Validated Inputs
- Priority: High
- Impact: Data integrity and error prevention
Implementation:
// Add Zod schema validation
import { z } from 'zod';
const NotificationOptionsSchema = z.object({
time: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/, 'Invalid time format. Use HH:MM'),
title: z.string().min(1).max(100, 'Title must be less than 100 characters'),
body: z.string().min(1).max(500, 'Body must be less than 500 characters'),
sound: z.boolean().optional().default(true),
priority: z.enum(['low', 'default', 'high']).optional().default('default'),
url: z.string().url().optional()
});
const ReminderOptionsSchema = z.object({
id: z.string().min(1),
title: z.string().min(1).max(100),
body: z.string().min(1).max(500),
time: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/),
sound: z.boolean().optional().default(true),
vibration: z.boolean().optional().default(true),
priority: z.enum(['low', 'normal', 'high']).optional().default('normal'),
repeatDaily: z.boolean().optional().default(true),
timezone: z.string().optional().default('UTC')
});
// Enhanced plugin methods with validation
export class DailyNotificationPlugin {
async scheduleDailyNotification(options: unknown): Promise<void> {
try {
const validatedOptions = NotificationOptionsSchema.parse(options);
return await this.nativeScheduleDailyNotification(validatedOptions);
} catch (error) {
if (error instanceof z.ZodError) {
throw new Error(`Validation failed: ${error.errors.map(e => e.message).join(', ')}`);
}
throw error;
}
}
async scheduleDailyReminder(options: unknown): Promise<void> {
try {
const validatedOptions = ReminderOptionsSchema.parse(options);
return await this.nativeScheduleDailyReminder(validatedOptions);
} catch (error) {
if (error instanceof z.ZodError) {
throw new Error(`Validation failed: ${error.errors.map(e => e.message).join(', ')}`);
}
throw error;
}
}
}
9. Quiet Hours Enforcement
- Priority: Medium
- Impact: User experience and notification respect
Implementation:
// Enhanced quiet hours handling
export class QuietHoursManager {
private quietHoursStart: string = '22:00';
private quietHoursEnd: string = '08:00';
isQuietHours(time: string): boolean {
const currentTime = new Date();
const currentTimeString = currentTime.toTimeString().slice(0, 5);
const start = this.quietHoursStart;
const end = this.quietHoursEnd;
if (start <= end) {
// Same day quiet hours (e.g., 22:00 to 08:00)
return currentTimeString >= start || currentTimeString <= end;
} else {
// Overnight quiet hours (e.g., 22:00 to 08:00)
return currentTimeString >= start || currentTimeString <= end;
}
}
getNextAllowedTime(time: string): string {
if (!this.isQuietHours(time)) {
return time;
}
// Calculate next allowed time
const [hours, minutes] = this.quietHoursEnd.split(':').map(Number);
const nextAllowed = new Date();
nextAllowed.setHours(hours, minutes, 0, 0);
// If quiet hours end is tomorrow
if (this.quietHoursStart > this.quietHoursEnd) {
nextAllowed.setDate(nextAllowed.getDate() + 1);
}
return nextAllowed.toTimeString().slice(0, 5);
}
}
// Enhanced scheduling with quiet hours
export class EnhancedNotificationScheduler {
private quietHoursManager = new QuietHoursManager();
async scheduleNotification(options: NotificationOptions): Promise<void> {
if (this.quietHoursManager.isQuietHours(options.time)) {
const nextAllowedTime = this.quietHoursManager.getNextAllowedTime(options.time);
console.log(`Scheduling notification for ${nextAllowedTime} due to quiet hours`);
options.time = nextAllowedTime;
}
return await DailyNotification.scheduleDailyNotification(options);
}
}
10. Backoff & Jitter for API Polling
- Priority: Medium
- Impact: API efficiency and server load reduction
Implementation:
// Enhanced API polling with backoff
export class SmartApiPoller {
private baseInterval: number = 300000; // 5 minutes
private maxInterval: number = 1800000; // 30 minutes
private backoffMultiplier: number = 1.5;
private jitterRange: number = 0.1; // 10% jitter
private currentInterval: number = this.baseInterval;
private consecutiveErrors: number = 0;
private lastModified?: string;
private etag?: string;
async pollWithBackoff(): Promise<ChangeNotification[]> {
try {
const changes = await this.fetchChanges();
this.onSuccess();
return changes;
} catch (error) {
this.onError();
throw error;
}
}
private async fetchChanges(): Promise<ChangeNotification[]> {
const headers: Record<string, string> = {
'Content-Type': 'application/json'
};
// Add conditional headers for efficient polling
if (this.lastModified) {
headers['If-Modified-Since'] = this.lastModified;
}
if (this.etag) {
headers['If-None-Match'] = this.etag;
}
const response = await fetch('/api/v2/report/plansLastUpdatedBetween', {
method: 'POST',
headers,
body: JSON.stringify({
planIds: this.starredPlanHandleIds,
afterId: this.lastAckedJwtId
})
});
if (response.status === 304) {
// No changes
return [];
}
// Update conditional headers
this.lastModified = response.headers.get('Last-Modified') || undefined;
this.etag = response.headers.get('ETag') || undefined;
const data = await response.json();
return this.mapToChangeNotifications(data);
}
private onSuccess(): void {
this.consecutiveErrors = 0;
this.currentInterval = this.baseInterval;
}
private onError(): void {
this.consecutiveErrors++;
this.currentInterval = Math.min(
this.currentInterval * this.backoffMultiplier,
this.maxInterval
);
}
private getNextPollInterval(): number {
const jitter = this.currentInterval * this.jitterRange * (Math.random() - 0.5);
return Math.max(this.currentInterval + jitter, 60000); // Minimum 1 minute
}
}
11. Action Handling End-to-End
- Priority: High
- Impact: User engagement and data consistency
Implementation:
// Complete action handling system
export class NotificationActionHandler {
async handleAction(action: string, notificationId: string, data?: any): Promise<void> {
switch (action) {
case 'view':
await this.handleViewAction(notificationId, data);
break;
case 'dismiss':
await this.handleDismissAction(notificationId);
break;
case 'snooze':
await this.handleSnoozeAction(notificationId, data?.snoozeMinutes);
break;
default:
console.warn(`Unknown action: ${action}`);
}
// Record analytics
await this.recordActionAnalytics(action, notificationId);
}
private async handleViewAction(notificationId: string, data?: any): Promise<void> {
// Navigate to relevant content
if (data?.deepLink) {
await this.navigateToDeepLink(data.deepLink);
} else {
await this.navigateToMainApp();
}
// Mark as read on server
await this.markAsReadOnServer(notificationId);
}
private async handleDismissAction(notificationId: string): Promise<void> {
// Mark as dismissed locally
await this.markAsDismissedLocally(notificationId);
// Mark as dismissed on server
await this.markAsDismissedOnServer(notificationId);
}
private async handleSnoozeAction(notificationId: string, snoozeMinutes: number): Promise<void> {
// Reschedule notification
const newTime = new Date(Date.now() + snoozeMinutes * 60000);
await DailyNotification.scheduleDailyNotification({
time: newTime.toTimeString().slice(0, 5),
title: 'Snoozed Notification',
body: 'This notification was snoozed',
id: `${notificationId}_snoozed_${Date.now()}`
});
}
private async recordActionAnalytics(action: string, notificationId: string): Promise<void> {
// Record click-through rate and user engagement
await AnalyticsService.recordEvent('notification_action', {
action,
notificationId,
timestamp: Date.now()
});
}
}
12. Battery & Network Budgets
- Priority: Medium
- Impact: Battery life and network efficiency
Implementation:
// Job coalescing and budget management
export class NotificationJobManager {
private pendingJobs: Map<string, NotificationJob> = new Map();
private coalescingWindow: number = 300000; // 5 minutes
async scheduleNotificationJob(job: NotificationJob): Promise<void> {
this.pendingJobs.set(job.id, job);
// Check if we should coalesce jobs
if (this.shouldCoalesceJobs()) {
await this.coalesceJobs();
} else {
// Schedule individual job
await this.scheduleIndividualJob(job);
}
}
private shouldCoalesceJobs(): boolean {
const now = Date.now();
const jobsInWindow = Array.from(this.pendingJobs.values())
.filter(job => now - job.createdAt < this.coalescingWindow);
return jobsInWindow.length >= 3; // Coalesce if 3+ jobs in window
}
private async coalesceJobs(): Promise<void> {
const jobsToCoalesce = Array.from(this.pendingJobs.values())
.filter(job => Date.now() - job.createdAt < this.coalescingWindow);
if (jobsToCoalesce.length === 0) return;
// Create coalesced job
const coalescedJob: CoalescedNotificationJob = {
id: `coalesced_${Date.now()}`,
jobs: jobsToCoalesce,
createdAt: Date.now()
};
// Schedule coalesced job with WorkManager constraints
await this.scheduleCoalescedJob(coalescedJob);
// Clear pending jobs
jobsToCoalesce.forEach(job => this.pendingJobs.delete(job.id));
}
private async scheduleCoalescedJob(job: CoalescedNotificationJob): Promise<void> {
// Use WorkManager with battery and network constraints
const constraints = new WorkConstraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED) // Use unmetered network
.setRequiresBatteryNotLow(true) // Don't run on low battery
.setRequiresCharging(false) // Allow running while not charging
.build();
const workRequest = new OneTimeWorkRequest.Builder(CoalescedNotificationWorker.class)
.setInputData(createCoalescedJobData(job))
.setConstraints(constraints)
.build();
await WorkManager.getInstance().enqueue(workRequest);
}
}
13. Internationalization & Theming
- Priority: Low
- Impact: User experience and accessibility
Implementation:
// i18n support for notifications
export class NotificationI18n {
private locale: string = 'en';
private translations: Map<string, Map<string, string>> = new Map();
async loadTranslations(locale: string): Promise<void> {
this.locale = locale;
try {
const translations = await import(`./locales/${locale}.json`);
this.translations.set(locale, translations.default);
} catch (error) {
console.warn(`Failed to load translations for ${locale}:`, error);
// Fallback to English
this.locale = 'en';
}
}
t(key: string, params?: Record<string, string>): string {
const localeTranslations = this.translations.get(this.locale);
if (!localeTranslations) {
return key; // Fallback to key
}
let translation = localeTranslations.get(key) || key;
// Replace parameters
if (params) {
Object.entries(params).forEach(([param, value]) => {
translation = translation.replace(`{{${param}}}`, value);
});
}
return translation;
}
getNotificationTitle(type: string, params?: Record<string, string>): string {
return this.t(`notifications.${type}.title`, params);
}
getNotificationBody(type: string, params?: Record<string, string>): string {
return this.t(`notifications.${type}.body`, params);
}
}
// Enhanced notification preferences
export interface NotificationPreferences {
enableScheduledReminders: boolean;
enableChangeNotifications: boolean;
enableSystemNotifications: boolean;
quietHoursStart: string;
quietHoursEnd: string;
preferredNotificationTimes: string[];
changeTypes: string[];
locale: string;
theme: 'light' | 'dark' | 'system';
soundEnabled: boolean;
vibrationEnabled: boolean;
badgeEnabled: boolean;
}
14. Test Harness & Golden Scenarios
- Priority: High
- Impact: Reliability and confidence in production
Implementation:
// Comprehensive test scenarios
export class NotificationTestHarness {
async runGoldenScenarios(): Promise<TestResults> {
const results: TestResults = {
clockSkew: await this.testClockSkew(),
dstJump: await this.testDstJump(),
dozeIdle: await this.testDozeIdle(),
permissionDenied: await this.testPermissionDenied(),
exactAlarmDenied: await this.testExactAlarmDenied(),
oemBackgroundKill: await this.testOemBackgroundKill()
};
return results;
}
private async testClockSkew(): Promise<TestResult> {
// Test notification scheduling with clock skew
const originalTime = Date.now();
const skewedTime = originalTime + 300000; // 5 minutes ahead
// Mock clock skew
jest.spyOn(Date, 'now').mockReturnValue(skewedTime);
try {
await DailyNotification.scheduleDailyNotification({
time: '09:00',
title: 'Clock Skew Test',
body: 'Testing clock skew handling'
});
return { success: true, message: 'Clock skew handled correctly' };
} catch (error) {
return { success: false, message: `Clock skew test failed: ${error.message}` };
} finally {
jest.restoreAllMocks();
}
}
private async testDstJump(): Promise<TestResult> {
// Test DST transition handling
const dstTransitionDate = new Date('2025-03-09T07:00:00Z'); // Spring forward
const beforeDst = new Date('2025-03-09T06:59:00Z');
const afterDst = new Date('2025-03-09T08:01:00Z');
// Test scheduling before DST
jest.useFakeTimers();
jest.setSystemTime(beforeDst);
try {
await DailyNotification.scheduleDailyNotification({
time: '08:00',
title: 'DST Test',
body: 'Testing DST transition'
});
// Fast forward past DST
jest.setSystemTime(afterDst);
// Verify notification still scheduled correctly
const status = await DailyNotification.getNotificationStatus();
return { success: true, message: 'DST transition handled correctly' };
} catch (error) {
return { success: false, message: `DST test failed: ${error.message}` };
} finally {
jest.useRealTimers();
}
}
private async testDozeIdle(): Promise<TestResult> {
// Test Doze mode handling
try {
// Simulate Doze mode
await this.simulateDozeMode();
// Schedule notification
await DailyNotification.scheduleDailyNotification({
time: '09:00',
title: 'Doze Test',
body: 'Testing Doze mode handling'
});
// Verify notification scheduled
const status = await DailyNotification.getNotificationStatus();
return { success: true, message: 'Doze mode handled correctly' };
} catch (error) {
return { success: false, message: `Doze test failed: ${error.message}` };
}
}
private async testPermissionDenied(): Promise<TestResult> {
// Test permission denied scenarios
try {
// Mock permission denied
jest.spyOn(DailyNotification, 'checkPermissions').mockResolvedValue({
notifications: 'denied',
exactAlarms: 'denied'
});
// Attempt to schedule notification
await DailyNotification.scheduleDailyNotification({
time: '09:00',
title: 'Permission Test',
body: 'Testing permission denied handling'
});
return { success: true, message: 'Permission denied handled correctly' };
} catch (error) {
return { success: false, message: `Permission test failed: ${error.message}` };
} finally {
jest.restoreAllMocks();
}
}
private async testExactAlarmDenied(): Promise<TestResult> {
// Test exact alarm permission denied
try {
// Mock exact alarm denied
jest.spyOn(DailyNotification, 'getExactAlarmStatus').mockResolvedValue({
supported: true,
enabled: false,
canSchedule: false,
fallbackWindow: '±10 minutes'
});
// Attempt to schedule exact notification
await DailyNotification.scheduleDailyNotification({
time: '09:00',
title: 'Exact Alarm Test',
body: 'Testing exact alarm denied handling'
});
return { success: true, message: 'Exact alarm denied handled correctly' };
} catch (error) {
return { success: false, message: `Exact alarm test failed: ${error.message}` };
} finally {
jest.restoreAllMocks();
}
}
private async testOemBackgroundKill(): Promise<TestResult> {
// Test OEM background kill scenarios
try {
// Simulate background kill
await this.simulateBackgroundKill();
// Verify recovery
const status = await DailyNotification.getNotificationStatus();
return { success: true, message: 'OEM background kill handled correctly' };
} catch (error) {
return { success: false, message: `OEM background kill test failed: ${error.message}` };
}
}
}
Implementation Priority Matrix
Critical Priority (Implement First)
- 1. Exact-time reliability - Core functionality
- 2. DST-safe time calculation - Prevents user-facing bugs
- 3. Schema-validated inputs - Data integrity
- 4. Permission UX - User experience
High Priority (Implement Second)
- 5. Work deduplication - Prevents race conditions
- 6. Storage hardening - Data integrity and performance
- 7. Action handling end-to-end - User engagement
- 8. Test harness - Reliability and confidence
Medium Priority (Implement Third)
- 9. Notification channel discipline - User control
- 10. Quiet hours enforcement - User experience
- 11. Backoff & jitter - API efficiency
- 12. Battery & network budgets - Performance
Low Priority (Implement Last)
- 13. Click analytics - User insights
- 14. Internationalization - Accessibility
Success Metrics
Reliability Metrics
- Notification delivery rate: >95% of scheduled notifications delivered
- Timing accuracy: Notifications delivered within 1 minute of scheduled time
- DST transition success: 100% success rate across DST boundaries
- Permission handling: Graceful degradation when permissions denied
Performance Metrics
- Battery impact: <1% battery drain per day
- Network efficiency: <1MB data usage per day
- Storage usage: <10MB local storage
- Memory usage: <50MB RAM usage
User Experience Metrics
- Click-through rate: >20% of notifications clicked
- Dismissal rate: <30% of notifications dismissed
- User satisfaction: >4.0/5.0 rating
- Permission grant rate: >80% of users grant permissions
Conclusion
This improvement plan addresses the critical areas identified in the analysis while maintaining the existing strengths of the DailyNotification plugin. The phased approach ensures that the most impactful improvements are implemented first, providing immediate value while building toward a robust, production-ready notification system.
The improvements focus on:
- Reliability: Ensuring notifications fire at the right time, every time
- User Experience: Providing intuitive controls and graceful error handling
- Performance: Minimizing battery and network impact
- Maintainability: Building a robust, testable system
By implementing these improvements, the DailyNotification plugin will become a production-ready, enterprise-grade notification system that provides reliable, efficient, and user-friendly notifications across all supported platforms.