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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 =====
|
||||
|
||||
Reference in New Issue
Block a user