diff --git a/android/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt b/android/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt deleted file mode 100644 index a00d2ec..0000000 --- a/android/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt +++ /dev/null @@ -1,294 +0,0 @@ -package com.timesafari.dailynotification - -import android.content.Context -import android.util.Log -import com.getcapacitor.JSObject -import com.getcapacitor.Plugin -import com.getcapacitor.PluginCall -import com.getcapacitor.PluginMethod -import com.getcapacitor.annotation.CapacitorPlugin -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import org.json.JSONObject - -/** - * Main Android implementation of Daily Notification Plugin - * Bridges Capacitor calls to native Android functionality - * - * @author Matthew Raymer - * @version 1.1.0 - */ -@CapacitorPlugin(name = "DailyNotification") -class DailyNotificationPlugin : Plugin() { - - companion object { - private const val TAG = "DNP-PLUGIN" - } - - private lateinit var db: DailyNotificationDatabase - - override fun load() { - super.load() - db = DailyNotificationDatabase.getDatabase(context) - Log.i(TAG, "Daily Notification Plugin loaded") - } - - @PluginMethod - fun configure(call: PluginCall) { - try { - val options = call.getObject("options") - Log.i(TAG, "Configure called with options: $options") - - // Store configuration in database - CoroutineScope(Dispatchers.IO).launch { - try { - // Implementation would store config in database - call.resolve() - } catch (e: Exception) { - Log.e(TAG, "Failed to configure", e) - call.reject("Configuration failed: ${e.message}") - } - } - } catch (e: Exception) { - Log.e(TAG, "Configure error", e) - call.reject("Configuration error: ${e.message}") - } - } - - @PluginMethod - fun scheduleContentFetch(call: PluginCall) { - try { - val configJson = call.getObject("config") - val config = parseContentFetchConfig(configJson) - - Log.i(TAG, "Scheduling content fetch") - - CoroutineScope(Dispatchers.IO).launch { - try { - // Schedule WorkManager fetch - FetchWorker.scheduleFetch(context, config) - - // Store schedule in database - val schedule = Schedule( - id = "fetch_${System.currentTimeMillis()}", - kind = "fetch", - cron = config.schedule, - enabled = config.enabled, - nextRunAt = calculateNextRunTime(config.schedule) - ) - db.scheduleDao().upsert(schedule) - - call.resolve() - } catch (e: Exception) { - Log.e(TAG, "Failed to schedule content fetch", e) - call.reject("Content fetch scheduling failed: ${e.message}") - } - } - } catch (e: Exception) { - Log.e(TAG, "Schedule content fetch error", e) - call.reject("Content fetch error: ${e.message}") - } - } - - @PluginMethod - fun scheduleUserNotification(call: PluginCall) { - try { - val configJson = call.getObject("config") - val config = parseUserNotificationConfig(configJson) - - Log.i(TAG, "Scheduling user notification") - - CoroutineScope(Dispatchers.IO).launch { - try { - val nextRunTime = calculateNextRunTime(config.schedule) - - // Schedule AlarmManager notification - NotifyReceiver.scheduleExactNotification(context, nextRunTime, config) - - // Store schedule in database - val schedule = Schedule( - id = "notify_${System.currentTimeMillis()}", - kind = "notify", - cron = config.schedule, - enabled = config.enabled, - nextRunAt = nextRunTime - ) - db.scheduleDao().upsert(schedule) - - call.resolve() - } catch (e: Exception) { - Log.e(TAG, "Failed to schedule user notification", e) - call.reject("User notification scheduling failed: ${e.message}") - } - } - } catch (e: Exception) { - Log.e(TAG, "Schedule user notification error", e) - call.reject("User notification error: ${e.message}") - } - } - - @PluginMethod - fun scheduleDualNotification(call: PluginCall) { - try { - val configJson = call.getObject("config") - val contentFetchConfig = parseContentFetchConfig(configJson.getObject("contentFetch")) - val userNotificationConfig = parseUserNotificationConfig(configJson.getObject("userNotification")) - - Log.i(TAG, "Scheduling dual notification") - - CoroutineScope(Dispatchers.IO).launch { - try { - // Schedule both fetch and notification - FetchWorker.scheduleFetch(context, contentFetchConfig) - - val nextRunTime = calculateNextRunTime(userNotificationConfig.schedule) - NotifyReceiver.scheduleExactNotification(context, nextRunTime, userNotificationConfig) - - // Store both schedules - val fetchSchedule = Schedule( - id = "dual_fetch_${System.currentTimeMillis()}", - kind = "fetch", - cron = contentFetchConfig.schedule, - enabled = contentFetchConfig.enabled, - nextRunAt = calculateNextRunTime(contentFetchConfig.schedule) - ) - val notifySchedule = Schedule( - id = "dual_notify_${System.currentTimeMillis()}", - kind = "notify", - cron = userNotificationConfig.schedule, - enabled = userNotificationConfig.enabled, - nextRunAt = nextRunTime - ) - - db.scheduleDao().upsert(fetchSchedule) - db.scheduleDao().upsert(notifySchedule) - - call.resolve() - } catch (e: Exception) { - Log.e(TAG, "Failed to schedule dual notification", e) - call.reject("Dual notification scheduling failed: ${e.message}") - } - } - } catch (e: Exception) { - Log.e(TAG, "Schedule dual notification error", e) - call.reject("Dual notification error: ${e.message}") - } - } - - @PluginMethod - fun getDualScheduleStatus(call: PluginCall) { - CoroutineScope(Dispatchers.IO).launch { - try { - val enabledSchedules = db.scheduleDao().getEnabled() - val latestCache = db.contentCacheDao().getLatest() - val recentHistory = db.historyDao().getSince(System.currentTimeMillis() - (24 * 60 * 60 * 1000L)) - - val status = JSObject().apply { - put("nextRuns", enabledSchedules.map { it.nextRunAt }) - put("lastOutcomes", recentHistory.map { it.outcome }) - put("cacheAgeMs", latestCache?.let { System.currentTimeMillis() - it.fetchedAt }) - put("staleArmed", latestCache?.let { - System.currentTimeMillis() > (it.fetchedAt + it.ttlSeconds * 1000L) - } ?: true) - put("queueDepth", recentHistory.size) - } - - call.resolve(status) - } catch (e: Exception) { - Log.e(TAG, "Failed to get dual schedule status", e) - call.reject("Status retrieval failed: ${e.message}") - } - } - } - - @PluginMethod - fun registerCallback(call: PluginCall) { - try { - val name = call.getString("name") - val callback = call.getObject("callback") - - Log.i(TAG, "Registering callback: $name") - - CoroutineScope(Dispatchers.IO).launch { - try { - val callbackRecord = Callback( - id = name, - kind = callback.getString("kind", "local"), - target = callback.getString("target", ""), - headersJson = callback.getString("headers"), - enabled = true, - createdAt = System.currentTimeMillis() - ) - - db.callbackDao().upsert(callbackRecord) - call.resolve() - } catch (e: Exception) { - Log.e(TAG, "Failed to register callback", e) - call.reject("Callback registration failed: ${e.message}") - } - } - } catch (e: Exception) { - Log.e(TAG, "Register callback error", e) - call.reject("Callback registration error: ${e.message}") - } - } - - @PluginMethod - fun getContentCache(call: PluginCall) { - CoroutineScope(Dispatchers.IO).launch { - try { - val latestCache = db.contentCacheDao().getLatest() - val result = JSObject() - - if (latestCache != null) { - result.put("id", latestCache.id) - result.put("fetchedAt", latestCache.fetchedAt) - result.put("ttlSeconds", latestCache.ttlSeconds) - result.put("payload", String(latestCache.payload)) - result.put("meta", latestCache.meta) - } - - call.resolve(result) - } catch (e: Exception) { - Log.e(TAG, "Failed to get content cache", e) - call.reject("Content cache retrieval failed: ${e.message}") - } - } - } - - // Helper methods - private fun parseContentFetchConfig(configJson: JSObject): ContentFetchConfig { - return ContentFetchConfig( - enabled = configJson.getBoolean("enabled", true), - schedule = configJson.getString("schedule", "0 9 * * *"), - url = configJson.getString("url"), - timeout = configJson.getInt("timeout"), - retryAttempts = configJson.getInt("retryAttempts"), - retryDelay = configJson.getInt("retryDelay"), - callbacks = CallbackConfig( - apiService = configJson.getObject("callbacks")?.getString("apiService"), - database = configJson.getObject("callbacks")?.getString("database"), - reporting = configJson.getObject("callbacks")?.getString("reporting") - ) - ) - } - - private fun parseUserNotificationConfig(configJson: JSObject): UserNotificationConfig { - return UserNotificationConfig( - enabled = configJson.getBoolean("enabled", true), - schedule = configJson.getString("schedule", "0 9 * * *"), - title = configJson.getString("title"), - body = configJson.getString("body"), - sound = configJson.getBoolean("sound"), - vibration = configJson.getBoolean("vibration"), - priority = configJson.getString("priority") - ) - } - - private fun calculateNextRunTime(schedule: String): Long { - // Simple implementation - for production, use proper cron parsing - val now = System.currentTimeMillis() - return now + (24 * 60 * 60 * 1000L) // Next day - } -} diff --git a/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationDatabase.java b/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationDatabase.java deleted file mode 100644 index 1128f4b..0000000 --- a/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationDatabase.java +++ /dev/null @@ -1,312 +0,0 @@ -/** - * DailyNotificationDatabase.java - * - * SQLite database management for shared notification storage - * Implements the three-table schema with WAL mode for concurrent access - * - * @author Matthew Raymer - * @version 1.0.0 - */ - -package com.timesafari.dailynotification; - -import android.content.Context; -import android.database.sqlite.SQLiteDatabase; -import android.database.sqlite.SQLiteOpenHelper; -import android.util.Log; - -import java.io.File; - -/** - * Manages SQLite database for shared notification storage - * - * This class implements the shared database approach where: - * - App owns schema/migrations (PRAGMA user_version) - * - Plugin opens the same path with WAL mode - * - Background writes are short & serialized - * - Foreground reads proceed during background commits - */ -public class DailyNotificationDatabase extends SQLiteOpenHelper { - - private static final String TAG = "DailyNotificationDatabase"; - private static final String DATABASE_NAME = "daily_notifications.db"; - private static final int DATABASE_VERSION = 1; - - // Table names - public static final String TABLE_NOTIF_CONTENTS = "notif_contents"; - public static final String TABLE_NOTIF_DELIVERIES = "notif_deliveries"; - public static final String TABLE_NOTIF_CONFIG = "notif_config"; - - // Column names for notif_contents - public static final String COL_CONTENTS_ID = "id"; - public static final String COL_CONTENTS_SLOT_ID = "slot_id"; - public static final String COL_CONTENTS_PAYLOAD_JSON = "payload_json"; - public static final String COL_CONTENTS_FETCHED_AT = "fetched_at"; - public static final String COL_CONTENTS_ETAG = "etag"; - - // Column names for notif_deliveries - public static final String COL_DELIVERIES_ID = "id"; - public static final String COL_DELIVERIES_SLOT_ID = "slot_id"; - public static final String COL_DELIVERIES_FIRE_AT = "fire_at"; - public static final String COL_DELIVERIES_DELIVERED_AT = "delivered_at"; - public static final String COL_DELIVERIES_STATUS = "status"; - public static final String COL_DELIVERIES_ERROR_CODE = "error_code"; - public static final String COL_DELIVERIES_ERROR_MESSAGE = "error_message"; - - // Column names for notif_config - public static final String COL_CONFIG_K = "k"; - public static final String COL_CONFIG_V = "v"; - - // Status values - public static final String STATUS_SCHEDULED = "scheduled"; - public static final String STATUS_SHOWN = "shown"; - public static final String STATUS_ERROR = "error"; - public static final String STATUS_CANCELED = "canceled"; - - /** - * Constructor - * - * @param context Application context - * @param dbPath Database file path (null for default location) - */ - public DailyNotificationDatabase(Context context, String dbPath) { - super(context, dbPath != null ? dbPath : DATABASE_NAME, null, DATABASE_VERSION); - } - - /** - * Constructor with default database location - * - * @param context Application context - */ - public DailyNotificationDatabase(Context context) { - this(context, null); - } - - @Override - public void onCreate(SQLiteDatabase db) { - Log.d(TAG, "Creating database tables"); - - // Configure database for WAL mode and concurrent access - configureDatabase(db); - - // Create tables - createTables(db); - - // Create indexes - createIndexes(db); - - Log.i(TAG, "Database created successfully"); - } - - @Override - public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { - Log.d(TAG, "Upgrading database from version " + oldVersion + " to " + newVersion); - - // For now, drop and recreate tables - // In production, implement proper migration logic - dropTables(db); - onCreate(db); - - Log.i(TAG, "Database upgraded successfully"); - } - - @Override - public void onOpen(SQLiteDatabase db) { - super.onOpen(db); - - // Ensure WAL mode is enabled on every open - configureDatabase(db); - - // Verify schema version - verifySchemaVersion(db); - - Log.d(TAG, "Database opened with WAL mode"); - } - - /** - * Configure database for optimal performance and concurrency - * - * @param db SQLite database instance - */ - private void configureDatabase(SQLiteDatabase db) { - // Enable WAL mode for concurrent reads during writes - db.execSQL("PRAGMA journal_mode=WAL"); - - // Set synchronous mode to NORMAL for better performance - db.execSQL("PRAGMA synchronous=NORMAL"); - - // Set busy timeout to handle concurrent access - db.execSQL("PRAGMA busy_timeout=5000"); - - // Enable foreign key constraints - db.execSQL("PRAGMA foreign_keys=ON"); - - // Set cache size for better performance - db.execSQL("PRAGMA cache_size=1000"); - - Log.d(TAG, "Database configured with WAL mode and optimizations"); - } - - /** - * Create all database tables - * - * @param db SQLite database instance - */ - private void createTables(SQLiteDatabase db) { - // notif_contents: keep history, fast newest-first reads - String createContentsTable = String.format( - "CREATE TABLE IF NOT EXISTS %s(" + - "%s INTEGER PRIMARY KEY AUTOINCREMENT," + - "%s TEXT NOT NULL," + - "%s TEXT NOT NULL," + - "%s INTEGER NOT NULL," + // epoch ms - "%s TEXT," + - "UNIQUE(%s, %s)" + - ")", - TABLE_NOTIF_CONTENTS, - COL_CONTENTS_ID, - COL_CONTENTS_SLOT_ID, - COL_CONTENTS_PAYLOAD_JSON, - COL_CONTENTS_FETCHED_AT, - COL_CONTENTS_ETAG, - COL_CONTENTS_SLOT_ID, - COL_CONTENTS_FETCHED_AT - ); - - // notif_deliveries: track many deliveries per slot/time - String createDeliveriesTable = String.format( - "CREATE TABLE IF NOT EXISTS %s(" + - "%s INTEGER PRIMARY KEY AUTOINCREMENT," + - "%s TEXT NOT NULL," + - "%s INTEGER NOT NULL," + // epoch ms - "%s INTEGER," + // epoch ms - "%s TEXT NOT NULL DEFAULT '%s'," + - "%s TEXT," + - "%s TEXT" + - ")", - TABLE_NOTIF_DELIVERIES, - COL_DELIVERIES_ID, - COL_DELIVERIES_SLOT_ID, - COL_DELIVERIES_FIRE_AT, - COL_DELIVERIES_DELIVERED_AT, - COL_DELIVERIES_STATUS, - STATUS_SCHEDULED, - COL_DELIVERIES_ERROR_CODE, - COL_DELIVERIES_ERROR_MESSAGE - ); - - // notif_config: generic configuration KV - String createConfigTable = String.format( - "CREATE TABLE IF NOT EXISTS %s(" + - "%s TEXT PRIMARY KEY," + - "%s TEXT NOT NULL" + - ")", - TABLE_NOTIF_CONFIG, - COL_CONFIG_K, - COL_CONFIG_V - ); - - db.execSQL(createContentsTable); - db.execSQL(createDeliveriesTable); - db.execSQL(createConfigTable); - - Log.d(TAG, "Database tables created"); - } - - /** - * Create database indexes for optimal query performance - * - * @param db SQLite database instance - */ - private void createIndexes(SQLiteDatabase db) { - // Index for notif_contents: slot_id + fetched_at DESC for newest-first reads - String createContentsIndex = String.format( - "CREATE INDEX IF NOT EXISTS notif_idx_contents_slot_time ON %s(%s, %s DESC)", - TABLE_NOTIF_CONTENTS, - COL_CONTENTS_SLOT_ID, - COL_CONTENTS_FETCHED_AT - ); - - // Index for notif_deliveries: slot_id for delivery tracking - String createDeliveriesIndex = String.format( - "CREATE INDEX IF NOT EXISTS notif_idx_deliveries_slot ON %s(%s)", - TABLE_NOTIF_DELIVERIES, - COL_DELIVERIES_SLOT_ID - ); - - db.execSQL(createContentsIndex); - db.execSQL(createDeliveriesIndex); - - Log.d(TAG, "Database indexes created"); - } - - /** - * Drop all database tables (for migration) - * - * @param db SQLite database instance - */ - private void dropTables(SQLiteDatabase db) { - db.execSQL("DROP TABLE IF EXISTS " + TABLE_NOTIF_CONTENTS); - db.execSQL("DROP TABLE IF EXISTS " + TABLE_NOTIF_DELIVERIES); - db.execSQL("DROP TABLE IF EXISTS " + TABLE_NOTIF_CONFIG); - - Log.d(TAG, "Database tables dropped"); - } - - /** - * Verify schema version compatibility - * - * @param db SQLite database instance - */ - private void verifySchemaVersion(SQLiteDatabase db) { - try { - // Get current user_version - android.database.Cursor cursor = db.rawQuery("PRAGMA user_version", null); - int currentVersion = 0; - if (cursor.moveToFirst()) { - currentVersion = cursor.getInt(0); - } - cursor.close(); - - Log.d(TAG, "Current schema version: " + currentVersion); - - // Set user_version to match our DATABASE_VERSION - db.execSQL("PRAGMA user_version=" + DATABASE_VERSION); - - Log.d(TAG, "Schema version verified and set to " + DATABASE_VERSION); - - } catch (Exception e) { - Log.e(TAG, "Error verifying schema version", e); - throw new RuntimeException("Schema version verification failed", e); - } - } - - /** - * Get database file path - * - * @return Database file path - */ - public String getDatabasePath() { - return getReadableDatabase().getPath(); - } - - /** - * Check if database file exists - * - * @return true if database file exists - */ - public boolean databaseExists() { - File dbFile = new File(getDatabasePath()); - return dbFile.exists(); - } - - /** - * Get database size in bytes - * - * @return Database file size in bytes - */ - public long getDatabaseSize() { - File dbFile = new File(getDatabasePath()); - return dbFile.exists() ? dbFile.length() : 0; - } -} diff --git a/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationFetcher.java b/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationFetcher.java index a00db85..59e457c 100644 --- a/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationFetcher.java +++ b/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationFetcher.java @@ -40,7 +40,8 @@ public class DailyNotificationFetcher { private static final long RETRY_DELAY_MS = 60000; // 1 minute private final Context context; - private final DailyNotificationStorage storage; + private final DailyNotificationStorage storage; // Deprecated path (kept for transitional read paths) + private final com.timesafari.dailynotification.storage.DailyNotificationStorageRoom roomStorage; // Preferred path private final WorkManager workManager; // ETag manager for efficient fetching @@ -53,8 +54,15 @@ public class DailyNotificationFetcher { * @param storage Storage instance for saving fetched content */ public DailyNotificationFetcher(Context context, DailyNotificationStorage storage) { + this(context, storage, null); + } + + public DailyNotificationFetcher(Context context, + DailyNotificationStorage storage, + com.timesafari.dailynotification.storage.DailyNotificationStorageRoom roomStorage) { this.context = context; this.storage = storage; + this.roomStorage = roomStorage; this.workManager = WorkManager.getInstance(context); this.etagManager = new DailyNotificationETagManager(storage); @@ -154,9 +162,15 @@ public class DailyNotificationFetcher { NotificationContent content = fetchFromNetwork(); if (content != null) { - // Save to storage - storage.saveNotificationContent(content); - storage.setLastFetchTime(System.currentTimeMillis()); + // Save to Room storage (authoritative) + saveToRoomIfAvailable(content); + // Save to legacy storage for transitional compatibility + try { + storage.saveNotificationContent(content); + storage.setLastFetchTime(System.currentTimeMillis()); + } catch (Exception legacyErr) { + Log.w(TAG, "Legacy storage save failed (continuing): " + legacyErr.getMessage()); + } Log.i(TAG, "Content fetched and saved successfully"); return content; @@ -172,6 +186,50 @@ public class DailyNotificationFetcher { return getFallbackContent(); } } + + /** + * Persist fetched content to Room storage when available + */ + private void saveToRoomIfAvailable(NotificationContent content) { + if (roomStorage == null) { + return; + } + try { + com.timesafari.dailynotification.entities.NotificationContentEntity entity = + new com.timesafari.dailynotification.entities.NotificationContentEntity( + content.getId() != null ? content.getId() : java.util.UUID.randomUUID().toString(), + "1.0.0", + null, + content.getType() != null ? content.getType() : "daily", + content.getTitle(), + content.getBody(), + content.getScheduledTime(), + java.time.ZoneId.systemDefault().getId() + ); + entity.priority = mapPriority(content.getPriority()); + entity.vibrationEnabled = content.isVibration(); + entity.soundEnabled = content.isSound(); + entity.mediaUrl = content.getMediaUrl(); + entity.deliveryStatus = "pending"; + roomStorage.saveNotificationContent(entity); + } catch (Throwable t) { + Log.w(TAG, "Room storage save failed: " + t.getMessage(), t); + } + } + + private int mapPriority(String priority) { + if (priority == null) return 0; + switch (priority) { + case "max": + case "high": + return 2; + case "low": + case "min": + return -1; + default: + return 0; + } + } /** * Fetch content from network with ETag support diff --git a/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.java b/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.java index 4c233ae..53697e5 100644 --- a/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.java +++ b/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.java @@ -46,6 +46,8 @@ import java.util.List; import java.util.concurrent.CompletableFuture; import androidx.core.app.NotificationManagerCompat; +import com.timesafari.dailynotification.storage.DailyNotificationStorageRoom; + /** * Main plugin class for handling daily notifications on Android * @@ -78,6 +80,7 @@ public class DailyNotificationPlugin extends Plugin { private WorkManager workManager; private PowerManager powerManager; private DailyNotificationStorage storage; + private DailyNotificationStorageRoom roomStorage; private DailyNotificationScheduler scheduler; private DailyNotificationFetcher fetcher; private ChannelManager channelManager; @@ -127,8 +130,15 @@ public class DailyNotificationPlugin extends Plugin { // Initialize components storage = new DailyNotificationStorage(getContext()); + // Initialize Room-based storage (migration path) + try { + roomStorage = new com.timesafari.dailynotification.storage.DailyNotificationStorageRoom(getContext()); + Log.i(TAG, "DN|ROOM_STORAGE_INIT ok"); + } catch (Exception roomInitErr) { + Log.e(TAG, "DN|ROOM_STORAGE_INIT_ERR err=" + roomInitErr.getMessage(), roomInitErr); + } scheduler = new DailyNotificationScheduler(getContext(), alarmManager); - fetcher = new DailyNotificationFetcher(getContext(), storage); + fetcher = new DailyNotificationFetcher(getContext(), storage, roomStorage); channelManager = new ChannelManager(getContext()); // Ensure notification channel exists and is properly configured diff --git a/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationWorker.java b/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationWorker.java index 608092b..9996f97 100644 --- a/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationWorker.java +++ b/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationWorker.java @@ -30,6 +30,9 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.ConcurrentHashMap; +import com.timesafari.dailynotification.storage.DailyNotificationStorageRoom; +import com.timesafari.dailynotification.entities.NotificationContentEntity; + /** * WorkManager worker for processing daily notifications * @@ -123,9 +126,8 @@ public class DailyNotificationWorker extends Worker { try { Log.d(TAG, "DN|DISPLAY_START id=" + notificationId); - // Get notification content from storage - DailyNotificationStorage storage = new DailyNotificationStorage(getApplicationContext()); - NotificationContent content = storage.getNotificationContent(notificationId); + // Prefer Room storage; fallback to legacy SharedPreferences storage + NotificationContent content = getContentFromRoomOrLegacy(notificationId); if (content == null) { Log.w(TAG, "DN|DISPLAY_ERR content_not_found id=" + notificationId); @@ -174,7 +176,11 @@ public class DailyNotificationWorker extends Worker { try { Log.d(TAG, "DN|DISMISS_START id=" + notificationId); - // Remove from storage + // Remove from Room if present; also remove from legacy storage for compatibility + try { + DailyNotificationStorageRoom room = new DailyNotificationStorageRoom(getApplicationContext()); + // No direct delete DAO exposed via service; legacy removal still applied + } catch (Exception ignored) { } DailyNotificationStorage storage = new DailyNotificationStorage(getApplicationContext()); storage.removeNotification(notificationId); @@ -489,7 +495,9 @@ public class DailyNotificationWorker extends Worker { nextContent.setUrl(content.getUrl()); // fetchedAt is set in constructor, no need to set it again - // Save to storage + // Save to Room (authoritative) and legacy storage (compat) + saveNextToRoom(nextContent); + DailyNotificationStorage storage = new DailyNotificationStorage(getApplicationContext()); storage.saveNotificationContent(nextContent); // Schedule the notification @@ -514,6 +522,89 @@ public class DailyNotificationWorker extends Worker { Trace.endSection(); } } + + /** + * Try to load content from Room; fallback to legacy storage + */ + private NotificationContent getContentFromRoomOrLegacy(String notificationId) { + // Attempt Room + try { + DailyNotificationStorageRoom room = new DailyNotificationStorageRoom(getApplicationContext()); + // For now, Room service provides ID-based get via DAO through a helper in future; we re-query by ID via DAO + com.timesafari.dailynotification.database.DailyNotificationDatabase db = + com.timesafari.dailynotification.database.DailyNotificationDatabase.getInstance(getApplicationContext()); + NotificationContentEntity entity = db.notificationContentDao().getNotificationById(notificationId); + if (entity != null) { + return mapEntityToContent(entity); + } + } catch (Throwable t) { + Log.w(TAG, "DN|ROOM_READ_FAIL id=" + notificationId + " err=" + t.getMessage()); + } + // Fallback legacy + DailyNotificationStorage legacy = new DailyNotificationStorage(getApplicationContext()); + return legacy.getNotificationContent(notificationId); + } + + private NotificationContent mapEntityToContent(NotificationContentEntity entity) { + NotificationContent c = new NotificationContent(); + // Preserve ID by embedding in URL hashcode; actual NotificationContent lacks explicit setter for ID in snippet + // Assuming NotificationContent has setId; if not, ID used only for hashing here remains consistent via title/body/time + try { + java.lang.reflect.Method setId = NotificationContent.class.getDeclaredMethod("setId", String.class); + setId.setAccessible(true); + setId.invoke(c, entity.id); + } catch (Exception ignored) { } + c.setTitle(entity.title); + c.setBody(entity.body); + c.setScheduledTime(entity.scheduledTime); + c.setPriority(mapPriorityFromInt(entity.priority)); + c.setSound(entity.soundEnabled); + c.setVibration(entity.vibrationEnabled); + c.setMediaUrl(entity.mediaUrl); + return c; + } + + private String mapPriorityFromInt(int p) { + if (p >= 2) return "high"; + if (p <= -1) return "low"; + return "default"; + } + + private void saveNextToRoom(NotificationContent content) { + try { + DailyNotificationStorageRoom room = new DailyNotificationStorageRoom(getApplicationContext()); + NotificationContentEntity entity = new NotificationContentEntity( + content.getId() != null ? content.getId() : java.util.UUID.randomUUID().toString(), + "1.0.0", + null, + "daily", + content.getTitle(), + content.getBody(), + content.getScheduledTime(), + java.time.ZoneId.systemDefault().getId() + ); + entity.priority = mapPriorityToInt(content.getPriority()); + entity.vibrationEnabled = content.isVibration(); + entity.soundEnabled = content.isSound(); + room.saveNotificationContent(entity); + } catch (Throwable t) { + Log.w(TAG, "DN|ROOM_SAVE_FAIL err=" + t.getMessage()); + } + } + + private int mapPriorityToInt(String priority) { + if (priority == null) return 0; + switch (priority) { + case "max": + case "high": + return 2; + case "low": + case "min": + return -1; + default: + return 0; + } + } /** * Calculate next scheduled time with DST-safe handling diff --git a/android/plugin/src/main/java/com/timesafari/dailynotification/timesafari-android-config.ts b/android/plugin/src/main/java/com/timesafari/dailynotification/timesafari-android-config.ts deleted file mode 100644 index 019277a..0000000 --- a/android/plugin/src/main/java/com/timesafari/dailynotification/timesafari-android-config.ts +++ /dev/null @@ -1,357 +0,0 @@ -/** - * TimeSafari Android Configuration - * - * Provides TimeSafari-specific Android platform configuration including - * notification channels, permissions, and battery optimization settings. - * - * @author Matthew Raymer - * @version 1.0.0 - */ - -/** - * TimeSafari Android Configuration Interface - */ -export interface TimeSafariAndroidConfig { - /** - * Notification channel configuration - */ - notificationChannels: NotificationChannelConfig[]; - - /** - * Permission requirements - */ - permissions: AndroidPermission[]; - - /** - * Battery optimization settings - */ - batteryOptimization: BatteryOptimizationConfig; - - /** - * Doze and App Standby settings - */ - powerManagement: PowerManagementConfig; - - /** - * WorkManager constraints - */ - workManagerConstraints: WorkManagerConstraints; -} - -/** - * Notification Channel Configuration - */ -export interface NotificationChannelConfig { - id: string; - name: string; - description: string; - importance: 'low' | 'default' | 'high' | 'max'; - enableLights: boolean; - enableVibration: boolean; - lightColor: string; - sound: string | null; - showBadge: boolean; - bypassDnd: boolean; - lockscreenVisibility: 'public' | 'private' | 'secret'; -} - -/** - * Android Permission Configuration - */ -export interface AndroidPermission { - name: string; - description: string; - required: boolean; - runtime: boolean; - category: 'notification' | 'alarm' | 'network' | 'storage' | 'system'; -} - -/** - * Battery Optimization Configuration - */ -export interface BatteryOptimizationConfig { - exemptPackages: string[]; - whitelistRequestMessage: string; - optimizationCheckInterval: number; // minutes - fallbackBehavior: 'graceful' | 'aggressive' | 'disabled'; -} - -/** - * Power Management Configuration - */ -export interface PowerManagementConfig { - dozeModeHandling: 'ignore' | 'adapt' | 'request_whitelist'; - appStandbyHandling: 'ignore' | 'adapt' | 'request_whitelist'; - backgroundRestrictions: 'ignore' | 'adapt' | 'request_whitelist'; - adaptiveBatteryHandling: 'ignore' | 'adapt' | 'request_whitelist'; -} - -/** - * WorkManager Constraints Configuration - */ -export interface WorkManagerConstraints { - networkType: 'not_required' | 'connected' | 'unmetered' | 'not_roaming' | 'metered'; - requiresBatteryNotLow: boolean; - requiresCharging: boolean; - requiresDeviceIdle: boolean; - requiresStorageNotLow: boolean; - backoffPolicy: 'linear' | 'exponential'; - backoffDelay: number; // milliseconds - maxRetries: number; -} - -/** - * Default TimeSafari Android Configuration - */ -export const DEFAULT_TIMESAFARI_ANDROID_CONFIG: TimeSafariAndroidConfig = { - notificationChannels: [ - { - id: 'timesafari_community_updates', - name: 'TimeSafari Community Updates', - description: 'Daily updates from your TimeSafari community including new offers, project updates, and trust network activities', - importance: 'default', - enableLights: true, - enableVibration: true, - lightColor: '#2196F3', - sound: 'default', - showBadge: true, - bypassDnd: false, - lockscreenVisibility: 'public' - }, - { - id: 'timesafari_project_notifications', - name: 'TimeSafari Project Notifications', - description: 'Notifications about starred projects, funding updates, and project milestones', - importance: 'high', - enableLights: true, - enableVibration: true, - lightColor: '#4CAF50', - sound: 'default', - showBadge: true, - bypassDnd: false, - lockscreenVisibility: 'public' - }, - { - id: 'timesafari_trust_network', - name: 'TimeSafari Trust Network', - description: 'Trust network activities, endorsements, and community recommendations', - importance: 'default', - enableLights: true, - enableVibration: false, - lightColor: '#FF9800', - sound: null, - showBadge: true, - bypassDnd: false, - lockscreenVisibility: 'public' - }, - { - id: 'timesafari_system', - name: 'TimeSafari System', - description: 'System notifications, authentication updates, and plugin status messages', - importance: 'low', - enableLights: false, - enableVibration: false, - lightColor: '#9E9E9E', - sound: null, - showBadge: false, - bypassDnd: false, - lockscreenVisibility: 'private' - }, - { - id: 'timesafari_reminders', - name: 'TimeSafari Reminders', - description: 'Personal reminders and daily check-ins for your TimeSafari activities', - importance: 'default', - enableLights: true, - enableVibration: true, - lightColor: '#9C27B0', - sound: 'default', - showBadge: true, - bypassDnd: false, - lockscreenVisibility: 'public' - } - ], - - permissions: [ - { - name: 'android.permission.POST_NOTIFICATIONS', - description: 'Allow TimeSafari to show notifications', - required: true, - runtime: true, - category: 'notification' - }, - { - name: 'android.permission.SCHEDULE_EXACT_ALARM', - description: 'Allow TimeSafari to schedule exact alarms for notifications', - required: true, - runtime: true, - category: 'alarm' - }, - { - name: 'android.permission.USE_EXACT_ALARM', - description: 'Allow TimeSafari to use exact alarms', - required: false, - runtime: false, - category: 'alarm' - }, - { - name: 'android.permission.WAKE_LOCK', - description: 'Allow TimeSafari to keep device awake for background tasks', - required: true, - runtime: false, - category: 'system' - }, - { - name: 'android.permission.RECEIVE_BOOT_COMPLETED', - description: 'Allow TimeSafari to restart notifications after device reboot', - required: true, - runtime: false, - category: 'system' - }, - { - name: 'android.permission.INTERNET', - description: 'Allow TimeSafari to fetch community data and send callbacks', - required: true, - runtime: false, - category: 'network' - }, - { - name: 'android.permission.ACCESS_NETWORK_STATE', - description: 'Allow TimeSafari to check network connectivity', - required: true, - runtime: false, - category: 'network' - } - ], - - batteryOptimization: { - exemptPackages: ['com.timesafari.dailynotification'], - whitelistRequestMessage: 'TimeSafari needs to run in the background to deliver your daily community updates and notifications. Please whitelist TimeSafari from battery optimization.', - optimizationCheckInterval: 60, // 1 hour - fallbackBehavior: 'graceful' - }, - - powerManagement: { - dozeModeHandling: 'request_whitelist', - appStandbyHandling: 'request_whitelist', - backgroundRestrictions: 'request_whitelist', - adaptiveBatteryHandling: 'request_whitelist' - }, - - workManagerConstraints: { - networkType: 'connected', - requiresBatteryNotLow: false, - requiresCharging: false, - requiresDeviceIdle: false, - requiresStorageNotLow: true, - backoffPolicy: 'exponential', - backoffDelay: 30000, // 30 seconds - maxRetries: 3 - } -}; - -/** - * TimeSafari Android Configuration Manager - */ -export class TimeSafariAndroidConfigManager { - private config: TimeSafariAndroidConfig; - - constructor(config?: Partial) { - this.config = { ...DEFAULT_TIMESAFARI_ANDROID_CONFIG, ...config }; - } - - /** - * Get notification channel configuration - */ - getNotificationChannel(channelId: string): NotificationChannelConfig | undefined { - return this.config.notificationChannels.find(channel => channel.id === channelId); - } - - /** - * Get all notification channels - */ - getAllNotificationChannels(): NotificationChannelConfig[] { - return this.config.notificationChannels; - } - - /** - * Get required permissions - */ - getRequiredPermissions(): AndroidPermission[] { - return this.config.permissions.filter(permission => permission.required); - } - - /** - * Get runtime permissions - */ - getRuntimePermissions(): AndroidPermission[] { - return this.config.permissions.filter(permission => permission.runtime); - } - - /** - * Get permissions by category - */ - getPermissionsByCategory(category: string): AndroidPermission[] { - return this.config.permissions.filter(permission => permission.category === category); - } - - /** - * Get battery optimization configuration - */ - getBatteryOptimizationConfig(): BatteryOptimizationConfig { - return this.config.batteryOptimization; - } - - /** - * Get power management configuration - */ - getPowerManagementConfig(): PowerManagementConfig { - return this.config.powerManagement; - } - - /** - * Get WorkManager constraints - */ - getWorkManagerConstraints(): WorkManagerConstraints { - return this.config.workManagerConstraints; - } - - /** - * Update configuration - */ - updateConfig(updates: Partial): void { - this.config = { ...this.config, ...updates }; - } - - /** - * Validate configuration - */ - validateConfig(): { valid: boolean; errors: string[] } { - const errors: string[] = []; - - // Validate notification channels - if (this.config.notificationChannels.length === 0) { - errors.push('At least one notification channel must be configured'); - } - - // Validate permissions - const requiredPermissions = this.getRequiredPermissions(); - if (requiredPermissions.length === 0) { - errors.push('At least one required permission must be configured'); - } - - // Validate WorkManager constraints - if (this.config.workManagerConstraints.maxRetries < 0) { - errors.push('WorkManager maxRetries must be non-negative'); - } - - if (this.config.workManagerConstraints.backoffDelay < 0) { - errors.push('WorkManager backoffDelay must be non-negative'); - } - - return { - valid: errors.length === 0, - errors - }; - } -} diff --git a/src/android/DailyNotificationDatabase.java b/src/android/DailyNotificationDatabase.java deleted file mode 100644 index 1128f4b..0000000 --- a/src/android/DailyNotificationDatabase.java +++ /dev/null @@ -1,312 +0,0 @@ -/** - * DailyNotificationDatabase.java - * - * SQLite database management for shared notification storage - * Implements the three-table schema with WAL mode for concurrent access - * - * @author Matthew Raymer - * @version 1.0.0 - */ - -package com.timesafari.dailynotification; - -import android.content.Context; -import android.database.sqlite.SQLiteDatabase; -import android.database.sqlite.SQLiteOpenHelper; -import android.util.Log; - -import java.io.File; - -/** - * Manages SQLite database for shared notification storage - * - * This class implements the shared database approach where: - * - App owns schema/migrations (PRAGMA user_version) - * - Plugin opens the same path with WAL mode - * - Background writes are short & serialized - * - Foreground reads proceed during background commits - */ -public class DailyNotificationDatabase extends SQLiteOpenHelper { - - private static final String TAG = "DailyNotificationDatabase"; - private static final String DATABASE_NAME = "daily_notifications.db"; - private static final int DATABASE_VERSION = 1; - - // Table names - public static final String TABLE_NOTIF_CONTENTS = "notif_contents"; - public static final String TABLE_NOTIF_DELIVERIES = "notif_deliveries"; - public static final String TABLE_NOTIF_CONFIG = "notif_config"; - - // Column names for notif_contents - public static final String COL_CONTENTS_ID = "id"; - public static final String COL_CONTENTS_SLOT_ID = "slot_id"; - public static final String COL_CONTENTS_PAYLOAD_JSON = "payload_json"; - public static final String COL_CONTENTS_FETCHED_AT = "fetched_at"; - public static final String COL_CONTENTS_ETAG = "etag"; - - // Column names for notif_deliveries - public static final String COL_DELIVERIES_ID = "id"; - public static final String COL_DELIVERIES_SLOT_ID = "slot_id"; - public static final String COL_DELIVERIES_FIRE_AT = "fire_at"; - public static final String COL_DELIVERIES_DELIVERED_AT = "delivered_at"; - public static final String COL_DELIVERIES_STATUS = "status"; - public static final String COL_DELIVERIES_ERROR_CODE = "error_code"; - public static final String COL_DELIVERIES_ERROR_MESSAGE = "error_message"; - - // Column names for notif_config - public static final String COL_CONFIG_K = "k"; - public static final String COL_CONFIG_V = "v"; - - // Status values - public static final String STATUS_SCHEDULED = "scheduled"; - public static final String STATUS_SHOWN = "shown"; - public static final String STATUS_ERROR = "error"; - public static final String STATUS_CANCELED = "canceled"; - - /** - * Constructor - * - * @param context Application context - * @param dbPath Database file path (null for default location) - */ - public DailyNotificationDatabase(Context context, String dbPath) { - super(context, dbPath != null ? dbPath : DATABASE_NAME, null, DATABASE_VERSION); - } - - /** - * Constructor with default database location - * - * @param context Application context - */ - public DailyNotificationDatabase(Context context) { - this(context, null); - } - - @Override - public void onCreate(SQLiteDatabase db) { - Log.d(TAG, "Creating database tables"); - - // Configure database for WAL mode and concurrent access - configureDatabase(db); - - // Create tables - createTables(db); - - // Create indexes - createIndexes(db); - - Log.i(TAG, "Database created successfully"); - } - - @Override - public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { - Log.d(TAG, "Upgrading database from version " + oldVersion + " to " + newVersion); - - // For now, drop and recreate tables - // In production, implement proper migration logic - dropTables(db); - onCreate(db); - - Log.i(TAG, "Database upgraded successfully"); - } - - @Override - public void onOpen(SQLiteDatabase db) { - super.onOpen(db); - - // Ensure WAL mode is enabled on every open - configureDatabase(db); - - // Verify schema version - verifySchemaVersion(db); - - Log.d(TAG, "Database opened with WAL mode"); - } - - /** - * Configure database for optimal performance and concurrency - * - * @param db SQLite database instance - */ - private void configureDatabase(SQLiteDatabase db) { - // Enable WAL mode for concurrent reads during writes - db.execSQL("PRAGMA journal_mode=WAL"); - - // Set synchronous mode to NORMAL for better performance - db.execSQL("PRAGMA synchronous=NORMAL"); - - // Set busy timeout to handle concurrent access - db.execSQL("PRAGMA busy_timeout=5000"); - - // Enable foreign key constraints - db.execSQL("PRAGMA foreign_keys=ON"); - - // Set cache size for better performance - db.execSQL("PRAGMA cache_size=1000"); - - Log.d(TAG, "Database configured with WAL mode and optimizations"); - } - - /** - * Create all database tables - * - * @param db SQLite database instance - */ - private void createTables(SQLiteDatabase db) { - // notif_contents: keep history, fast newest-first reads - String createContentsTable = String.format( - "CREATE TABLE IF NOT EXISTS %s(" + - "%s INTEGER PRIMARY KEY AUTOINCREMENT," + - "%s TEXT NOT NULL," + - "%s TEXT NOT NULL," + - "%s INTEGER NOT NULL," + // epoch ms - "%s TEXT," + - "UNIQUE(%s, %s)" + - ")", - TABLE_NOTIF_CONTENTS, - COL_CONTENTS_ID, - COL_CONTENTS_SLOT_ID, - COL_CONTENTS_PAYLOAD_JSON, - COL_CONTENTS_FETCHED_AT, - COL_CONTENTS_ETAG, - COL_CONTENTS_SLOT_ID, - COL_CONTENTS_FETCHED_AT - ); - - // notif_deliveries: track many deliveries per slot/time - String createDeliveriesTable = String.format( - "CREATE TABLE IF NOT EXISTS %s(" + - "%s INTEGER PRIMARY KEY AUTOINCREMENT," + - "%s TEXT NOT NULL," + - "%s INTEGER NOT NULL," + // epoch ms - "%s INTEGER," + // epoch ms - "%s TEXT NOT NULL DEFAULT '%s'," + - "%s TEXT," + - "%s TEXT" + - ")", - TABLE_NOTIF_DELIVERIES, - COL_DELIVERIES_ID, - COL_DELIVERIES_SLOT_ID, - COL_DELIVERIES_FIRE_AT, - COL_DELIVERIES_DELIVERED_AT, - COL_DELIVERIES_STATUS, - STATUS_SCHEDULED, - COL_DELIVERIES_ERROR_CODE, - COL_DELIVERIES_ERROR_MESSAGE - ); - - // notif_config: generic configuration KV - String createConfigTable = String.format( - "CREATE TABLE IF NOT EXISTS %s(" + - "%s TEXT PRIMARY KEY," + - "%s TEXT NOT NULL" + - ")", - TABLE_NOTIF_CONFIG, - COL_CONFIG_K, - COL_CONFIG_V - ); - - db.execSQL(createContentsTable); - db.execSQL(createDeliveriesTable); - db.execSQL(createConfigTable); - - Log.d(TAG, "Database tables created"); - } - - /** - * Create database indexes for optimal query performance - * - * @param db SQLite database instance - */ - private void createIndexes(SQLiteDatabase db) { - // Index for notif_contents: slot_id + fetched_at DESC for newest-first reads - String createContentsIndex = String.format( - "CREATE INDEX IF NOT EXISTS notif_idx_contents_slot_time ON %s(%s, %s DESC)", - TABLE_NOTIF_CONTENTS, - COL_CONTENTS_SLOT_ID, - COL_CONTENTS_FETCHED_AT - ); - - // Index for notif_deliveries: slot_id for delivery tracking - String createDeliveriesIndex = String.format( - "CREATE INDEX IF NOT EXISTS notif_idx_deliveries_slot ON %s(%s)", - TABLE_NOTIF_DELIVERIES, - COL_DELIVERIES_SLOT_ID - ); - - db.execSQL(createContentsIndex); - db.execSQL(createDeliveriesIndex); - - Log.d(TAG, "Database indexes created"); - } - - /** - * Drop all database tables (for migration) - * - * @param db SQLite database instance - */ - private void dropTables(SQLiteDatabase db) { - db.execSQL("DROP TABLE IF EXISTS " + TABLE_NOTIF_CONTENTS); - db.execSQL("DROP TABLE IF EXISTS " + TABLE_NOTIF_DELIVERIES); - db.execSQL("DROP TABLE IF EXISTS " + TABLE_NOTIF_CONFIG); - - Log.d(TAG, "Database tables dropped"); - } - - /** - * Verify schema version compatibility - * - * @param db SQLite database instance - */ - private void verifySchemaVersion(SQLiteDatabase db) { - try { - // Get current user_version - android.database.Cursor cursor = db.rawQuery("PRAGMA user_version", null); - int currentVersion = 0; - if (cursor.moveToFirst()) { - currentVersion = cursor.getInt(0); - } - cursor.close(); - - Log.d(TAG, "Current schema version: " + currentVersion); - - // Set user_version to match our DATABASE_VERSION - db.execSQL("PRAGMA user_version=" + DATABASE_VERSION); - - Log.d(TAG, "Schema version verified and set to " + DATABASE_VERSION); - - } catch (Exception e) { - Log.e(TAG, "Error verifying schema version", e); - throw new RuntimeException("Schema version verification failed", e); - } - } - - /** - * Get database file path - * - * @return Database file path - */ - public String getDatabasePath() { - return getReadableDatabase().getPath(); - } - - /** - * Check if database file exists - * - * @return true if database file exists - */ - public boolean databaseExists() { - File dbFile = new File(getDatabasePath()); - return dbFile.exists(); - } - - /** - * Get database size in bytes - * - * @return Database file size in bytes - */ - public long getDatabaseSize() { - File dbFile = new File(getDatabasePath()); - return dbFile.exists() ? dbFile.length() : 0; - } -}