/** * 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; } }