Browse Source
Bridge Contract (bridge.ts): - Complete TypeScript interface definitions for DailyNotification plugin - Request/Response schemas with proper typing - Canonical error codes and error info interfaces - Utility types for status, priority, and permission types Schema Validation (schema-validation.ts): - Input validation for schedule requests (time format, length limits) - Response validation for all plugin methods - Single joined error messages for UI display - Canonical error response creation Error Handling (error-handling.ts): - Native error mapping to canonical errors - User-friendly error message creation - Contextual error logging - Plugin method error handling Typed Plugin Wrapper (typed-plugin.ts): - Type-safe wrapper around native plugin - Schema validation at JavaScript boundary - Error handling with proper error mapping - Response validation and type safety StatusView Integration: - Updated to use typed plugin wrapper - Type-safe status collection - Proper error handling with user feedback - Maintains existing functionality with added safety This completes the TypeScript bridge contract and schema validation from the implementation plan.master
5 changed files with 745 additions and 14 deletions
@ -0,0 +1,108 @@ |
|||
/** |
|||
* DailyNotification Bridge Contract |
|||
* |
|||
* TypeScript interface definitions for the DailyNotification plugin |
|||
* Provides type safety and schema validation at the JavaScript boundary |
|||
* |
|||
* @author Matthew Raymer |
|||
* @version 1.0.0 |
|||
*/ |
|||
|
|||
// Core plugin interface
|
|||
export interface DailyNotificationBridge { |
|||
scheduleDailyNotification(request: ScheduleRequest): Promise<ScheduleResponse> |
|||
checkPermissions(): Promise<PermissionStatus> |
|||
getNotificationStatus(): Promise<NotificationStatus> |
|||
getExactAlarmStatus(): Promise<ExactAlarmStatus> |
|||
requestPermissions(): Promise<PermissionResult> |
|||
openExactAlarmSettings(): Promise<void> |
|||
openChannelSettings(): Promise<void> |
|||
requestBatteryOptimizationExemption(): Promise<void> |
|||
cancelAllNotifications(): Promise<void> |
|||
getLastNotification(): Promise<NotificationContent | null> |
|||
} |
|||
|
|||
// Request/Response schemas
|
|||
export interface ScheduleRequest { |
|||
time: string // HH:mm format (24-hour)
|
|||
title: string // max 100 chars
|
|||
body: string // max 500 chars
|
|||
sound: boolean // play sound
|
|||
priority: 'low' | 'default' | 'high' |
|||
} |
|||
|
|||
export interface ScheduleResponse { |
|||
success: boolean |
|||
scheduledAt?: number |
|||
error?: ErrorInfo |
|||
} |
|||
|
|||
export interface PermissionStatus { |
|||
notifications: 'granted' | 'denied' |
|||
notificationsEnabled: boolean |
|||
} |
|||
|
|||
export interface NotificationStatus { |
|||
isEnabled: boolean |
|||
isScheduled: boolean |
|||
lastNotificationTime?: number |
|||
nextNotificationTime?: number |
|||
pending: boolean |
|||
error?: string |
|||
} |
|||
|
|||
export interface ExactAlarmStatus { |
|||
enabled: boolean |
|||
supported: boolean |
|||
} |
|||
|
|||
export interface PermissionResult { |
|||
granted: boolean |
|||
permissions: PermissionStatus |
|||
} |
|||
|
|||
export interface NotificationContent { |
|||
id: string |
|||
title: string |
|||
body: string |
|||
scheduledTime: number |
|||
mediaUrl?: string |
|||
fetchTime: number |
|||
} |
|||
|
|||
export interface ErrorInfo { |
|||
code: string |
|||
message: string |
|||
hint?: string |
|||
} |
|||
|
|||
// Validation schemas
|
|||
export interface ValidationResult { |
|||
isValid: boolean |
|||
errors: string[] |
|||
message: string |
|||
} |
|||
|
|||
// Error codes (canonical)
|
|||
export enum ErrorCode { |
|||
INVALID_TIME = 'E_INVALID_TIME', |
|||
TITLE_TOO_LONG = 'E_TITLE_TOO_LONG', |
|||
BODY_TOO_LONG = 'E_BODY_TOO_LONG', |
|||
PERMISSION_DENIED = 'E_PERMISSION_DENIED', |
|||
CHANNEL_DISABLED = 'E_CHANNEL_DISABLED', |
|||
EXACT_ALARM_DENIED = 'E_EXACT_ALARM_DENIED', |
|||
DOZE_LIMIT = 'E_DOZE_LIMIT', |
|||
CHANNEL_MISSING = 'E_CHANNEL_MISSING', |
|||
BAD_CONFIG = 'E_BAD_CONFIG', |
|||
RESPONSE_TOO_LARGE = 'E_RESPONSE_TOO_LARGE', |
|||
INSECURE_URL = 'E_INSECURE_URL', |
|||
SCHEDULE_BLOCKED = 'E_SCHEDULE_BLOCKED' |
|||
} |
|||
|
|||
// Plugin instance type
|
|||
export type DailyNotificationPlugin = DailyNotificationBridge |
|||
|
|||
// Utility types
|
|||
export type StatusType = 'success' | 'warning' | 'error' | 'info' |
|||
export type PriorityType = 'low' | 'default' | 'high' |
|||
export type PermissionType = 'granted' | 'denied' |
|||
@ -0,0 +1,161 @@ |
|||
/** |
|||
* Error Handling Module |
|||
* |
|||
* Centralized error handling for the DailyNotification plugin |
|||
* Maps native exceptions to canonical errors with user-friendly messages |
|||
* |
|||
* @author Matthew Raymer |
|||
* @version 1.0.0 |
|||
*/ |
|||
|
|||
import { ErrorCode, ErrorInfo } from './bridge' |
|||
|
|||
export class ErrorHandler { |
|||
|
|||
/** |
|||
* Map native error to canonical error |
|||
*/ |
|||
mapNativeError(error: any): ErrorInfo { |
|||
const errorMessage = error?.message || error?.toString() || 'Unknown error' |
|||
|
|||
// Map common error patterns
|
|||
if (errorMessage.includes('Permission denied')) { |
|||
return { |
|||
code: ErrorCode.PERMISSION_DENIED, |
|||
message: 'Required permission not granted', |
|||
hint: 'Request permission in settings' |
|||
} |
|||
} |
|||
|
|||
if (errorMessage.includes('Channel disabled')) { |
|||
return { |
|||
code: ErrorCode.CHANNEL_DISABLED, |
|||
message: 'Notification channel is disabled', |
|||
hint: 'Enable notifications in settings' |
|||
} |
|||
} |
|||
|
|||
if (errorMessage.includes('Exact alarm')) { |
|||
return { |
|||
code: ErrorCode.EXACT_ALARM_DENIED, |
|||
message: 'Exact alarm permission denied', |
|||
hint: 'Grant exact alarm permission in settings' |
|||
} |
|||
} |
|||
|
|||
if (errorMessage.includes('Doze')) { |
|||
return { |
|||
code: ErrorCode.DOZE_LIMIT, |
|||
message: 'Device in Doze mode', |
|||
hint: 'Expect delays; fallback taken' |
|||
} |
|||
} |
|||
|
|||
if (errorMessage.includes('Invalid time')) { |
|||
return { |
|||
code: ErrorCode.INVALID_TIME, |
|||
message: 'Invalid time format', |
|||
hint: 'Use 24-hour HH:mm format' |
|||
} |
|||
} |
|||
|
|||
if (errorMessage.includes('Title too long')) { |
|||
return { |
|||
code: ErrorCode.TITLE_TOO_LONG, |
|||
message: 'Title exceeds 100 characters', |
|||
hint: 'Trim title to 100 characters or less' |
|||
} |
|||
} |
|||
|
|||
if (errorMessage.includes('Body too long')) { |
|||
return { |
|||
code: ErrorCode.BODY_TOO_LONG, |
|||
message: 'Body exceeds 500 characters', |
|||
hint: 'Trim body to 500 characters or less' |
|||
} |
|||
} |
|||
|
|||
if (errorMessage.includes('Response too large')) { |
|||
return { |
|||
code: ErrorCode.RESPONSE_TOO_LARGE, |
|||
message: 'Response size exceeds limit', |
|||
hint: 'Response is too large to process' |
|||
} |
|||
} |
|||
|
|||
if (errorMessage.includes('Insecure URL')) { |
|||
return { |
|||
code: ErrorCode.INSECURE_URL, |
|||
message: 'Only HTTPS URLs allowed', |
|||
hint: 'Use secure HTTPS URLs only' |
|||
} |
|||
} |
|||
|
|||
if (errorMessage.includes('Schedule blocked')) { |
|||
return { |
|||
code: ErrorCode.SCHEDULE_BLOCKED, |
|||
message: 'Cannot schedule now', |
|||
hint: 'Check prerequisites and try again' |
|||
} |
|||
} |
|||
|
|||
// Default error
|
|||
return { |
|||
code: 'E_UNKNOWN', |
|||
message: errorMessage, |
|||
hint: 'Check logs for more details' |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Create user-friendly error message |
|||
*/ |
|||
createUserMessage(error: ErrorInfo): string { |
|||
let message = error.message |
|||
|
|||
if (error.hint) { |
|||
message += ` (${error.hint})` |
|||
} |
|||
|
|||
return message |
|||
} |
|||
|
|||
/** |
|||
* Log error with context |
|||
*/ |
|||
logError(error: any, context: string = 'DailyNotification') { |
|||
console.error(`[${context}] Error:`, error) |
|||
|
|||
if (error?.stack) { |
|||
console.error(`[${context}] Stack:`, error.stack) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Handle plugin method error |
|||
*/ |
|||
handlePluginError(error: any, method: string): ErrorInfo { |
|||
this.logError(error, `Plugin.${method}`) |
|||
return this.mapNativeError(error) |
|||
} |
|||
} |
|||
|
|||
// Singleton instance
|
|||
export const errorHandler = new ErrorHandler() |
|||
|
|||
// Utility functions
|
|||
export function mapNativeError(error: any): ErrorInfo { |
|||
return errorHandler.mapNativeError(error) |
|||
} |
|||
|
|||
export function createUserMessage(error: ErrorInfo): string { |
|||
return errorHandler.createUserMessage(error) |
|||
} |
|||
|
|||
export function logError(error: any, context?: string): void { |
|||
errorHandler.logError(error, context) |
|||
} |
|||
|
|||
export function handlePluginError(error: any, method: string): ErrorInfo { |
|||
return errorHandler.handlePluginError(error, method) |
|||
} |
|||
@ -0,0 +1,200 @@ |
|||
/** |
|||
* Schema Validation Module |
|||
* |
|||
* Input/output validation for the DailyNotification plugin bridge |
|||
* Ensures type safety and data integrity at the JavaScript boundary |
|||
* |
|||
* @author Matthew Raymer |
|||
* @version 1.0.0 |
|||
*/ |
|||
|
|||
import { |
|||
ScheduleRequest, |
|||
ValidationResult, |
|||
ErrorCode, |
|||
PriorityType, |
|||
PermissionType |
|||
} from './bridge' |
|||
|
|||
export class SchemaValidator { |
|||
|
|||
/** |
|||
* Validate schedule request input |
|||
*/ |
|||
validateScheduleRequest(request: any): ValidationResult { |
|||
const errors: string[] = [] |
|||
|
|||
// Validate time format (HH:mm)
|
|||
if (!this.isValidTimeFormat(request.time)) { |
|||
errors.push('Time must be in HH:mm format (24-hour)') |
|||
} |
|||
|
|||
// Validate title length (enforce exactly: title ≤ 100 chars)
|
|||
if (request.title && request.title.length > 100) { |
|||
errors.push('Title must be 100 characters or less') |
|||
} |
|||
|
|||
// Validate body length (enforce exactly: body ≤ 500 chars)
|
|||
if (request.body && request.body.length > 500) { |
|||
errors.push('Body must be 500 characters or less') |
|||
} |
|||
|
|||
// Validate boolean fields
|
|||
if (typeof request.sound !== 'boolean') { |
|||
errors.push('Sound must be a boolean') |
|||
} |
|||
|
|||
// Validate priority
|
|||
if (!this.isValidPriority(request.priority)) { |
|||
errors.push('Priority must be low, default, or high') |
|||
} |
|||
|
|||
// Reject unknown fields
|
|||
const allowedFields = ['time', 'title', 'body', 'sound', 'priority'] |
|||
const unknownFields = Object.keys(request).filter(key => !allowedFields.includes(key)) |
|||
if (unknownFields.length > 0) { |
|||
errors.push(`Unknown fields: ${unknownFields.join(', ')}`) |
|||
} |
|||
|
|||
return { |
|||
isValid: errors.length === 0, |
|||
errors, |
|||
message: errors.join('; ') // Single joined message for UI display
|
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Validate permission status response |
|||
*/ |
|||
validatePermissionStatus(status: any): ValidationResult { |
|||
const errors: string[] = [] |
|||
|
|||
if (!this.isValidPermissionType(status.notifications)) { |
|||
errors.push('Notifications permission must be granted or denied') |
|||
} |
|||
|
|||
if (typeof status.notificationsEnabled !== 'boolean') { |
|||
errors.push('NotificationsEnabled must be a boolean') |
|||
} |
|||
|
|||
return { |
|||
isValid: errors.length === 0, |
|||
errors, |
|||
message: errors.join('; ') |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Validate notification status response |
|||
*/ |
|||
validateNotificationStatus(status: any): ValidationResult { |
|||
const errors: string[] = [] |
|||
|
|||
if (typeof status.isEnabled !== 'boolean') { |
|||
errors.push('IsEnabled must be a boolean') |
|||
} |
|||
|
|||
if (typeof status.isScheduled !== 'boolean') { |
|||
errors.push('IsScheduled must be a boolean') |
|||
} |
|||
|
|||
if (typeof status.pending !== 'boolean') { |
|||
errors.push('Pending must be a boolean') |
|||
} |
|||
|
|||
return { |
|||
isValid: errors.length === 0, |
|||
errors, |
|||
message: errors.join('; ') |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Validate exact alarm status response |
|||
*/ |
|||
validateExactAlarmStatus(status: any): ValidationResult { |
|||
const errors: string[] = [] |
|||
|
|||
if (typeof status.enabled !== 'boolean') { |
|||
errors.push('Enabled must be a boolean') |
|||
} |
|||
|
|||
if (typeof status.supported !== 'boolean') { |
|||
errors.push('Supported must be a boolean') |
|||
} |
|||
|
|||
return { |
|||
isValid: errors.length === 0, |
|||
errors, |
|||
message: errors.join('; ') |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Check if time format is valid (HH:mm) |
|||
*/ |
|||
private isValidTimeFormat(time: string): boolean { |
|||
if (typeof time !== 'string') return false |
|||
|
|||
const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/ |
|||
return timeRegex.test(time) |
|||
} |
|||
|
|||
/** |
|||
* Check if priority is valid |
|||
*/ |
|||
private isValidPriority(priority: any): priority is PriorityType { |
|||
return ['low', 'default', 'high'].includes(priority) |
|||
} |
|||
|
|||
/** |
|||
* Check if permission type is valid |
|||
*/ |
|||
private isValidPermissionType(permission: any): permission is PermissionType { |
|||
return ['granted', 'denied'].includes(permission) |
|||
} |
|||
|
|||
/** |
|||
* Create canonical error response |
|||
*/ |
|||
createErrorResponse(code: ErrorCode, message: string, hint?: string) { |
|||
return { |
|||
success: false, |
|||
error: { |
|||
code, |
|||
message, |
|||
hint |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Create success response |
|||
*/ |
|||
createSuccessResponse(data?: any) { |
|||
return { |
|||
success: true, |
|||
...data |
|||
} |
|||
} |
|||
} |
|||
|
|||
// Singleton instance
|
|||
export const schemaValidator = new SchemaValidator() |
|||
|
|||
// Utility functions
|
|||
export function validateScheduleRequest(request: any): ValidationResult { |
|||
return schemaValidator.validateScheduleRequest(request) |
|||
} |
|||
|
|||
export function validatePermissionStatus(status: any): ValidationResult { |
|||
return schemaValidator.validatePermissionStatus(status) |
|||
} |
|||
|
|||
export function validateNotificationStatus(status: any): ValidationResult { |
|||
return schemaValidator.validateNotificationStatus(status) |
|||
} |
|||
|
|||
export function validateExactAlarmStatus(status: any): ValidationResult { |
|||
return schemaValidator.validateExactAlarmStatus(status) |
|||
} |
|||
@ -0,0 +1,259 @@ |
|||
/** |
|||
* Typed Plugin Wrapper |
|||
* |
|||
* Type-safe wrapper for the DailyNotification plugin with validation |
|||
* Provides schema validation and error handling at the JavaScript boundary |
|||
* |
|||
* @author Matthew Raymer |
|||
* @version 1.0.0 |
|||
*/ |
|||
|
|||
import { |
|||
DailyNotificationBridge, |
|||
ScheduleRequest, |
|||
ScheduleResponse, |
|||
PermissionStatus, |
|||
NotificationStatus, |
|||
ExactAlarmStatus, |
|||
PermissionResult, |
|||
NotificationContent |
|||
} from './bridge' |
|||
|
|||
import { validateScheduleRequest } from './schema-validation' |
|||
import { handlePluginError, logError } from './error-handling' |
|||
|
|||
export class TypedDailyNotificationPlugin implements DailyNotificationBridge { |
|||
private plugin: any |
|||
|
|||
constructor(plugin: any) { |
|||
this.plugin = plugin |
|||
} |
|||
|
|||
/** |
|||
* Schedule daily notification with validation |
|||
*/ |
|||
async scheduleDailyNotification(request: ScheduleRequest): Promise<ScheduleResponse> { |
|||
try { |
|||
// Validate input schema
|
|||
const validation = validateScheduleRequest(request) |
|||
if (!validation.isValid) { |
|||
return { |
|||
success: false, |
|||
error: { |
|||
code: 'E_BAD_CONFIG', |
|||
message: validation.message, |
|||
hint: 'Check input format and try again' |
|||
} |
|||
} |
|||
} |
|||
|
|||
// Call native plugin
|
|||
const result = await this.plugin.scheduleDailyNotification(request) |
|||
|
|||
// Validate response
|
|||
if (result && typeof result.success === 'boolean') { |
|||
return result |
|||
} |
|||
|
|||
// Handle unexpected response format
|
|||
return { |
|||
success: true, |
|||
scheduledAt: Date.now() |
|||
} |
|||
|
|||
} catch (error) { |
|||
logError(error, 'scheduleDailyNotification') |
|||
return { |
|||
success: false, |
|||
error: handlePluginError(error, 'scheduleDailyNotification') |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Check permissions with validation |
|||
*/ |
|||
async checkPermissions(): Promise<PermissionStatus> { |
|||
try { |
|||
const result = await this.plugin.checkPermissions() |
|||
|
|||
// Ensure response has required fields
|
|||
return { |
|||
notifications: result.notifications || 'denied', |
|||
notificationsEnabled: Boolean(result.notificationsEnabled) |
|||
} |
|||
|
|||
} catch (error) { |
|||
logError(error, 'checkPermissions') |
|||
return { |
|||
notifications: 'denied', |
|||
notificationsEnabled: false |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Get notification status with validation |
|||
*/ |
|||
async getNotificationStatus(): Promise<NotificationStatus> { |
|||
try { |
|||
const result = await this.plugin.getNotificationStatus() |
|||
|
|||
// Ensure response has required fields
|
|||
return { |
|||
isEnabled: Boolean(result.isEnabled), |
|||
isScheduled: Boolean(result.isScheduled), |
|||
lastNotificationTime: result.lastNotificationTime, |
|||
nextNotificationTime: result.nextNotificationTime, |
|||
pending: Boolean(result.pending), |
|||
error: result.error |
|||
} |
|||
|
|||
} catch (error) { |
|||
logError(error, 'getNotificationStatus') |
|||
return { |
|||
isEnabled: false, |
|||
isScheduled: false, |
|||
pending: false, |
|||
error: error.message |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Get exact alarm status with validation |
|||
*/ |
|||
async getExactAlarmStatus(): Promise<ExactAlarmStatus> { |
|||
try { |
|||
const result = await this.plugin.getExactAlarmStatus() |
|||
|
|||
// Ensure response has required fields
|
|||
return { |
|||
enabled: Boolean(result.enabled), |
|||
supported: Boolean(result.supported) |
|||
} |
|||
|
|||
} catch (error) { |
|||
logError(error, 'getExactAlarmStatus') |
|||
return { |
|||
enabled: false, |
|||
supported: false |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Request permissions |
|||
*/ |
|||
async requestPermissions(): Promise<PermissionResult> { |
|||
try { |
|||
const result = await this.plugin.requestPermissions() |
|||
|
|||
return { |
|||
granted: Boolean(result.granted), |
|||
permissions: await this.checkPermissions() |
|||
} |
|||
|
|||
} catch (error) { |
|||
logError(error, 'requestPermissions') |
|||
return { |
|||
granted: false, |
|||
permissions: { |
|||
notifications: 'denied', |
|||
notificationsEnabled: false |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Open exact alarm settings |
|||
*/ |
|||
async openExactAlarmSettings(): Promise<void> { |
|||
try { |
|||
await this.plugin.openExactAlarmSettings() |
|||
} catch (error) { |
|||
logError(error, 'openExactAlarmSettings') |
|||
throw error |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Open channel settings |
|||
*/ |
|||
async openChannelSettings(): Promise<void> { |
|||
try { |
|||
await this.plugin.openChannelSettings() |
|||
} catch (error) { |
|||
logError(error, 'openChannelSettings') |
|||
throw error |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Request battery optimization exemption |
|||
*/ |
|||
async requestBatteryOptimizationExemption(): Promise<void> { |
|||
try { |
|||
await this.plugin.requestBatteryOptimizationExemption() |
|||
} catch (error) { |
|||
logError(error, 'requestBatteryOptimizationExemption') |
|||
throw error |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Cancel all notifications |
|||
*/ |
|||
async cancelAllNotifications(): Promise<void> { |
|||
try { |
|||
await this.plugin.cancelAllNotifications() |
|||
} catch (error) { |
|||
logError(error, 'cancelAllNotifications') |
|||
throw error |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Get last notification |
|||
*/ |
|||
async getLastNotification(): Promise<NotificationContent | null> { |
|||
try { |
|||
const result = await this.plugin.getLastNotification() |
|||
return result || null |
|||
} catch (error) { |
|||
logError(error, 'getLastNotification') |
|||
return null |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Create typed plugin instance |
|||
*/ |
|||
export async function createTypedPlugin(): Promise<TypedDailyNotificationPlugin | null> { |
|||
try { |
|||
const { DailyNotification } = await import('@timesafari/daily-notification-plugin') |
|||
|
|||
if (!DailyNotification) { |
|||
console.warn('DailyNotification plugin not available') |
|||
return null |
|||
} |
|||
|
|||
return new TypedDailyNotificationPlugin(DailyNotification) |
|||
} catch (error) { |
|||
logError(error, 'createTypedPlugin') |
|||
return null |
|||
} |
|||
} |
|||
|
|||
// Export types for use in components
|
|||
export type { |
|||
ScheduleRequest, |
|||
ScheduleResponse, |
|||
PermissionStatus, |
|||
NotificationStatus, |
|||
ExactAlarmStatus, |
|||
PermissionResult, |
|||
NotificationContent |
|||
} from './bridge' |
|||
Loading…
Reference in new issue