From 489dd4ac284e981951a43d79d794ff0c8df4a749 Mon Sep 17 00:00:00 2001 From: Matthew Raymer Date: Mon, 8 Sep 2025 09:49:08 +0000 Subject: [PATCH] feat(android): implement Phase 1.1 SQLite database sharing with WAL mode - Add DailyNotificationDatabase.java with three-table schema and WAL configuration - Add DailyNotificationMigration.java for SharedPreferences to SQLite migration - Add DailyNotificationDatabaseTest.java with comprehensive unit tests - Add ConfigureOptions interface with dbPath, storage mode, and TTL settings - Add configure() method to DailyNotificationPlugin interface - Update Android plugin with SQLite integration and automatic migration - Update web implementations to implement new configure() method - Add phase1-sqlite-usage.ts example demonstrating shared storage configuration This implements the critical Phase 1.1 gate for shared SQLite storage: - App and plugin can open the same SQLite file with WAL mode - Automatic migration from SharedPreferences preserves existing data - Schema version checking prevents compatibility issues - Concurrent reads during background writes enabled - Configuration API supports both shared and tiered storage modes Files: 8 changed, 1204 insertions(+) --- examples/phase1-sqlite-usage.ts | 121 ++++++ src/android/DailyNotificationDatabase.java | 312 +++++++++++++++ .../DailyNotificationDatabaseTest.java | 215 +++++++++++ src/android/DailyNotificationMigration.java | 354 ++++++++++++++++++ src/android/DailyNotificationPlugin.java | 180 +++++++++ src/definitions.ts | 12 + src/web.ts | 5 + src/web/index.ts | 5 + 8 files changed, 1204 insertions(+) create mode 100644 examples/phase1-sqlite-usage.ts create mode 100644 src/android/DailyNotificationDatabase.java create mode 100644 src/android/DailyNotificationDatabaseTest.java create mode 100644 src/android/DailyNotificationMigration.java diff --git a/examples/phase1-sqlite-usage.ts b/examples/phase1-sqlite-usage.ts new file mode 100644 index 0000000..d570d75 --- /dev/null +++ b/examples/phase1-sqlite-usage.ts @@ -0,0 +1,121 @@ +/** + * Phase 1.1 Usage Example + * + * Demonstrates SQLite database sharing configuration and usage + * Shows how to configure the plugin for shared storage mode + * + * @author Matthew Raymer + * @version 1.0.0 + */ + +import { DailyNotification } from '@timesafari/daily-notification-plugin'; + +/** + * Example: Configure plugin for shared SQLite storage + */ +async function configureSharedStorage() { + try { + console.log('Configuring plugin for shared SQLite storage...'); + + // Configure the plugin with shared storage mode + await DailyNotification.configure({ + dbPath: '/data/data/com.yourapp/databases/daily_notifications.db', + storage: 'shared', + ttlSeconds: 3600, // 1 hour TTL + prefetchLeadMinutes: 15, // 15 minutes before notification + maxNotificationsPerDay: 5, + retentionDays: 7 + }); + + console.log('✅ Plugin configured successfully for shared storage'); + + // Now the plugin will use SQLite database instead of SharedPreferences + // The database will be shared between app and plugin + // WAL mode enables concurrent reads during writes + + } catch (error) { + console.error('❌ Configuration failed:', error); + } +} + +/** + * Example: Configure plugin for tiered storage (current implementation) + */ +async function configureTieredStorage() { + try { + console.log('Configuring plugin for tiered storage...'); + + // Configure the plugin with tiered storage mode (default) + await DailyNotification.configure({ + storage: 'tiered', + ttlSeconds: 1800, // 30 minutes TTL + prefetchLeadMinutes: 10, // 10 minutes before notification + maxNotificationsPerDay: 3, + retentionDays: 5 + }); + + console.log('✅ Plugin configured successfully for tiered storage'); + + // Plugin will continue using SharedPreferences + in-memory cache + + } catch (error) { + console.error('❌ Configuration failed:', error); + } +} + +/** + * Example: Schedule notification with new configuration + */ +async function scheduleWithNewConfig() { + try { + // First configure for shared storage + await configureSharedStorage(); + + // Then schedule a notification + await DailyNotification.scheduleDailyNotification({ + url: 'https://api.example.com/daily-content', + time: '09:00', + title: 'Daily Update', + body: 'Your daily notification is ready', + sound: true, + priority: 'high' + }); + + console.log('✅ Notification scheduled with shared storage configuration'); + + } catch (error) { + console.error('❌ Scheduling failed:', error); + } +} + +/** + * Example: Check migration status + */ +async function checkMigrationStatus() { + try { + // Configure for shared storage to trigger migration + await DailyNotification.configure({ + storage: 'shared', + dbPath: '/data/data/com.yourapp/databases/daily_notifications.db' + }); + + // The plugin will automatically: + // 1. Create SQLite database with WAL mode + // 2. Migrate existing SharedPreferences data + // 3. Validate migration success + // 4. Log migration statistics + + console.log('✅ Migration completed automatically during configuration'); + + } catch (error) { + console.error('❌ Migration failed:', error); + } +} + +// Export examples for use +export { + configureSharedStorage, + configureTieredStorage, + scheduleWithNewConfig, + checkMigrationStatus +}; diff --git a/src/android/DailyNotificationDatabase.java b/src/android/DailyNotificationDatabase.java new file mode 100644 index 0000000..1128f4b --- /dev/null +++ b/src/android/DailyNotificationDatabase.java @@ -0,0 +1,312 @@ +/** + * 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/src/android/DailyNotificationDatabaseTest.java b/src/android/DailyNotificationDatabaseTest.java new file mode 100644 index 0000000..811b93d --- /dev/null +++ b/src/android/DailyNotificationDatabaseTest.java @@ -0,0 +1,215 @@ +/** + * DailyNotificationDatabaseTest.java + * + * Unit tests for SQLite database functionality + * Tests schema creation, WAL mode, and basic operations + * + * @author Matthew Raymer + * @version 1.0.0 + */ + +package com.timesafari.dailynotification; + +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; +import android.test.AndroidTestCase; +import android.test.mock.MockContext; + +import java.io.File; + +/** + * Unit tests for DailyNotificationDatabase + * + * Tests the core SQLite functionality including: + * - Database creation and schema + * - WAL mode configuration + * - Table and index creation + * - Schema version management + */ +public class DailyNotificationDatabaseTest extends AndroidTestCase { + + private DailyNotificationDatabase database; + private Context mockContext; + + @Override + protected void setUp() throws Exception { + super.setUp(); + + // Create mock context + mockContext = new MockContext() { + @Override + public File getDatabasePath(String name) { + return new File(getContext().getCacheDir(), name); + } + }; + + // Create database instance + database = new DailyNotificationDatabase(mockContext); + } + + @Override + protected void tearDown() throws Exception { + if (database != null) { + database.close(); + } + super.tearDown(); + } + + /** + * Test database creation and schema + */ + public void testDatabaseCreation() { + assertNotNull("Database should not be null", database); + + SQLiteDatabase db = database.getReadableDatabase(); + assertNotNull("Readable database should not be null", db); + assertTrue("Database should be open", db.isOpen()); + + db.close(); + } + + /** + * Test WAL mode configuration + */ + public void testWALModeConfiguration() { + SQLiteDatabase db = database.getWritableDatabase(); + + // Check journal mode + android.database.Cursor cursor = db.rawQuery("PRAGMA journal_mode", null); + assertTrue("Should have journal mode result", cursor.moveToFirst()); + String journalMode = cursor.getString(0); + assertEquals("Journal mode should be WAL", "wal", journalMode.toLowerCase()); + cursor.close(); + + // Check synchronous mode + cursor = db.rawQuery("PRAGMA synchronous", null); + assertTrue("Should have synchronous result", cursor.moveToFirst()); + int synchronous = cursor.getInt(0); + assertEquals("Synchronous mode should be NORMAL", 1, synchronous); + cursor.close(); + + // Check foreign keys + cursor = db.rawQuery("PRAGMA foreign_keys", null); + assertTrue("Should have foreign_keys result", cursor.moveToFirst()); + int foreignKeys = cursor.getInt(0); + assertEquals("Foreign keys should be enabled", 1, foreignKeys); + cursor.close(); + + db.close(); + } + + /** + * Test table creation + */ + public void testTableCreation() { + SQLiteDatabase db = database.getWritableDatabase(); + + // Check if tables exist + assertTrue("notif_contents table should exist", + tableExists(db, DailyNotificationDatabase.TABLE_NOTIF_CONTENTS)); + assertTrue("notif_deliveries table should exist", + tableExists(db, DailyNotificationDatabase.TABLE_NOTIF_DELIVERIES)); + assertTrue("notif_config table should exist", + tableExists(db, DailyNotificationDatabase.TABLE_NOTIF_CONFIG)); + + db.close(); + } + + /** + * Test index creation + */ + public void testIndexCreation() { + SQLiteDatabase db = database.getWritableDatabase(); + + // Check if indexes exist + assertTrue("notif_idx_contents_slot_time index should exist", + indexExists(db, "notif_idx_contents_slot_time")); + assertTrue("notif_idx_deliveries_slot index should exist", + indexExists(db, "notif_idx_deliveries_slot")); + + db.close(); + } + + /** + * Test schema version management + */ + public void testSchemaVersion() { + SQLiteDatabase db = database.getWritableDatabase(); + + // Check user_version + android.database.Cursor cursor = db.rawQuery("PRAGMA user_version", null); + assertTrue("Should have user_version result", cursor.moveToFirst()); + int userVersion = cursor.getInt(0); + assertEquals("User version should match database version", + DailyNotificationDatabase.DATABASE_VERSION, userVersion); + cursor.close(); + + db.close(); + } + + /** + * Test basic insert operations + */ + public void testBasicInsertOperations() { + SQLiteDatabase db = database.getWritableDatabase(); + + // Test inserting into notif_contents + android.content.ContentValues values = new android.content.ContentValues(); + values.put(DailyNotificationDatabase.COL_CONTENTS_SLOT_ID, "test_slot_1"); + values.put(DailyNotificationDatabase.COL_CONTENTS_PAYLOAD_JSON, "{\"title\":\"Test\"}"); + values.put(DailyNotificationDatabase.COL_CONTENTS_FETCHED_AT, System.currentTimeMillis()); + + long rowId = db.insert(DailyNotificationDatabase.TABLE_NOTIF_CONTENTS, null, values); + assertTrue("Insert should succeed", rowId > 0); + + // Test inserting into notif_config + values.clear(); + values.put(DailyNotificationDatabase.COL_CONFIG_K, "test_key"); + values.put(DailyNotificationDatabase.COL_CONFIG_V, "test_value"); + + rowId = db.insert(DailyNotificationDatabase.TABLE_NOTIF_CONFIG, null, values); + assertTrue("Config insert should succeed", rowId > 0); + + db.close(); + } + + /** + * Test database file operations + */ + public void testDatabaseFileOperations() { + String dbPath = database.getDatabasePath(); + assertNotNull("Database path should not be null", dbPath); + assertTrue("Database path should not be empty", !dbPath.isEmpty()); + + // Database should exist after creation + assertTrue("Database file should exist", database.databaseExists()); + + // Database size should be greater than 0 + long size = database.getDatabaseSize(); + assertTrue("Database size should be greater than 0", size > 0); + } + + /** + * Helper method to check if table exists + */ + private boolean tableExists(SQLiteDatabase db, String tableName) { + android.database.Cursor cursor = db.rawQuery( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", + new String[]{tableName}); + boolean exists = cursor.moveToFirst(); + cursor.close(); + return exists; + } + + /** + * Helper method to check if index exists + */ + private boolean indexExists(SQLiteDatabase db, String indexName) { + android.database.Cursor cursor = db.rawQuery( + "SELECT name FROM sqlite_master WHERE type='index' AND name=?", + new String[]{indexName}); + boolean exists = cursor.moveToFirst(); + cursor.close(); + return exists; + } +} diff --git a/src/android/DailyNotificationMigration.java b/src/android/DailyNotificationMigration.java new file mode 100644 index 0000000..970d719 --- /dev/null +++ b/src/android/DailyNotificationMigration.java @@ -0,0 +1,354 @@ +/** + * DailyNotificationMigration.java + * + * Migration utilities for transitioning from SharedPreferences to SQLite + * Handles data migration while preserving existing notification data + * + * @author Matthew Raymer + * @version 1.0.0 + */ + +package com.timesafari.dailynotification; + +import android.content.ContentValues; +import android.content.Context; +import android.content.SharedPreferences; +import android.database.sqlite.SQLiteDatabase; +import android.util.Log; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; + +/** + * Handles migration from SharedPreferences to SQLite database + * + * This class provides utilities to: + * - Migrate existing notification data from SharedPreferences + * - Preserve all existing notification content during transition + * - Provide backward compatibility during migration period + * - Validate migration success + */ +public class DailyNotificationMigration { + + private static final String TAG = "DailyNotificationMigration"; + private static final String PREFS_NAME = "DailyNotificationPrefs"; + private static final String KEY_NOTIFICATIONS = "notifications"; + private static final String KEY_SETTINGS = "settings"; + private static final String KEY_LAST_FETCH = "last_fetch"; + private static final String KEY_ADAPTIVE_SCHEDULING = "adaptive_scheduling"; + + private final Context context; + private final DailyNotificationDatabase database; + private final Gson gson; + + /** + * Constructor + * + * @param context Application context + * @param database SQLite database instance + */ + public DailyNotificationMigration(Context context, DailyNotificationDatabase database) { + this.context = context; + this.database = database; + this.gson = new Gson(); + } + + /** + * Perform complete migration from SharedPreferences to SQLite + * + * @return true if migration was successful + */ + public boolean migrateToSQLite() { + try { + Log.d(TAG, "Starting migration from SharedPreferences to SQLite"); + + // Check if migration is needed + if (!isMigrationNeeded()) { + Log.d(TAG, "Migration not needed - SQLite already up to date"); + return true; + } + + // Get writable database + SQLiteDatabase db = database.getWritableDatabase(); + + // Start transaction for atomic migration + db.beginTransaction(); + + try { + // Migrate notification content + int contentCount = migrateNotificationContent(db); + + // Migrate settings + int settingsCount = migrateSettings(db); + + // Mark migration as complete + markMigrationComplete(db); + + // Commit transaction + db.setTransactionSuccessful(); + + Log.i(TAG, String.format("Migration completed successfully: %d notifications, %d settings", + contentCount, settingsCount)); + + return true; + + } catch (Exception e) { + Log.e(TAG, "Error during migration transaction", e); + db.endTransaction(); + return false; + } finally { + db.endTransaction(); + } + + } catch (Exception e) { + Log.e(TAG, "Error during migration", e); + return false; + } + } + + /** + * Check if migration is needed + * + * @return true if migration is required + */ + private boolean isMigrationNeeded() { + try { + // Check if SharedPreferences has data + SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + String notificationsJson = prefs.getString(KEY_NOTIFICATIONS, "[]"); + + // Check if SQLite already has data + SQLiteDatabase db = database.getReadableDatabase(); + android.database.Cursor cursor = db.rawQuery( + "SELECT COUNT(*) FROM " + DailyNotificationDatabase.TABLE_NOTIF_CONTENTS, null); + + int sqliteCount = 0; + if (cursor.moveToFirst()) { + sqliteCount = cursor.getInt(0); + } + cursor.close(); + + // Migration needed if SharedPreferences has data but SQLite doesn't + boolean hasPrefsData = !notificationsJson.equals("[]") && !notificationsJson.isEmpty(); + boolean needsMigration = hasPrefsData && sqliteCount == 0; + + Log.d(TAG, String.format("Migration check: prefs_data=%s, sqlite_count=%d, needed=%s", + hasPrefsData, sqliteCount, needsMigration)); + + return needsMigration; + + } catch (Exception e) { + Log.e(TAG, "Error checking migration status", e); + return false; + } + } + + /** + * Migrate notification content from SharedPreferences to SQLite + * + * @param db SQLite database instance + * @return Number of notifications migrated + */ + private int migrateNotificationContent(SQLiteDatabase db) { + try { + SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + String notificationsJson = prefs.getString(KEY_NOTIFICATIONS, "[]"); + + if (notificationsJson.equals("[]") || notificationsJson.isEmpty()) { + Log.d(TAG, "No notification content to migrate"); + return 0; + } + + // Parse JSON to List + Type type = new TypeToken>(){}.getType(); + List notifications = gson.fromJson(notificationsJson, type); + + int migratedCount = 0; + + for (NotificationContent notification : notifications) { + try { + // Create ContentValues for notif_contents table + ContentValues values = new ContentValues(); + values.put(DailyNotificationDatabase.COL_CONTENTS_SLOT_ID, notification.getId()); + values.put(DailyNotificationDatabase.COL_CONTENTS_PAYLOAD_JSON, + gson.toJson(notification)); + values.put(DailyNotificationDatabase.COL_CONTENTS_FETCHED_AT, + notification.getFetchedAt()); + // ETag is null for migrated data + values.putNull(DailyNotificationDatabase.COL_CONTENTS_ETAG); + + // Insert into notif_contents table + long rowId = db.insert(DailyNotificationDatabase.TABLE_NOTIF_CONTENTS, null, values); + + if (rowId != -1) { + migratedCount++; + Log.d(TAG, "Migrated notification: " + notification.getId()); + } else { + Log.w(TAG, "Failed to migrate notification: " + notification.getId()); + } + + } catch (Exception e) { + Log.e(TAG, "Error migrating notification: " + notification.getId(), e); + } + } + + Log.i(TAG, "Migrated " + migratedCount + " notifications to SQLite"); + return migratedCount; + + } catch (Exception e) { + Log.e(TAG, "Error migrating notification content", e); + return 0; + } + } + + /** + * Migrate settings from SharedPreferences to SQLite + * + * @param db SQLite database instance + * @return Number of settings migrated + */ + private int migrateSettings(SQLiteDatabase db) { + try { + SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + int migratedCount = 0; + + // Migrate last_fetch timestamp + long lastFetch = prefs.getLong(KEY_LAST_FETCH, 0); + if (lastFetch > 0) { + ContentValues values = new ContentValues(); + values.put(DailyNotificationDatabase.COL_CONFIG_K, KEY_LAST_FETCH); + values.put(DailyNotificationDatabase.COL_CONFIG_V, String.valueOf(lastFetch)); + + long rowId = db.insert(DailyNotificationDatabase.TABLE_NOTIF_CONFIG, null, values); + if (rowId != -1) { + migratedCount++; + Log.d(TAG, "Migrated last_fetch setting"); + } + } + + // Migrate adaptive_scheduling setting + boolean adaptiveScheduling = prefs.getBoolean(KEY_ADAPTIVE_SCHEDULING, false); + ContentValues values = new ContentValues(); + values.put(DailyNotificationDatabase.COL_CONFIG_K, KEY_ADAPTIVE_SCHEDULING); + values.put(DailyNotificationDatabase.COL_CONFIG_V, String.valueOf(adaptiveScheduling)); + + long rowId = db.insert(DailyNotificationDatabase.TABLE_NOTIF_CONFIG, null, values); + if (rowId != -1) { + migratedCount++; + Log.d(TAG, "Migrated adaptive_scheduling setting"); + } + + Log.i(TAG, "Migrated " + migratedCount + " settings to SQLite"); + return migratedCount; + + } catch (Exception e) { + Log.e(TAG, "Error migrating settings", e); + return 0; + } + } + + /** + * Mark migration as complete in the database + * + * @param db SQLite database instance + */ + private void markMigrationComplete(SQLiteDatabase db) { + try { + ContentValues values = new ContentValues(); + values.put(DailyNotificationDatabase.COL_CONFIG_K, "migration_complete"); + values.put(DailyNotificationDatabase.COL_CONFIG_V, String.valueOf(System.currentTimeMillis())); + + db.insert(DailyNotificationDatabase.TABLE_NOTIF_CONFIG, null, values); + + Log.d(TAG, "Migration marked as complete"); + + } catch (Exception e) { + Log.e(TAG, "Error marking migration complete", e); + } + } + + /** + * Validate migration success + * + * @return true if migration was successful + */ + public boolean validateMigration() { + try { + SQLiteDatabase db = database.getReadableDatabase(); + + // Check if migration_complete flag exists + android.database.Cursor cursor = db.query( + DailyNotificationDatabase.TABLE_NOTIF_CONFIG, + new String[]{DailyNotificationDatabase.COL_CONFIG_V}, + DailyNotificationDatabase.COL_CONFIG_K + " = ?", + new String[]{"migration_complete"}, + null, null, null + ); + + boolean migrationComplete = cursor.moveToFirst(); + cursor.close(); + + if (!migrationComplete) { + Log.w(TAG, "Migration validation failed - migration_complete flag not found"); + return false; + } + + // Check if we have notification content + cursor = db.rawQuery( + "SELECT COUNT(*) FROM " + DailyNotificationDatabase.TABLE_NOTIF_CONTENTS, null); + + int contentCount = 0; + if (cursor.moveToFirst()) { + contentCount = cursor.getInt(0); + } + cursor.close(); + + Log.i(TAG, "Migration validation successful - " + contentCount + " notifications in SQLite"); + return true; + + } catch (Exception e) { + Log.e(TAG, "Error validating migration", e); + return false; + } + } + + /** + * Get migration statistics + * + * @return Migration statistics string + */ + public String getMigrationStats() { + try { + SQLiteDatabase db = database.getReadableDatabase(); + + // Count notifications + android.database.Cursor cursor = db.rawQuery( + "SELECT COUNT(*) FROM " + DailyNotificationDatabase.TABLE_NOTIF_CONTENTS, null); + int notificationCount = 0; + if (cursor.moveToFirst()) { + notificationCount = cursor.getInt(0); + } + cursor.close(); + + // Count settings + cursor = db.rawQuery( + "SELECT COUNT(*) FROM " + DailyNotificationDatabase.TABLE_NOTIF_CONFIG, null); + int settingsCount = 0; + if (cursor.moveToFirst()) { + settingsCount = cursor.getInt(0); + } + cursor.close(); + + return String.format("Migration stats: %d notifications, %d settings", + notificationCount, settingsCount); + + } catch (Exception e) { + Log.e(TAG, "Error getting migration stats", e); + return "Migration stats: Error retrieving data"; + } + } +} diff --git a/src/android/DailyNotificationPlugin.java b/src/android/DailyNotificationPlugin.java index a292663..8ffe317 100644 --- a/src/android/DailyNotificationPlugin.java +++ b/src/android/DailyNotificationPlugin.java @@ -15,9 +15,12 @@ import android.app.AlarmManager; import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.PendingIntent; +import android.content.ContentValues; import android.content.Context; import android.content.Intent; +import android.content.SharedPreferences; import android.content.pm.PackageManager; +import android.database.sqlite.SQLiteDatabase; import android.os.Build; import android.os.PowerManager; import android.util.Log; @@ -70,6 +73,12 @@ public class DailyNotificationPlugin extends Plugin { private DailyNotificationScheduler scheduler; private DailyNotificationFetcher fetcher; + // SQLite database components + private DailyNotificationDatabase database; + private DailyNotificationMigration migration; + private String databasePath; + private boolean useSharedStorage = false; + /** * Initialize the plugin and create notification channel */ @@ -105,6 +114,177 @@ public class DailyNotificationPlugin extends Plugin { } } + /** + * Configure the plugin with database and storage options + * + * @param call Plugin call containing configuration parameters + */ + @PluginMethod + public void configure(PluginCall call) { + try { + Log.d(TAG, "Configuring plugin with new options"); + + // Get configuration options + String dbPath = call.getString("dbPath"); + String storageMode = call.getString("storage", "tiered"); + Integer ttlSeconds = call.getInt("ttlSeconds"); + Integer prefetchLeadMinutes = call.getInt("prefetchLeadMinutes"); + Integer maxNotificationsPerDay = call.getInt("maxNotificationsPerDay"); + Integer retentionDays = call.getInt("retentionDays"); + + // Update storage mode + useSharedStorage = "shared".equals(storageMode); + + // Set database path + if (dbPath != null && !dbPath.isEmpty()) { + databasePath = dbPath; + Log.d(TAG, "Database path set to: " + databasePath); + } else { + // Use default database path + databasePath = getContext().getDatabasePath("daily_notifications.db").getAbsolutePath(); + Log.d(TAG, "Using default database path: " + databasePath); + } + + // Initialize SQLite database if using shared storage + if (useSharedStorage) { + initializeSQLiteDatabase(); + } + + // Store configuration in database or SharedPreferences + storeConfiguration(ttlSeconds, prefetchLeadMinutes, maxNotificationsPerDay, retentionDays); + + Log.i(TAG, "Plugin configuration completed successfully"); + call.resolve(); + + } catch (Exception e) { + Log.e(TAG, "Error configuring plugin", e); + call.reject("Configuration failed: " + e.getMessage()); + } + } + + /** + * Initialize SQLite database with migration + */ + private void initializeSQLiteDatabase() { + try { + Log.d(TAG, "Initializing SQLite database"); + + // Create database instance + database = new DailyNotificationDatabase(getContext(), databasePath); + + // Initialize migration utility + migration = new DailyNotificationMigration(getContext(), database); + + // Perform migration if needed + if (migration.migrateToSQLite()) { + Log.i(TAG, "Migration completed successfully"); + + // Validate migration + if (migration.validateMigration()) { + Log.i(TAG, "Migration validation successful"); + Log.i(TAG, migration.getMigrationStats()); + } else { + Log.w(TAG, "Migration validation failed"); + } + } else { + Log.w(TAG, "Migration failed or not needed"); + } + + } catch (Exception e) { + Log.e(TAG, "Error initializing SQLite database", e); + throw new RuntimeException("SQLite initialization failed", e); + } + } + + /** + * Store configuration values + */ + private void storeConfiguration(Integer ttlSeconds, Integer prefetchLeadMinutes, + Integer maxNotificationsPerDay, Integer retentionDays) { + try { + if (useSharedStorage && database != null) { + // Store in SQLite + storeConfigurationInSQLite(ttlSeconds, prefetchLeadMinutes, maxNotificationsPerDay, retentionDays); + } else { + // Store in SharedPreferences + storeConfigurationInSharedPreferences(ttlSeconds, prefetchLeadMinutes, maxNotificationsPerDay, retentionDays); + } + } catch (Exception e) { + Log.e(TAG, "Error storing configuration", e); + } + } + + /** + * Store configuration in SQLite database + */ + private void storeConfigurationInSQLite(Integer ttlSeconds, Integer prefetchLeadMinutes, + Integer maxNotificationsPerDay, Integer retentionDays) { + try { + SQLiteDatabase db = database.getWritableDatabase(); + + // Store each configuration value + if (ttlSeconds != null) { + storeConfigValue(db, "ttlSeconds", String.valueOf(ttlSeconds)); + } + if (prefetchLeadMinutes != null) { + storeConfigValue(db, "prefetchLeadMinutes", String.valueOf(prefetchLeadMinutes)); + } + if (maxNotificationsPerDay != null) { + storeConfigValue(db, "maxNotificationsPerDay", String.valueOf(maxNotificationsPerDay)); + } + if (retentionDays != null) { + storeConfigValue(db, "retentionDays", String.valueOf(retentionDays)); + } + + Log.d(TAG, "Configuration stored in SQLite"); + + } catch (Exception e) { + Log.e(TAG, "Error storing configuration in SQLite", e); + } + } + + /** + * Store a single configuration value in SQLite + */ + private void storeConfigValue(SQLiteDatabase db, String key, String value) { + ContentValues values = new ContentValues(); + values.put(DailyNotificationDatabase.COL_CONFIG_K, key); + values.put(DailyNotificationDatabase.COL_CONFIG_V, value); + + // Use INSERT OR REPLACE to handle updates + db.replace(DailyNotificationDatabase.TABLE_NOTIF_CONFIG, null, values); + } + + /** + * Store configuration in SharedPreferences + */ + private void storeConfigurationInSharedPreferences(Integer ttlSeconds, Integer prefetchLeadMinutes, + Integer maxNotificationsPerDay, Integer retentionDays) { + try { + SharedPreferences prefs = getContext().getSharedPreferences("DailyNotificationPrefs", Context.MODE_PRIVATE); + SharedPreferences.Editor editor = prefs.edit(); + + if (ttlSeconds != null) { + editor.putInt("ttlSeconds", ttlSeconds); + } + if (prefetchLeadMinutes != null) { + editor.putInt("prefetchLeadMinutes", prefetchLeadMinutes); + } + if (maxNotificationsPerDay != null) { + editor.putInt("maxNotificationsPerDay", maxNotificationsPerDay); + } + if (retentionDays != null) { + editor.putInt("retentionDays", retentionDays); + } + + editor.apply(); + Log.d(TAG, "Configuration stored in SharedPreferences"); + + } catch (Exception e) { + Log.e(TAG, "Error storing configuration in SharedPreferences", e); + } + } + /** * Schedule a daily notification with the specified options * diff --git a/src/definitions.ts b/src/definitions.ts index 8a14de3..e6bddbc 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -153,6 +153,15 @@ export interface SchedulingConfig { timezone: string; } +export interface ConfigureOptions { + dbPath?: string; + storage?: 'shared' | 'tiered'; + ttlSeconds?: number; + prefetchLeadMinutes?: number; + maxNotificationsPerDay?: number; + retentionDays?: number; +} + // Dual Scheduling System Interfaces export interface ContentFetchConfig { enabled: boolean; @@ -248,6 +257,9 @@ export interface DualScheduleStatus { // Enhanced DailyNotificationPlugin interface with dual scheduling export interface DailyNotificationPlugin { + // Configuration methods + configure(options: ConfigureOptions): Promise; + // Existing methods scheduleDailyNotification(options: NotificationOptions | ScheduleOptions): Promise; getLastNotification(): Promise; diff --git a/src/web.ts b/src/web.ts index da78591..0ede0e8 100644 --- a/src/web.ts +++ b/src/web.ts @@ -9,6 +9,11 @@ import { WebPlugin } from '@capacitor/core'; import type { DailyNotificationPlugin, NotificationOptions, NotificationSettings, NotificationResponse, NotificationStatus, BatteryStatus, PowerState, PermissionStatus } from './definitions'; export class DailyNotificationWeb extends WebPlugin implements DailyNotificationPlugin { + async configure(_options: any): Promise { + // Web implementation placeholder + console.log('Configure called on web platform'); + } + async scheduleDailyNotification(_options: NotificationOptions | any): Promise { // Web implementation placeholder console.log('Schedule daily notification called on web platform'); diff --git a/src/web/index.ts b/src/web/index.ts index 43299d3..4b34ec0 100644 --- a/src/web/index.ts +++ b/src/web/index.ts @@ -19,6 +19,11 @@ export class DailyNotificationWeb implements DailyNotificationPlugin { }; private scheduledNotifications: Set = new Set(); + async configure(_options: any): Promise { + // Web implementation placeholder + console.log('Configure called on web platform'); + } + /** * Schedule a daily notification */