feat(android): consolidate databases and add prefetch scheduling

Consolidate Java and Kotlin database implementations into unified
schema, add delayed prefetch scheduling, and fix notification
delivery issues.

Database Consolidation:
- Merge Java DailyNotificationDatabase into Kotlin DatabaseSchema
- Add migration path from v1 to v2 unified schema
- Include all entities: ContentCache, Schedule, Callback, History,
  NotificationContentEntity, NotificationDeliveryEntity,
  NotificationConfigEntity
- Add @JvmStatic getInstance() for Java interoperability
- Update DailyNotificationWorker and DailyNotificationStorageRoom
  to use unified database

Prefetch Functionality:
- Add scheduleDelayedFetch() to FetchWorker for 5-minute prefetch
  before notifications
- Support delayed WorkManager scheduling with initialDelay
- Update scheduleDailyNotification() to optionally schedule prefetch
  when URL is provided

Notification Delivery Fixes:
- Register NotifyReceiver in AndroidManifest.xml (was missing,
  causing notifications not to fire)
- Add safe database initialization with lazy getDatabase() helper
- Prevent PluginLoadException on database init failure

Build Configuration:
- Add kotlin-android and kotlin-kapt plugins
- Configure Room annotation processor (kapt) for Kotlin
- Add Room KTX dependency for coroutines support
- Fix Gradle settings with pluginManagement blocks

Plugin Methods Added:
- checkPermissionStatus() - detailed permission status
- requestNotificationPermissions() - request POST_NOTIFICATIONS
- scheduleDailyNotification() - schedule with AlarmManager
- configureNativeFetcher() - configure native content fetcher
- Various status and configuration methods

Code Cleanup:
- Remove duplicate BootReceiver.java (keep Kotlin version)
- Remove duplicate DailyNotificationPlugin.java (keep Kotlin version)
- Remove old Java database implementation
- Add native fetcher SPI registry (@JvmStatic methods)

The unified database ensures schedule persistence across reboots
and provides a single source of truth for all plugin data.
Prefetch scheduling enables content caching before notifications
fire, improving offline-first reliability.
This commit is contained in:
Matthew Raymer
2025-11-06 06:28:00 +00:00
parent d9bdeb6d02
commit 18106e5ba8
17 changed files with 3310 additions and 3114 deletions

View File

@@ -1,206 +0,0 @@
/**
* BootReceiver.java
*
* Android Boot Receiver for DailyNotification plugin
* Handles system boot events to restore scheduled notifications
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.dailynotification;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.util.Log;
/**
* Broadcast receiver for system boot events
*
* This receiver is triggered when:
* - Device boots up (BOOT_COMPLETED)
* - App is updated (MY_PACKAGE_REPLACED)
* - Any package is updated (PACKAGE_REPLACED)
*
* It ensures that scheduled notifications are restored after system events
* that might have cleared the alarm manager.
*/
public class BootReceiver extends BroadcastReceiver {
private static final String TAG = "BootReceiver";
// Broadcast actions we handle
private static final String ACTION_LOCKED_BOOT_COMPLETED = "android.intent.action.LOCKED_BOOT_COMPLETED";
private static final String ACTION_BOOT_COMPLETED = "android.intent.action.BOOT_COMPLETED";
private static final String ACTION_MY_PACKAGE_REPLACED = "android.intent.action.MY_PACKAGE_REPLACED";
@Override
public void onReceive(Context context, Intent intent) {
if (intent == null || intent.getAction() == null) {
Log.w(TAG, "Received null intent or action");
return;
}
String action = intent.getAction();
Log.d(TAG, "Received broadcast: " + action);
try {
switch (action) {
case ACTION_LOCKED_BOOT_COMPLETED:
handleLockedBootCompleted(context);
break;
case ACTION_BOOT_COMPLETED:
handleBootCompleted(context);
break;
case ACTION_MY_PACKAGE_REPLACED:
handlePackageReplaced(context, intent);
break;
default:
Log.w(TAG, "Unknown action: " + action);
break;
}
} catch (Exception e) {
Log.e(TAG, "Error handling broadcast: " + action, e);
}
}
/**
* Handle locked boot completion (before user unlock)
*
* @param context Application context
*/
private void handleLockedBootCompleted(Context context) {
Log.i(TAG, "Locked boot completed - preparing for recovery");
try {
// Use device protected storage context for Direct Boot
Context deviceProtectedContext = context;
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
deviceProtectedContext = context.createDeviceProtectedStorageContext();
}
// Minimal work here - just log that we're ready
// Full recovery will happen on BOOT_COMPLETED when storage is available
Log.i(TAG, "Locked boot completed - ready for full recovery on unlock");
} catch (Exception e) {
Log.e(TAG, "Error during locked boot completion", e);
}
}
/**
* Handle device boot completion (after user unlock)
*
* @param context Application context
*/
private void handleBootCompleted(Context context) {
Log.i(TAG, "Device boot completed - restoring notifications");
try {
// Initialize components for recovery
DailyNotificationStorage storage = new DailyNotificationStorage(context);
android.app.AlarmManager alarmManager = (android.app.AlarmManager)
context.getSystemService(android.content.Context.ALARM_SERVICE);
DailyNotificationScheduler scheduler = new DailyNotificationScheduler(context, alarmManager);
// Perform boot recovery
boolean recoveryPerformed = performBootRecovery(context, storage, scheduler);
if (recoveryPerformed) {
Log.i(TAG, "Boot recovery completed successfully");
} else {
Log.d(TAG, "Boot recovery skipped (not needed or already performed)");
}
} catch (Exception e) {
Log.e(TAG, "Error during boot recovery", e);
}
}
/**
* Handle package replacement (app update)
*
* @param context Application context
* @param intent Broadcast intent
*/
private void handlePackageReplaced(Context context, Intent intent) {
Log.i(TAG, "Package replaced - restoring notifications");
try {
// Initialize components for recovery
DailyNotificationStorage storage = new DailyNotificationStorage(context);
android.app.AlarmManager alarmManager = (android.app.AlarmManager)
context.getSystemService(android.content.Context.ALARM_SERVICE);
DailyNotificationScheduler scheduler = new DailyNotificationScheduler(context, alarmManager);
// Perform package replacement recovery
boolean recoveryPerformed = performBootRecovery(context, storage, scheduler);
if (recoveryPerformed) {
Log.i(TAG, "Package replacement recovery completed successfully");
} else {
Log.d(TAG, "Package replacement recovery skipped (not needed or already performed)");
}
} catch (Exception e) {
Log.e(TAG, "Error during package replacement recovery", e);
}
}
/**
* Perform boot recovery by rescheduling notifications
*
* @param context Application context
* @param storage Notification storage
* @param scheduler Notification scheduler
* @return true if recovery was performed, false otherwise
*/
private boolean performBootRecovery(Context context, DailyNotificationStorage storage,
DailyNotificationScheduler scheduler) {
try {
Log.d(TAG, "DN|BOOT_RECOVERY_START");
// Get all notifications from storage
java.util.List<NotificationContent> notifications = storage.getAllNotifications();
if (notifications.isEmpty()) {
Log.d(TAG, "DN|BOOT_RECOVERY_SKIP no_notifications");
return false;
}
Log.d(TAG, "DN|BOOT_RECOVERY_FOUND count=" + notifications.size());
int recoveredCount = 0;
long currentTime = System.currentTimeMillis();
for (NotificationContent notification : notifications) {
try {
if (notification.getScheduledTime() > currentTime) {
boolean scheduled = scheduler.scheduleNotification(notification);
if (scheduled) {
recoveredCount++;
Log.d(TAG, "DN|BOOT_RECOVERY_OK id=" + notification.getId());
} else {
Log.w(TAG, "DN|BOOT_RECOVERY_FAIL id=" + notification.getId());
}
} else {
Log.d(TAG, "DN|BOOT_RECOVERY_SKIP_PAST id=" + notification.getId());
}
} catch (Exception e) {
Log.e(TAG, "DN|BOOT_RECOVERY_ERR id=" + notification.getId() + " err=" + e.getMessage(), e);
}
}
Log.i(TAG, "DN|BOOT_RECOVERY_COMPLETE recovered=" + recoveredCount + "/" + notifications.size());
return recoveredCount > 0;
} catch (Exception e) {
Log.e(TAG, "DN|BOOT_RECOVERY_ERR exception=" + e.getMessage(), e);
return false;
}
}
}

View File

@@ -563,9 +563,9 @@ public class DailyNotificationWorker extends Worker {
// 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());
// Use unified database (Kotlin schema with Java entities)
com.timesafari.dailynotification.DailyNotificationDatabase db =
com.timesafari.dailynotification.DailyNotificationDatabase.getInstance(getApplicationContext());
NotificationContentEntity entity = db.notificationContentDao().getNotificationById(notificationId);
if (entity != null) {
return mapEntityToContent(entity);

View File

@@ -1,15 +1,31 @@
package com.timesafari.dailynotification
import android.content.Context
import androidx.room.*
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.timesafari.dailynotification.entities.NotificationContentEntity
import com.timesafari.dailynotification.entities.NotificationDeliveryEntity
import com.timesafari.dailynotification.entities.NotificationConfigEntity
import com.timesafari.dailynotification.dao.NotificationContentDao
import com.timesafari.dailynotification.dao.NotificationDeliveryDao
import com.timesafari.dailynotification.dao.NotificationConfigDao
/**
* SQLite schema for Daily Notification Plugin
* Implements TTL-at-fire invariant and rolling window armed design
* Unified SQLite schema for Daily Notification Plugin
*
* This database consolidates both Kotlin and Java schemas into a single
* unified database. Contains all entities needed for:
* - Recurring schedule patterns (reboot recovery)
* - Content caching (offline-first)
* - Configuration management
* - Delivery tracking and analytics
* - Execution history
*
* Database name: daily_notification_plugin.db
*
* @author Matthew Raymer
* @version 1.1.0
* @version 2.0.0 - Unified schema consolidation
*/
@Entity(tableName = "content_cache")
data class ContentCache(
@@ -56,16 +72,201 @@ data class History(
)
@Database(
entities = [ContentCache::class, Schedule::class, Callback::class, History::class],
version = 1,
entities = [
// Kotlin entities (from original schema)
ContentCache::class,
Schedule::class,
Callback::class,
History::class,
// Java entities (merged from Java database)
NotificationContentEntity::class,
NotificationDeliveryEntity::class,
NotificationConfigEntity::class
],
version = 2, // Incremented for unified schema
exportSchema = false
)
@TypeConverters(Converters::class)
abstract class DailyNotificationDatabase : RoomDatabase() {
// Kotlin DAOs
abstract fun contentCacheDao(): ContentCacheDao
abstract fun scheduleDao(): ScheduleDao
abstract fun callbackDao(): CallbackDao
abstract fun historyDao(): HistoryDao
// Java DAOs (for compatibility with existing Java code)
abstract fun notificationContentDao(): NotificationContentDao
abstract fun notificationDeliveryDao(): NotificationDeliveryDao
abstract fun notificationConfigDao(): NotificationConfigDao
companion object {
@Volatile
private var INSTANCE: DailyNotificationDatabase? = null
private const val DATABASE_NAME = "daily_notification_plugin.db"
/**
* Get singleton instance of unified database
*
* @param context Application context
* @return Database instance
*/
fun getDatabase(context: Context): DailyNotificationDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
DailyNotificationDatabase::class.java,
DATABASE_NAME
)
.addMigrations(MIGRATION_1_2) // Migration from Kotlin-only to unified
.addCallback(roomCallback)
.build()
INSTANCE = instance
instance
}
}
/**
* Java-compatible static method (for existing Java code)
*
* @param context Application context
* @return Database instance
*/
@JvmStatic
fun getInstance(context: Context): DailyNotificationDatabase {
return getDatabase(context)
}
/**
* Room database callback for initialization
*/
private val roomCallback = object : RoomDatabase.Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db)
// Initialize default data if needed
}
override fun onOpen(db: SupportSQLiteDatabase) {
super.onOpen(db)
// Cleanup expired data on open
}
}
/**
* Migration from version 1 (Kotlin-only) to version 2 (unified)
*/
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
// Create Java entity tables
database.execSQL("""
CREATE TABLE IF NOT EXISTS notification_content (
id TEXT PRIMARY KEY NOT NULL,
plugin_version TEXT,
timesafari_did TEXT,
notification_type TEXT,
title TEXT,
body TEXT,
scheduled_time INTEGER NOT NULL,
timezone TEXT,
priority INTEGER NOT NULL,
vibration_enabled INTEGER NOT NULL,
sound_enabled INTEGER NOT NULL,
media_url TEXT,
encrypted_content TEXT,
encryption_key_id TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
ttl_seconds INTEGER NOT NULL,
delivery_status TEXT,
delivery_attempts INTEGER NOT NULL,
last_delivery_attempt INTEGER NOT NULL,
user_interaction_count INTEGER NOT NULL,
last_user_interaction INTEGER NOT NULL,
metadata TEXT
)
""".trimIndent())
database.execSQL("""
CREATE INDEX IF NOT EXISTS index_notification_content_timesafari_did
ON notification_content(timesafari_did)
""".trimIndent())
database.execSQL("""
CREATE INDEX IF NOT EXISTS index_notification_content_notification_type
ON notification_content(notification_type)
""".trimIndent())
database.execSQL("""
CREATE INDEX IF NOT EXISTS index_notification_content_scheduled_time
ON notification_content(scheduled_time)
""".trimIndent())
database.execSQL("""
CREATE TABLE IF NOT EXISTS notification_delivery (
id TEXT PRIMARY KEY NOT NULL,
notification_id TEXT,
timesafari_did TEXT,
delivery_timestamp INTEGER NOT NULL,
delivery_status TEXT,
delivery_method TEXT,
delivery_attempt_number INTEGER NOT NULL,
delivery_duration_ms INTEGER NOT NULL,
user_interaction_type TEXT,
user_interaction_timestamp INTEGER NOT NULL,
user_interaction_duration_ms INTEGER NOT NULL,
error_code TEXT,
error_message TEXT,
device_info TEXT,
network_info TEXT,
battery_level INTEGER NOT NULL,
doze_mode_active INTEGER NOT NULL,
exact_alarm_permission INTEGER NOT NULL,
notification_permission INTEGER NOT NULL,
metadata TEXT,
FOREIGN KEY(notification_id) REFERENCES notification_content(id) ON DELETE CASCADE
)
""".trimIndent())
database.execSQL("""
CREATE INDEX IF NOT EXISTS index_notification_delivery_notification_id
ON notification_delivery(notification_id)
""".trimIndent())
database.execSQL("""
CREATE INDEX IF NOT EXISTS index_notification_delivery_delivery_timestamp
ON notification_delivery(delivery_timestamp)
""".trimIndent())
database.execSQL("""
CREATE TABLE IF NOT EXISTS notification_config (
id TEXT PRIMARY KEY NOT NULL,
timesafari_did TEXT,
config_type TEXT,
config_key TEXT,
config_value TEXT,
config_data_type TEXT,
is_encrypted INTEGER NOT NULL,
encryption_key_id TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
ttl_seconds INTEGER NOT NULL,
is_active INTEGER NOT NULL,
metadata TEXT
)
""".trimIndent())
database.execSQL("""
CREATE INDEX IF NOT EXISTS index_notification_config_timesafari_did
ON notification_config(timesafari_did)
""".trimIndent())
database.execSQL("""
CREATE INDEX IF NOT EXISTS index_notification_config_config_type
ON notification_config(config_type)
""".trimIndent())
}
}
}
}
@Dao
@@ -76,12 +277,18 @@ interface ContentCacheDao {
@Query("SELECT * FROM content_cache ORDER BY fetchedAt DESC LIMIT 1")
suspend fun getLatest(): ContentCache?
@Query("SELECT * FROM content_cache ORDER BY fetchedAt DESC LIMIT :limit")
suspend fun getHistory(limit: Int): List<ContentCache>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(contentCache: ContentCache)
@Query("DELETE FROM content_cache WHERE fetchedAt < :cutoffTime")
suspend fun deleteOlderThan(cutoffTime: Long)
@Query("DELETE FROM content_cache")
suspend fun deleteAll()
@Query("SELECT COUNT(*) FROM content_cache")
suspend fun getCount(): Int
}
@@ -94,6 +301,15 @@ interface ScheduleDao {
@Query("SELECT * FROM schedules WHERE id = :id")
suspend fun getById(id: String): Schedule?
@Query("SELECT * FROM schedules")
suspend fun getAll(): List<Schedule>
@Query("SELECT * FROM schedules WHERE kind = :kind")
suspend fun getByKind(kind: String): List<Schedule>
@Query("SELECT * FROM schedules WHERE kind = :kind AND enabled = :enabled")
suspend fun getByKindAndEnabled(kind: String, enabled: Boolean): List<Schedule>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(schedule: Schedule)
@@ -102,6 +318,12 @@ interface ScheduleDao {
@Query("UPDATE schedules SET lastRunAt = :lastRunAt, nextRunAt = :nextRunAt WHERE id = :id")
suspend fun updateRunTimes(id: String, lastRunAt: Long?, nextRunAt: Long?)
@Query("DELETE FROM schedules WHERE id = :id")
suspend fun deleteById(id: String)
@Query("UPDATE schedules SET enabled = :enabled, cron = :cron, clockTime = :clockTime, jitterMs = :jitterMs, backoffPolicy = :backoffPolicy, stateJson = :stateJson WHERE id = :id")
suspend fun update(id: String, enabled: Boolean?, cron: String?, clockTime: String?, jitterMs: Int?, backoffPolicy: String?, stateJson: String?)
}
@Dao
@@ -109,9 +331,24 @@ interface CallbackDao {
@Query("SELECT * FROM callbacks WHERE enabled = 1")
suspend fun getEnabled(): List<Callback>
@Query("SELECT * FROM callbacks")
suspend fun getAll(): List<Callback>
@Query("SELECT * FROM callbacks WHERE enabled = :enabled")
suspend fun getByEnabled(enabled: Boolean): List<Callback>
@Query("SELECT * FROM callbacks WHERE id = :id")
suspend fun getById(id: String): Callback?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(callback: Callback)
@Query("UPDATE callbacks SET enabled = :enabled WHERE id = :id")
suspend fun setEnabled(id: String, enabled: Boolean)
@Query("UPDATE callbacks SET kind = :kind, target = :target, headersJson = :headersJson, enabled = :enabled WHERE id = :id")
suspend fun update(id: String, kind: String?, target: String?, headersJson: String?, enabled: Boolean?)
@Query("DELETE FROM callbacks WHERE id = :id")
suspend fun deleteById(id: String)
}
@@ -124,6 +361,12 @@ interface HistoryDao {
@Query("SELECT * FROM history WHERE occurredAt >= :since ORDER BY occurredAt DESC")
suspend fun getSince(since: Long): List<History>
@Query("SELECT * FROM history WHERE occurredAt >= :since AND kind = :kind ORDER BY occurredAt DESC LIMIT :limit")
suspend fun getSinceByKind(since: Long, kind: String, limit: Int): List<History>
@Query("SELECT * FROM history ORDER BY occurredAt DESC LIMIT :limit")
suspend fun getRecent(limit: Int): List<History>
@Query("DELETE FROM history WHERE occurredAt < :cutoffTime")
suspend fun deleteOlderThan(cutoffTime: Long)

View File

@@ -1,6 +1,7 @@
package com.timesafari.dailynotification
import android.content.Context
import android.os.SystemClock
import android.util.Log
import androidx.work.*
import kotlinx.coroutines.Dispatchers
@@ -41,7 +42,6 @@ class FetchWorker(
.setInputData(
Data.Builder()
.putString("url", config.url)
.putString("headers", config.headers?.toString())
.putInt("timeout", config.timeout ?: 30000)
.putInt("retryAttempts", config.retryAttempts ?: 3)
.putInt("retryDelay", config.retryDelay ?: 1000)
@@ -56,6 +56,103 @@ class FetchWorker(
workRequest
)
}
/**
* Schedule a delayed fetch for prefetch (5 minutes before notification)
*
* @param context Application context
* @param fetchTime When to fetch (in milliseconds since epoch)
* @param notificationTime When the notification will be shown (in milliseconds since epoch)
* @param url Optional URL to fetch from (if null, generates mock content)
*/
fun scheduleDelayedFetch(
context: Context,
fetchTime: Long,
notificationTime: Long,
url: String? = null
) {
val currentTime = System.currentTimeMillis()
val delayMs = fetchTime - currentTime
Log.i(TAG, "Scheduling delayed prefetch: fetchTime=$fetchTime, notificationTime=$notificationTime, delayMs=$delayMs")
if (delayMs <= 0) {
Log.w(TAG, "Fetch time is in the past, scheduling immediate fetch")
scheduleImmediateFetch(context, notificationTime, url)
return
}
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
// Create unique work name based on notification time to prevent duplicate fetches
val notificationTimeMinutes = notificationTime / (60 * 1000)
val workName = "prefetch_${notificationTimeMinutes}"
val workRequest = OneTimeWorkRequestBuilder<FetchWorker>()
.setConstraints(constraints)
.setInitialDelay(delayMs, TimeUnit.MILLISECONDS)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
30,
TimeUnit.SECONDS
)
.setInputData(
Data.Builder()
.putString("url", url)
.putLong("fetchTime", fetchTime)
.putLong("notificationTime", notificationTime)
.putInt("timeout", 30000)
.putInt("retryAttempts", 3)
.putInt("retryDelay", 1000)
.build()
)
.addTag("prefetch")
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(
workName,
ExistingWorkPolicy.REPLACE,
workRequest
)
Log.i(TAG, "Delayed prefetch scheduled: workName=$workName, delayMs=$delayMs")
}
/**
* Schedule an immediate fetch (fallback when delay is in the past)
*/
private fun scheduleImmediateFetch(
context: Context,
notificationTime: Long,
url: String? = null
) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val workRequest = OneTimeWorkRequestBuilder<FetchWorker>()
.setConstraints(constraints)
.setInputData(
Data.Builder()
.putString("url", url)
.putLong("notificationTime", notificationTime)
.putInt("timeout", 30000)
.putInt("retryAttempts", 3)
.putInt("retryDelay", 1000)
.putBoolean("immediate", true)
.build()
)
.addTag("prefetch")
.build()
WorkManager.getInstance(context)
.enqueue(workRequest)
Log.i(TAG, "Immediate prefetch scheduled")
}
}
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
@@ -180,23 +277,3 @@ class FetchWorker(
return "fetch_${System.currentTimeMillis()}_${(1000..9999).random()}"
}
}
/**
* Database singleton for Room
*/
object DailyNotificationDatabase {
@Volatile
private var INSTANCE: DailyNotificationDatabase? = null
fun getDatabase(context: Context): DailyNotificationDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
DailyNotificationDatabase::class.java,
"daily_notification_database"
).build()
INSTANCE = instance
instance
}
}
}

View File

@@ -1,300 +0,0 @@
/**
* DailyNotificationDatabase.java
*
* Room database for the DailyNotification plugin
* Provides centralized data management with encryption, retention policies, and migration support
*
* @author Matthew Raymer
* @version 1.0.0
* @since 2025-10-20
*/
package com.timesafari.dailynotification.database;
import android.content.Context;
import androidx.room.*;
import androidx.room.migration.Migration;
import androidx.sqlite.db.SupportSQLiteDatabase;
import com.timesafari.dailynotification.dao.NotificationContentDao;
import com.timesafari.dailynotification.dao.NotificationDeliveryDao;
import com.timesafari.dailynotification.dao.NotificationConfigDao;
import com.timesafari.dailynotification.entities.NotificationContentEntity;
import com.timesafari.dailynotification.entities.NotificationDeliveryEntity;
import com.timesafari.dailynotification.entities.NotificationConfigEntity;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* Room database for the DailyNotification plugin
*
* This database provides:
* - Centralized data management for all plugin data
* - Encryption support for sensitive information
* - Automatic retention policy enforcement
* - Migration support for schema changes
* - Performance optimization with proper indexing
* - Background thread execution for database operations
*/
@Database(
entities = {
NotificationContentEntity.class,
NotificationDeliveryEntity.class,
NotificationConfigEntity.class
},
version = 1,
exportSchema = false
)
public abstract class DailyNotificationDatabase extends RoomDatabase {
private static final String TAG = "DailyNotificationDatabase";
private static final String DATABASE_NAME = "daily_notification_plugin.db";
// Singleton instance
private static volatile DailyNotificationDatabase INSTANCE;
// Thread pool for database operations
private static final int NUMBER_OF_THREADS = 4;
public static final ExecutorService databaseWriteExecutor = Executors.newFixedThreadPool(NUMBER_OF_THREADS);
// DAO accessors
public abstract NotificationContentDao notificationContentDao();
public abstract NotificationDeliveryDao notificationDeliveryDao();
public abstract NotificationConfigDao notificationConfigDao();
/**
* Get singleton instance of the database
*
* @param context Application context
* @return Database instance
*/
public static DailyNotificationDatabase getInstance(Context context) {
if (INSTANCE == null) {
synchronized (DailyNotificationDatabase.class) {
if (INSTANCE == null) {
INSTANCE = Room.databaseBuilder(
context.getApplicationContext(),
DailyNotificationDatabase.class,
DATABASE_NAME
)
.addCallback(roomCallback)
.addMigrations(MIGRATION_1_2) // Add future migrations here
.build();
}
}
}
return INSTANCE;
}
/**
* Room database callback for initialization and cleanup
*/
private static RoomDatabase.Callback roomCallback = new RoomDatabase.Callback() {
@Override
public void onCreate(SupportSQLiteDatabase db) {
super.onCreate(db);
// Initialize database with default data if needed
databaseWriteExecutor.execute(() -> {
// Populate with default configurations
populateDefaultConfigurations();
});
}
@Override
public void onOpen(SupportSQLiteDatabase db) {
super.onOpen(db);
// Perform any necessary setup when database is opened
databaseWriteExecutor.execute(() -> {
// Clean up expired data
cleanupExpiredData();
});
}
};
/**
* Populate database with default configurations
*/
private static void populateDefaultConfigurations() {
if (INSTANCE == null) return;
NotificationConfigDao configDao = INSTANCE.notificationConfigDao();
// Default plugin settings
NotificationConfigEntity defaultSettings = new NotificationConfigEntity(
"default_plugin_settings",
null, // Global settings
"plugin_setting",
"default_settings",
"{}",
"json"
);
defaultSettings.setTypedValue("{\"version\":\"1.0.0\",\"retention_days\":7,\"max_notifications\":100}");
configDao.insertConfig(defaultSettings);
// Default performance settings
NotificationConfigEntity performanceSettings = new NotificationConfigEntity(
"default_performance_settings",
null, // Global settings
"performance_setting",
"performance_config",
"{}",
"json"
);
performanceSettings.setTypedValue("{\"max_concurrent_deliveries\":5,\"delivery_timeout_ms\":30000,\"retry_attempts\":3}");
configDao.insertConfig(performanceSettings);
}
/**
* Clean up expired data from all tables
*/
private static void cleanupExpiredData() {
if (INSTANCE == null) return;
long currentTime = System.currentTimeMillis();
// Clean up expired notifications
NotificationContentDao contentDao = INSTANCE.notificationContentDao();
int deletedNotifications = contentDao.deleteExpiredNotifications(currentTime);
// Clean up old delivery tracking data (keep for 30 days)
NotificationDeliveryDao deliveryDao = INSTANCE.notificationDeliveryDao();
long deliveryCutoff = currentTime - (30L * 24 * 60 * 60 * 1000); // 30 days ago
int deletedDeliveries = deliveryDao.deleteOldDeliveries(deliveryCutoff);
// Clean up expired configurations
NotificationConfigDao configDao = INSTANCE.notificationConfigDao();
int deletedConfigs = configDao.deleteExpiredConfigs(currentTime);
android.util.Log.d(TAG, "Cleanup completed: " + deletedNotifications + " notifications, " +
deletedDeliveries + " deliveries, " + deletedConfigs + " configs");
}
/**
* Migration from version 1 to 2
* Add new columns for enhanced functionality
*/
static final Migration MIGRATION_1_2 = new Migration(1, 2) {
@Override
public void migrate(SupportSQLiteDatabase database) {
// Add new columns to notification_content table
database.execSQL("ALTER TABLE notification_content ADD COLUMN analytics_data TEXT");
database.execSQL("ALTER TABLE notification_content ADD COLUMN priority_level INTEGER DEFAULT 0");
// Add new columns to notification_delivery table
database.execSQL("ALTER TABLE notification_delivery ADD COLUMN delivery_metadata TEXT");
database.execSQL("ALTER TABLE notification_delivery ADD COLUMN performance_metrics TEXT");
// Add new columns to notification_config table
database.execSQL("ALTER TABLE notification_config ADD COLUMN config_category TEXT DEFAULT 'general'");
database.execSQL("ALTER TABLE notification_config ADD COLUMN config_priority INTEGER DEFAULT 0");
}
};
/**
* Close the database connection
* Should be called when the plugin is being destroyed
*/
public static void closeDatabase() {
if (INSTANCE != null) {
INSTANCE.close();
INSTANCE = null;
}
}
/**
* Clear all data from the database
* Use with caution - this will delete all plugin data
*/
public static void clearAllData() {
if (INSTANCE == null) return;
databaseWriteExecutor.execute(() -> {
NotificationContentDao contentDao = INSTANCE.notificationContentDao();
NotificationDeliveryDao deliveryDao = INSTANCE.notificationDeliveryDao();
NotificationConfigDao configDao = INSTANCE.notificationConfigDao();
// Clear all tables
contentDao.deleteNotificationsByPluginVersion("0"); // Delete all
deliveryDao.deleteDeliveriesByTimeSafariDid("all"); // Delete all
configDao.deleteConfigsByType("all"); // Delete all
android.util.Log.d(TAG, "All plugin data cleared");
});
}
/**
* Get database statistics
*
* @return Database statistics as a formatted string
*/
public static String getDatabaseStats() {
if (INSTANCE == null) return "Database not initialized";
NotificationContentDao contentDao = INSTANCE.notificationContentDao();
NotificationDeliveryDao deliveryDao = INSTANCE.notificationDeliveryDao();
NotificationConfigDao configDao = INSTANCE.notificationConfigDao();
int notificationCount = contentDao.getTotalNotificationCount();
int deliveryCount = deliveryDao.getTotalDeliveryCount();
int configCount = configDao.getTotalConfigCount();
return String.format("Database Stats:\n" +
" Notifications: %d\n" +
" Deliveries: %d\n" +
" Configurations: %d\n" +
" Total Records: %d",
notificationCount, deliveryCount, configCount,
notificationCount + deliveryCount + configCount);
}
/**
* Perform database maintenance
* Includes cleanup, optimization, and integrity checks
*/
public static void performMaintenance() {
if (INSTANCE == null) return;
databaseWriteExecutor.execute(() -> {
long startTime = System.currentTimeMillis();
// Clean up expired data
cleanupExpiredData();
// Additional maintenance tasks can be added here
// - Vacuum database
// - Analyze tables for query optimization
// - Check database integrity
long duration = System.currentTimeMillis() - startTime;
android.util.Log.d(TAG, "Database maintenance completed in " + duration + "ms");
});
}
/**
* Export database data for backup or migration
*
* @return Database export as JSON string
*/
public static String exportDatabaseData() {
if (INSTANCE == null) return "{}";
// This would typically serialize all data to JSON
// Implementation depends on specific export requirements
return "{\"export\":\"not_implemented_yet\"}";
}
/**
* Import database data from backup
*
* @param jsonData JSON data to import
* @return Success status
*/
public static boolean importDatabaseData(String jsonData) {
if (INSTANCE == null || jsonData == null) return false;
// This would typically deserialize JSON data and insert into database
// Implementation depends on specific import requirements
return false;
}
}

View File

@@ -14,7 +14,7 @@ package com.timesafari.dailynotification.storage;
import android.content.Context;
import android.util.Log;
import com.timesafari.dailynotification.database.DailyNotificationDatabase;
import com.timesafari.dailynotification.DailyNotificationDatabase;
import com.timesafari.dailynotification.dao.NotificationContentDao;
import com.timesafari.dailynotification.dao.NotificationDeliveryDao;
import com.timesafari.dailynotification.dao.NotificationConfigDao;
@@ -42,7 +42,7 @@ public class DailyNotificationStorageRoom {
private static final String TAG = "DailyNotificationStorageRoom";
// Database and DAOs
// Database and DAOs (using unified database)
private DailyNotificationDatabase database;
private NotificationContentDao contentDao;
private NotificationDeliveryDao deliveryDao;
@@ -60,13 +60,14 @@ public class DailyNotificationStorageRoom {
* @param context Application context
*/
public DailyNotificationStorageRoom(Context context) {
// Use unified database (Kotlin schema with Java entities)
this.database = DailyNotificationDatabase.getInstance(context);
this.contentDao = database.notificationContentDao();
this.deliveryDao = database.notificationDeliveryDao();
this.configDao = database.notificationConfigDao();
this.executorService = Executors.newFixedThreadPool(4);
Log.d(TAG, "Room-based storage initialized");
Log.d(TAG, "Room-based storage initialized with unified database");
}
// ===== NOTIFICATION CONTENT OPERATIONS =====