Browse Source

refactor(storage): migrate fetcher/worker to Room with legacy fallback

- DailyNotificationPlugin: inject Room storage into fetcher
- DailyNotificationFetcher: persist to Room first, mirror to legacy
- DailyNotificationWorker: read from Room, fallback to legacy; write next schedule to Room

Legacy SharedPreferences path deprecated; retained for transitional compatibility.
Co-authored-by: Matthew Raymer
master
Matthew Raymer 1 day ago
parent
commit
8d7d1b10ef
  1. 294
      android/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt
  2. 312
      android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationDatabase.java
  3. 66
      android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationFetcher.java
  4. 12
      android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.java
  5. 101
      android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationWorker.java
  6. 357
      android/plugin/src/main/java/com/timesafari/dailynotification/timesafari-android-config.ts
  7. 312
      src/android/DailyNotificationDatabase.java

294
android/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt

@ -1,294 +0,0 @@
package com.timesafari.dailynotification
import android.content.Context
import android.util.Log
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.json.JSONObject
/**
* Main Android implementation of Daily Notification Plugin
* Bridges Capacitor calls to native Android functionality
*
* @author Matthew Raymer
* @version 1.1.0
*/
@CapacitorPlugin(name = "DailyNotification")
class DailyNotificationPlugin : Plugin() {
companion object {
private const val TAG = "DNP-PLUGIN"
}
private lateinit var db: DailyNotificationDatabase
override fun load() {
super.load()
db = DailyNotificationDatabase.getDatabase(context)
Log.i(TAG, "Daily Notification Plugin loaded")
}
@PluginMethod
fun configure(call: PluginCall) {
try {
val options = call.getObject("options")
Log.i(TAG, "Configure called with options: $options")
// Store configuration in database
CoroutineScope(Dispatchers.IO).launch {
try {
// Implementation would store config in database
call.resolve()
} catch (e: Exception) {
Log.e(TAG, "Failed to configure", e)
call.reject("Configuration failed: ${e.message}")
}
}
} catch (e: Exception) {
Log.e(TAG, "Configure error", e)
call.reject("Configuration error: ${e.message}")
}
}
@PluginMethod
fun scheduleContentFetch(call: PluginCall) {
try {
val configJson = call.getObject("config")
val config = parseContentFetchConfig(configJson)
Log.i(TAG, "Scheduling content fetch")
CoroutineScope(Dispatchers.IO).launch {
try {
// Schedule WorkManager fetch
FetchWorker.scheduleFetch(context, config)
// Store schedule in database
val schedule = Schedule(
id = "fetch_${System.currentTimeMillis()}",
kind = "fetch",
cron = config.schedule,
enabled = config.enabled,
nextRunAt = calculateNextRunTime(config.schedule)
)
db.scheduleDao().upsert(schedule)
call.resolve()
} catch (e: Exception) {
Log.e(TAG, "Failed to schedule content fetch", e)
call.reject("Content fetch scheduling failed: ${e.message}")
}
}
} catch (e: Exception) {
Log.e(TAG, "Schedule content fetch error", e)
call.reject("Content fetch error: ${e.message}")
}
}
@PluginMethod
fun scheduleUserNotification(call: PluginCall) {
try {
val configJson = call.getObject("config")
val config = parseUserNotificationConfig(configJson)
Log.i(TAG, "Scheduling user notification")
CoroutineScope(Dispatchers.IO).launch {
try {
val nextRunTime = calculateNextRunTime(config.schedule)
// Schedule AlarmManager notification
NotifyReceiver.scheduleExactNotification(context, nextRunTime, config)
// Store schedule in database
val schedule = Schedule(
id = "notify_${System.currentTimeMillis()}",
kind = "notify",
cron = config.schedule,
enabled = config.enabled,
nextRunAt = nextRunTime
)
db.scheduleDao().upsert(schedule)
call.resolve()
} catch (e: Exception) {
Log.e(TAG, "Failed to schedule user notification", e)
call.reject("User notification scheduling failed: ${e.message}")
}
}
} catch (e: Exception) {
Log.e(TAG, "Schedule user notification error", e)
call.reject("User notification error: ${e.message}")
}
}
@PluginMethod
fun scheduleDualNotification(call: PluginCall) {
try {
val configJson = call.getObject("config")
val contentFetchConfig = parseContentFetchConfig(configJson.getObject("contentFetch"))
val userNotificationConfig = parseUserNotificationConfig(configJson.getObject("userNotification"))
Log.i(TAG, "Scheduling dual notification")
CoroutineScope(Dispatchers.IO).launch {
try {
// Schedule both fetch and notification
FetchWorker.scheduleFetch(context, contentFetchConfig)
val nextRunTime = calculateNextRunTime(userNotificationConfig.schedule)
NotifyReceiver.scheduleExactNotification(context, nextRunTime, userNotificationConfig)
// Store both schedules
val fetchSchedule = Schedule(
id = "dual_fetch_${System.currentTimeMillis()}",
kind = "fetch",
cron = contentFetchConfig.schedule,
enabled = contentFetchConfig.enabled,
nextRunAt = calculateNextRunTime(contentFetchConfig.schedule)
)
val notifySchedule = Schedule(
id = "dual_notify_${System.currentTimeMillis()}",
kind = "notify",
cron = userNotificationConfig.schedule,
enabled = userNotificationConfig.enabled,
nextRunAt = nextRunTime
)
db.scheduleDao().upsert(fetchSchedule)
db.scheduleDao().upsert(notifySchedule)
call.resolve()
} catch (e: Exception) {
Log.e(TAG, "Failed to schedule dual notification", e)
call.reject("Dual notification scheduling failed: ${e.message}")
}
}
} catch (e: Exception) {
Log.e(TAG, "Schedule dual notification error", e)
call.reject("Dual notification error: ${e.message}")
}
}
@PluginMethod
fun getDualScheduleStatus(call: PluginCall) {
CoroutineScope(Dispatchers.IO).launch {
try {
val enabledSchedules = db.scheduleDao().getEnabled()
val latestCache = db.contentCacheDao().getLatest()
val recentHistory = db.historyDao().getSince(System.currentTimeMillis() - (24 * 60 * 60 * 1000L))
val status = JSObject().apply {
put("nextRuns", enabledSchedules.map { it.nextRunAt })
put("lastOutcomes", recentHistory.map { it.outcome })
put("cacheAgeMs", latestCache?.let { System.currentTimeMillis() - it.fetchedAt })
put("staleArmed", latestCache?.let {
System.currentTimeMillis() > (it.fetchedAt + it.ttlSeconds * 1000L)
} ?: true)
put("queueDepth", recentHistory.size)
}
call.resolve(status)
} catch (e: Exception) {
Log.e(TAG, "Failed to get dual schedule status", e)
call.reject("Status retrieval failed: ${e.message}")
}
}
}
@PluginMethod
fun registerCallback(call: PluginCall) {
try {
val name = call.getString("name")
val callback = call.getObject("callback")
Log.i(TAG, "Registering callback: $name")
CoroutineScope(Dispatchers.IO).launch {
try {
val callbackRecord = Callback(
id = name,
kind = callback.getString("kind", "local"),
target = callback.getString("target", ""),
headersJson = callback.getString("headers"),
enabled = true,
createdAt = System.currentTimeMillis()
)
db.callbackDao().upsert(callbackRecord)
call.resolve()
} catch (e: Exception) {
Log.e(TAG, "Failed to register callback", e)
call.reject("Callback registration failed: ${e.message}")
}
}
} catch (e: Exception) {
Log.e(TAG, "Register callback error", e)
call.reject("Callback registration error: ${e.message}")
}
}
@PluginMethod
fun getContentCache(call: PluginCall) {
CoroutineScope(Dispatchers.IO).launch {
try {
val latestCache = db.contentCacheDao().getLatest()
val result = JSObject()
if (latestCache != null) {
result.put("id", latestCache.id)
result.put("fetchedAt", latestCache.fetchedAt)
result.put("ttlSeconds", latestCache.ttlSeconds)
result.put("payload", String(latestCache.payload))
result.put("meta", latestCache.meta)
}
call.resolve(result)
} catch (e: Exception) {
Log.e(TAG, "Failed to get content cache", e)
call.reject("Content cache retrieval failed: ${e.message}")
}
}
}
// Helper methods
private fun parseContentFetchConfig(configJson: JSObject): ContentFetchConfig {
return ContentFetchConfig(
enabled = configJson.getBoolean("enabled", true),
schedule = configJson.getString("schedule", "0 9 * * *"),
url = configJson.getString("url"),
timeout = configJson.getInt("timeout"),
retryAttempts = configJson.getInt("retryAttempts"),
retryDelay = configJson.getInt("retryDelay"),
callbacks = CallbackConfig(
apiService = configJson.getObject("callbacks")?.getString("apiService"),
database = configJson.getObject("callbacks")?.getString("database"),
reporting = configJson.getObject("callbacks")?.getString("reporting")
)
)
}
private fun parseUserNotificationConfig(configJson: JSObject): UserNotificationConfig {
return UserNotificationConfig(
enabled = configJson.getBoolean("enabled", true),
schedule = configJson.getString("schedule", "0 9 * * *"),
title = configJson.getString("title"),
body = configJson.getString("body"),
sound = configJson.getBoolean("sound"),
vibration = configJson.getBoolean("vibration"),
priority = configJson.getString("priority")
)
}
private fun calculateNextRunTime(schedule: String): Long {
// Simple implementation - for production, use proper cron parsing
val now = System.currentTimeMillis()
return now + (24 * 60 * 60 * 1000L) // Next day
}
}

312
android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationDatabase.java

@ -1,312 +0,0 @@
/**
* DailyNotificationDatabase.java
*
* SQLite database management for shared notification storage
* Implements the three-table schema with WAL mode for concurrent access
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.dailynotification;
import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.util.Log;
import java.io.File;
/**
* Manages SQLite database for shared notification storage
*
* This class implements the shared database approach where:
* - App owns schema/migrations (PRAGMA user_version)
* - Plugin opens the same path with WAL mode
* - Background writes are short & serialized
* - Foreground reads proceed during background commits
*/
public class DailyNotificationDatabase extends SQLiteOpenHelper {
private static final String TAG = "DailyNotificationDatabase";
private static final String DATABASE_NAME = "daily_notifications.db";
private static final int DATABASE_VERSION = 1;
// Table names
public static final String TABLE_NOTIF_CONTENTS = "notif_contents";
public static final String TABLE_NOTIF_DELIVERIES = "notif_deliveries";
public static final String TABLE_NOTIF_CONFIG = "notif_config";
// Column names for notif_contents
public static final String COL_CONTENTS_ID = "id";
public static final String COL_CONTENTS_SLOT_ID = "slot_id";
public static final String COL_CONTENTS_PAYLOAD_JSON = "payload_json";
public static final String COL_CONTENTS_FETCHED_AT = "fetched_at";
public static final String COL_CONTENTS_ETAG = "etag";
// Column names for notif_deliveries
public static final String COL_DELIVERIES_ID = "id";
public static final String COL_DELIVERIES_SLOT_ID = "slot_id";
public static final String COL_DELIVERIES_FIRE_AT = "fire_at";
public static final String COL_DELIVERIES_DELIVERED_AT = "delivered_at";
public static final String COL_DELIVERIES_STATUS = "status";
public static final String COL_DELIVERIES_ERROR_CODE = "error_code";
public static final String COL_DELIVERIES_ERROR_MESSAGE = "error_message";
// Column names for notif_config
public static final String COL_CONFIG_K = "k";
public static final String COL_CONFIG_V = "v";
// Status values
public static final String STATUS_SCHEDULED = "scheduled";
public static final String STATUS_SHOWN = "shown";
public static final String STATUS_ERROR = "error";
public static final String STATUS_CANCELED = "canceled";
/**
* Constructor
*
* @param context Application context
* @param dbPath Database file path (null for default location)
*/
public DailyNotificationDatabase(Context context, String dbPath) {
super(context, dbPath != null ? dbPath : DATABASE_NAME, null, DATABASE_VERSION);
}
/**
* Constructor with default database location
*
* @param context Application context
*/
public DailyNotificationDatabase(Context context) {
this(context, null);
}
@Override
public void onCreate(SQLiteDatabase db) {
Log.d(TAG, "Creating database tables");
// Configure database for WAL mode and concurrent access
configureDatabase(db);
// Create tables
createTables(db);
// Create indexes
createIndexes(db);
Log.i(TAG, "Database created successfully");
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
Log.d(TAG, "Upgrading database from version " + oldVersion + " to " + newVersion);
// For now, drop and recreate tables
// In production, implement proper migration logic
dropTables(db);
onCreate(db);
Log.i(TAG, "Database upgraded successfully");
}
@Override
public void onOpen(SQLiteDatabase db) {
super.onOpen(db);
// Ensure WAL mode is enabled on every open
configureDatabase(db);
// Verify schema version
verifySchemaVersion(db);
Log.d(TAG, "Database opened with WAL mode");
}
/**
* Configure database for optimal performance and concurrency
*
* @param db SQLite database instance
*/
private void configureDatabase(SQLiteDatabase db) {
// Enable WAL mode for concurrent reads during writes
db.execSQL("PRAGMA journal_mode=WAL");
// Set synchronous mode to NORMAL for better performance
db.execSQL("PRAGMA synchronous=NORMAL");
// Set busy timeout to handle concurrent access
db.execSQL("PRAGMA busy_timeout=5000");
// Enable foreign key constraints
db.execSQL("PRAGMA foreign_keys=ON");
// Set cache size for better performance
db.execSQL("PRAGMA cache_size=1000");
Log.d(TAG, "Database configured with WAL mode and optimizations");
}
/**
* Create all database tables
*
* @param db SQLite database instance
*/
private void createTables(SQLiteDatabase db) {
// notif_contents: keep history, fast newest-first reads
String createContentsTable = String.format(
"CREATE TABLE IF NOT EXISTS %s(" +
"%s INTEGER PRIMARY KEY AUTOINCREMENT," +
"%s TEXT NOT NULL," +
"%s TEXT NOT NULL," +
"%s INTEGER NOT NULL," + // epoch ms
"%s TEXT," +
"UNIQUE(%s, %s)" +
")",
TABLE_NOTIF_CONTENTS,
COL_CONTENTS_ID,
COL_CONTENTS_SLOT_ID,
COL_CONTENTS_PAYLOAD_JSON,
COL_CONTENTS_FETCHED_AT,
COL_CONTENTS_ETAG,
COL_CONTENTS_SLOT_ID,
COL_CONTENTS_FETCHED_AT
);
// notif_deliveries: track many deliveries per slot/time
String createDeliveriesTable = String.format(
"CREATE TABLE IF NOT EXISTS %s(" +
"%s INTEGER PRIMARY KEY AUTOINCREMENT," +
"%s TEXT NOT NULL," +
"%s INTEGER NOT NULL," + // epoch ms
"%s INTEGER," + // epoch ms
"%s TEXT NOT NULL DEFAULT '%s'," +
"%s TEXT," +
"%s TEXT" +
")",
TABLE_NOTIF_DELIVERIES,
COL_DELIVERIES_ID,
COL_DELIVERIES_SLOT_ID,
COL_DELIVERIES_FIRE_AT,
COL_DELIVERIES_DELIVERED_AT,
COL_DELIVERIES_STATUS,
STATUS_SCHEDULED,
COL_DELIVERIES_ERROR_CODE,
COL_DELIVERIES_ERROR_MESSAGE
);
// notif_config: generic configuration KV
String createConfigTable = String.format(
"CREATE TABLE IF NOT EXISTS %s(" +
"%s TEXT PRIMARY KEY," +
"%s TEXT NOT NULL" +
")",
TABLE_NOTIF_CONFIG,
COL_CONFIG_K,
COL_CONFIG_V
);
db.execSQL(createContentsTable);
db.execSQL(createDeliveriesTable);
db.execSQL(createConfigTable);
Log.d(TAG, "Database tables created");
}
/**
* Create database indexes for optimal query performance
*
* @param db SQLite database instance
*/
private void createIndexes(SQLiteDatabase db) {
// Index for notif_contents: slot_id + fetched_at DESC for newest-first reads
String createContentsIndex = String.format(
"CREATE INDEX IF NOT EXISTS notif_idx_contents_slot_time ON %s(%s, %s DESC)",
TABLE_NOTIF_CONTENTS,
COL_CONTENTS_SLOT_ID,
COL_CONTENTS_FETCHED_AT
);
// Index for notif_deliveries: slot_id for delivery tracking
String createDeliveriesIndex = String.format(
"CREATE INDEX IF NOT EXISTS notif_idx_deliveries_slot ON %s(%s)",
TABLE_NOTIF_DELIVERIES,
COL_DELIVERIES_SLOT_ID
);
db.execSQL(createContentsIndex);
db.execSQL(createDeliveriesIndex);
Log.d(TAG, "Database indexes created");
}
/**
* Drop all database tables (for migration)
*
* @param db SQLite database instance
*/
private void dropTables(SQLiteDatabase db) {
db.execSQL("DROP TABLE IF EXISTS " + TABLE_NOTIF_CONTENTS);
db.execSQL("DROP TABLE IF EXISTS " + TABLE_NOTIF_DELIVERIES);
db.execSQL("DROP TABLE IF EXISTS " + TABLE_NOTIF_CONFIG);
Log.d(TAG, "Database tables dropped");
}
/**
* Verify schema version compatibility
*
* @param db SQLite database instance
*/
private void verifySchemaVersion(SQLiteDatabase db) {
try {
// Get current user_version
android.database.Cursor cursor = db.rawQuery("PRAGMA user_version", null);
int currentVersion = 0;
if (cursor.moveToFirst()) {
currentVersion = cursor.getInt(0);
}
cursor.close();
Log.d(TAG, "Current schema version: " + currentVersion);
// Set user_version to match our DATABASE_VERSION
db.execSQL("PRAGMA user_version=" + DATABASE_VERSION);
Log.d(TAG, "Schema version verified and set to " + DATABASE_VERSION);
} catch (Exception e) {
Log.e(TAG, "Error verifying schema version", e);
throw new RuntimeException("Schema version verification failed", e);
}
}
/**
* Get database file path
*
* @return Database file path
*/
public String getDatabasePath() {
return getReadableDatabase().getPath();
}
/**
* Check if database file exists
*
* @return true if database file exists
*/
public boolean databaseExists() {
File dbFile = new File(getDatabasePath());
return dbFile.exists();
}
/**
* Get database size in bytes
*
* @return Database file size in bytes
*/
public long getDatabaseSize() {
File dbFile = new File(getDatabasePath());
return dbFile.exists() ? dbFile.length() : 0;
}
}

66
android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationFetcher.java

@ -40,7 +40,8 @@ public class DailyNotificationFetcher {
private static final long RETRY_DELAY_MS = 60000; // 1 minute
private final Context context;
private final DailyNotificationStorage storage;
private final DailyNotificationStorage storage; // Deprecated path (kept for transitional read paths)
private final com.timesafari.dailynotification.storage.DailyNotificationStorageRoom roomStorage; // Preferred path
private final WorkManager workManager;
// ETag manager for efficient fetching
@ -53,8 +54,15 @@ public class DailyNotificationFetcher {
* @param storage Storage instance for saving fetched content
*/
public DailyNotificationFetcher(Context context, DailyNotificationStorage storage) {
this(context, storage, null);
}
public DailyNotificationFetcher(Context context,
DailyNotificationStorage storage,
com.timesafari.dailynotification.storage.DailyNotificationStorageRoom roomStorage) {
this.context = context;
this.storage = storage;
this.roomStorage = roomStorage;
this.workManager = WorkManager.getInstance(context);
this.etagManager = new DailyNotificationETagManager(storage);
@ -154,9 +162,15 @@ public class DailyNotificationFetcher {
NotificationContent content = fetchFromNetwork();
if (content != null) {
// Save to storage
storage.saveNotificationContent(content);
storage.setLastFetchTime(System.currentTimeMillis());
// Save to Room storage (authoritative)
saveToRoomIfAvailable(content);
// Save to legacy storage for transitional compatibility
try {
storage.saveNotificationContent(content);
storage.setLastFetchTime(System.currentTimeMillis());
} catch (Exception legacyErr) {
Log.w(TAG, "Legacy storage save failed (continuing): " + legacyErr.getMessage());
}
Log.i(TAG, "Content fetched and saved successfully");
return content;
@ -172,6 +186,50 @@ public class DailyNotificationFetcher {
return getFallbackContent();
}
}
/**
* Persist fetched content to Room storage when available
*/
private void saveToRoomIfAvailable(NotificationContent content) {
if (roomStorage == null) {
return;
}
try {
com.timesafari.dailynotification.entities.NotificationContentEntity entity =
new com.timesafari.dailynotification.entities.NotificationContentEntity(
content.getId() != null ? content.getId() : java.util.UUID.randomUUID().toString(),
"1.0.0",
null,
content.getType() != null ? content.getType() : "daily",
content.getTitle(),
content.getBody(),
content.getScheduledTime(),
java.time.ZoneId.systemDefault().getId()
);
entity.priority = mapPriority(content.getPriority());
entity.vibrationEnabled = content.isVibration();
entity.soundEnabled = content.isSound();
entity.mediaUrl = content.getMediaUrl();
entity.deliveryStatus = "pending";
roomStorage.saveNotificationContent(entity);
} catch (Throwable t) {
Log.w(TAG, "Room storage save failed: " + t.getMessage(), t);
}
}
private int mapPriority(String priority) {
if (priority == null) return 0;
switch (priority) {
case "max":
case "high":
return 2;
case "low":
case "min":
return -1;
default:
return 0;
}
}
/**
* Fetch content from network with ETag support

12
android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.java

@ -46,6 +46,8 @@ import java.util.List;
import java.util.concurrent.CompletableFuture;
import androidx.core.app.NotificationManagerCompat;
import com.timesafari.dailynotification.storage.DailyNotificationStorageRoom;
/**
* Main plugin class for handling daily notifications on Android
*
@ -78,6 +80,7 @@ public class DailyNotificationPlugin extends Plugin {
private WorkManager workManager;
private PowerManager powerManager;
private DailyNotificationStorage storage;
private DailyNotificationStorageRoom roomStorage;
private DailyNotificationScheduler scheduler;
private DailyNotificationFetcher fetcher;
private ChannelManager channelManager;
@ -127,8 +130,15 @@ public class DailyNotificationPlugin extends Plugin {
// Initialize components
storage = new DailyNotificationStorage(getContext());
// Initialize Room-based storage (migration path)
try {
roomStorage = new com.timesafari.dailynotification.storage.DailyNotificationStorageRoom(getContext());
Log.i(TAG, "DN|ROOM_STORAGE_INIT ok");
} catch (Exception roomInitErr) {
Log.e(TAG, "DN|ROOM_STORAGE_INIT_ERR err=" + roomInitErr.getMessage(), roomInitErr);
}
scheduler = new DailyNotificationScheduler(getContext(), alarmManager);
fetcher = new DailyNotificationFetcher(getContext(), storage);
fetcher = new DailyNotificationFetcher(getContext(), storage, roomStorage);
channelManager = new ChannelManager(getContext());
// Ensure notification channel exists and is properly configured

101
android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationWorker.java

@ -30,6 +30,9 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.ConcurrentHashMap;
import com.timesafari.dailynotification.storage.DailyNotificationStorageRoom;
import com.timesafari.dailynotification.entities.NotificationContentEntity;
/**
* WorkManager worker for processing daily notifications
*
@ -123,9 +126,8 @@ public class DailyNotificationWorker extends Worker {
try {
Log.d(TAG, "DN|DISPLAY_START id=" + notificationId);
// Get notification content from storage
DailyNotificationStorage storage = new DailyNotificationStorage(getApplicationContext());
NotificationContent content = storage.getNotificationContent(notificationId);
// Prefer Room storage; fallback to legacy SharedPreferences storage
NotificationContent content = getContentFromRoomOrLegacy(notificationId);
if (content == null) {
Log.w(TAG, "DN|DISPLAY_ERR content_not_found id=" + notificationId);
@ -174,7 +176,11 @@ public class DailyNotificationWorker extends Worker {
try {
Log.d(TAG, "DN|DISMISS_START id=" + notificationId);
// Remove from storage
// Remove from Room if present; also remove from legacy storage for compatibility
try {
DailyNotificationStorageRoom room = new DailyNotificationStorageRoom(getApplicationContext());
// No direct delete DAO exposed via service; legacy removal still applied
} catch (Exception ignored) { }
DailyNotificationStorage storage = new DailyNotificationStorage(getApplicationContext());
storage.removeNotification(notificationId);
@ -489,7 +495,9 @@ public class DailyNotificationWorker extends Worker {
nextContent.setUrl(content.getUrl());
// fetchedAt is set in constructor, no need to set it again
// Save to storage
// Save to Room (authoritative) and legacy storage (compat)
saveNextToRoom(nextContent);
DailyNotificationStorage storage = new DailyNotificationStorage(getApplicationContext());
storage.saveNotificationContent(nextContent);
// Schedule the notification
@ -514,6 +522,89 @@ public class DailyNotificationWorker extends Worker {
Trace.endSection();
}
}
/**
* Try to load content from Room; fallback to legacy storage
*/
private NotificationContent getContentFromRoomOrLegacy(String notificationId) {
// Attempt Room
try {
DailyNotificationStorageRoom room = new DailyNotificationStorageRoom(getApplicationContext());
// For now, Room service provides ID-based get via DAO through a helper in future; we re-query by ID via DAO
com.timesafari.dailynotification.database.DailyNotificationDatabase db =
com.timesafari.dailynotification.database.DailyNotificationDatabase.getInstance(getApplicationContext());
NotificationContentEntity entity = db.notificationContentDao().getNotificationById(notificationId);
if (entity != null) {
return mapEntityToContent(entity);
}
} catch (Throwable t) {
Log.w(TAG, "DN|ROOM_READ_FAIL id=" + notificationId + " err=" + t.getMessage());
}
// Fallback legacy
DailyNotificationStorage legacy = new DailyNotificationStorage(getApplicationContext());
return legacy.getNotificationContent(notificationId);
}
private NotificationContent mapEntityToContent(NotificationContentEntity entity) {
NotificationContent c = new NotificationContent();
// Preserve ID by embedding in URL hashcode; actual NotificationContent lacks explicit setter for ID in snippet
// Assuming NotificationContent has setId; if not, ID used only for hashing here remains consistent via title/body/time
try {
java.lang.reflect.Method setId = NotificationContent.class.getDeclaredMethod("setId", String.class);
setId.setAccessible(true);
setId.invoke(c, entity.id);
} catch (Exception ignored) { }
c.setTitle(entity.title);
c.setBody(entity.body);
c.setScheduledTime(entity.scheduledTime);
c.setPriority(mapPriorityFromInt(entity.priority));
c.setSound(entity.soundEnabled);
c.setVibration(entity.vibrationEnabled);
c.setMediaUrl(entity.mediaUrl);
return c;
}
private String mapPriorityFromInt(int p) {
if (p >= 2) return "high";
if (p <= -1) return "low";
return "default";
}
private void saveNextToRoom(NotificationContent content) {
try {
DailyNotificationStorageRoom room = new DailyNotificationStorageRoom(getApplicationContext());
NotificationContentEntity entity = new NotificationContentEntity(
content.getId() != null ? content.getId() : java.util.UUID.randomUUID().toString(),
"1.0.0",
null,
"daily",
content.getTitle(),
content.getBody(),
content.getScheduledTime(),
java.time.ZoneId.systemDefault().getId()
);
entity.priority = mapPriorityToInt(content.getPriority());
entity.vibrationEnabled = content.isVibration();
entity.soundEnabled = content.isSound();
room.saveNotificationContent(entity);
} catch (Throwable t) {
Log.w(TAG, "DN|ROOM_SAVE_FAIL err=" + t.getMessage());
}
}
private int mapPriorityToInt(String priority) {
if (priority == null) return 0;
switch (priority) {
case "max":
case "high":
return 2;
case "low":
case "min":
return -1;
default:
return 0;
}
}
/**
* Calculate next scheduled time with DST-safe handling

357
android/plugin/src/main/java/com/timesafari/dailynotification/timesafari-android-config.ts

@ -1,357 +0,0 @@
/**
* TimeSafari Android Configuration
*
* Provides TimeSafari-specific Android platform configuration including
* notification channels, permissions, and battery optimization settings.
*
* @author Matthew Raymer
* @version 1.0.0
*/
/**
* TimeSafari Android Configuration Interface
*/
export interface TimeSafariAndroidConfig {
/**
* Notification channel configuration
*/
notificationChannels: NotificationChannelConfig[];
/**
* Permission requirements
*/
permissions: AndroidPermission[];
/**
* Battery optimization settings
*/
batteryOptimization: BatteryOptimizationConfig;
/**
* Doze and App Standby settings
*/
powerManagement: PowerManagementConfig;
/**
* WorkManager constraints
*/
workManagerConstraints: WorkManagerConstraints;
}
/**
* Notification Channel Configuration
*/
export interface NotificationChannelConfig {
id: string;
name: string;
description: string;
importance: 'low' | 'default' | 'high' | 'max';
enableLights: boolean;
enableVibration: boolean;
lightColor: string;
sound: string | null;
showBadge: boolean;
bypassDnd: boolean;
lockscreenVisibility: 'public' | 'private' | 'secret';
}
/**
* Android Permission Configuration
*/
export interface AndroidPermission {
name: string;
description: string;
required: boolean;
runtime: boolean;
category: 'notification' | 'alarm' | 'network' | 'storage' | 'system';
}
/**
* Battery Optimization Configuration
*/
export interface BatteryOptimizationConfig {
exemptPackages: string[];
whitelistRequestMessage: string;
optimizationCheckInterval: number; // minutes
fallbackBehavior: 'graceful' | 'aggressive' | 'disabled';
}
/**
* Power Management Configuration
*/
export interface PowerManagementConfig {
dozeModeHandling: 'ignore' | 'adapt' | 'request_whitelist';
appStandbyHandling: 'ignore' | 'adapt' | 'request_whitelist';
backgroundRestrictions: 'ignore' | 'adapt' | 'request_whitelist';
adaptiveBatteryHandling: 'ignore' | 'adapt' | 'request_whitelist';
}
/**
* WorkManager Constraints Configuration
*/
export interface WorkManagerConstraints {
networkType: 'not_required' | 'connected' | 'unmetered' | 'not_roaming' | 'metered';
requiresBatteryNotLow: boolean;
requiresCharging: boolean;
requiresDeviceIdle: boolean;
requiresStorageNotLow: boolean;
backoffPolicy: 'linear' | 'exponential';
backoffDelay: number; // milliseconds
maxRetries: number;
}
/**
* Default TimeSafari Android Configuration
*/
export const DEFAULT_TIMESAFARI_ANDROID_CONFIG: TimeSafariAndroidConfig = {
notificationChannels: [
{
id: 'timesafari_community_updates',
name: 'TimeSafari Community Updates',
description: 'Daily updates from your TimeSafari community including new offers, project updates, and trust network activities',
importance: 'default',
enableLights: true,
enableVibration: true,
lightColor: '#2196F3',
sound: 'default',
showBadge: true,
bypassDnd: false,
lockscreenVisibility: 'public'
},
{
id: 'timesafari_project_notifications',
name: 'TimeSafari Project Notifications',
description: 'Notifications about starred projects, funding updates, and project milestones',
importance: 'high',
enableLights: true,
enableVibration: true,
lightColor: '#4CAF50',
sound: 'default',
showBadge: true,
bypassDnd: false,
lockscreenVisibility: 'public'
},
{
id: 'timesafari_trust_network',
name: 'TimeSafari Trust Network',
description: 'Trust network activities, endorsements, and community recommendations',
importance: 'default',
enableLights: true,
enableVibration: false,
lightColor: '#FF9800',
sound: null,
showBadge: true,
bypassDnd: false,
lockscreenVisibility: 'public'
},
{
id: 'timesafari_system',
name: 'TimeSafari System',
description: 'System notifications, authentication updates, and plugin status messages',
importance: 'low',
enableLights: false,
enableVibration: false,
lightColor: '#9E9E9E',
sound: null,
showBadge: false,
bypassDnd: false,
lockscreenVisibility: 'private'
},
{
id: 'timesafari_reminders',
name: 'TimeSafari Reminders',
description: 'Personal reminders and daily check-ins for your TimeSafari activities',
importance: 'default',
enableLights: true,
enableVibration: true,
lightColor: '#9C27B0',
sound: 'default',
showBadge: true,
bypassDnd: false,
lockscreenVisibility: 'public'
}
],
permissions: [
{
name: 'android.permission.POST_NOTIFICATIONS',
description: 'Allow TimeSafari to show notifications',
required: true,
runtime: true,
category: 'notification'
},
{
name: 'android.permission.SCHEDULE_EXACT_ALARM',
description: 'Allow TimeSafari to schedule exact alarms for notifications',
required: true,
runtime: true,
category: 'alarm'
},
{
name: 'android.permission.USE_EXACT_ALARM',
description: 'Allow TimeSafari to use exact alarms',
required: false,
runtime: false,
category: 'alarm'
},
{
name: 'android.permission.WAKE_LOCK',
description: 'Allow TimeSafari to keep device awake for background tasks',
required: true,
runtime: false,
category: 'system'
},
{
name: 'android.permission.RECEIVE_BOOT_COMPLETED',
description: 'Allow TimeSafari to restart notifications after device reboot',
required: true,
runtime: false,
category: 'system'
},
{
name: 'android.permission.INTERNET',
description: 'Allow TimeSafari to fetch community data and send callbacks',
required: true,
runtime: false,
category: 'network'
},
{
name: 'android.permission.ACCESS_NETWORK_STATE',
description: 'Allow TimeSafari to check network connectivity',
required: true,
runtime: false,
category: 'network'
}
],
batteryOptimization: {
exemptPackages: ['com.timesafari.dailynotification'],
whitelistRequestMessage: 'TimeSafari needs to run in the background to deliver your daily community updates and notifications. Please whitelist TimeSafari from battery optimization.',
optimizationCheckInterval: 60, // 1 hour
fallbackBehavior: 'graceful'
},
powerManagement: {
dozeModeHandling: 'request_whitelist',
appStandbyHandling: 'request_whitelist',
backgroundRestrictions: 'request_whitelist',
adaptiveBatteryHandling: 'request_whitelist'
},
workManagerConstraints: {
networkType: 'connected',
requiresBatteryNotLow: false,
requiresCharging: false,
requiresDeviceIdle: false,
requiresStorageNotLow: true,
backoffPolicy: 'exponential',
backoffDelay: 30000, // 30 seconds
maxRetries: 3
}
};
/**
* TimeSafari Android Configuration Manager
*/
export class TimeSafariAndroidConfigManager {
private config: TimeSafariAndroidConfig;
constructor(config?: Partial<TimeSafariAndroidConfig>) {
this.config = { ...DEFAULT_TIMESAFARI_ANDROID_CONFIG, ...config };
}
/**
* Get notification channel configuration
*/
getNotificationChannel(channelId: string): NotificationChannelConfig | undefined {
return this.config.notificationChannels.find(channel => channel.id === channelId);
}
/**
* Get all notification channels
*/
getAllNotificationChannels(): NotificationChannelConfig[] {
return this.config.notificationChannels;
}
/**
* Get required permissions
*/
getRequiredPermissions(): AndroidPermission[] {
return this.config.permissions.filter(permission => permission.required);
}
/**
* Get runtime permissions
*/
getRuntimePermissions(): AndroidPermission[] {
return this.config.permissions.filter(permission => permission.runtime);
}
/**
* Get permissions by category
*/
getPermissionsByCategory(category: string): AndroidPermission[] {
return this.config.permissions.filter(permission => permission.category === category);
}
/**
* Get battery optimization configuration
*/
getBatteryOptimizationConfig(): BatteryOptimizationConfig {
return this.config.batteryOptimization;
}
/**
* Get power management configuration
*/
getPowerManagementConfig(): PowerManagementConfig {
return this.config.powerManagement;
}
/**
* Get WorkManager constraints
*/
getWorkManagerConstraints(): WorkManagerConstraints {
return this.config.workManagerConstraints;
}
/**
* Update configuration
*/
updateConfig(updates: Partial<TimeSafariAndroidConfig>): void {
this.config = { ...this.config, ...updates };
}
/**
* Validate configuration
*/
validateConfig(): { valid: boolean; errors: string[] } {
const errors: string[] = [];
// Validate notification channels
if (this.config.notificationChannels.length === 0) {
errors.push('At least one notification channel must be configured');
}
// Validate permissions
const requiredPermissions = this.getRequiredPermissions();
if (requiredPermissions.length === 0) {
errors.push('At least one required permission must be configured');
}
// Validate WorkManager constraints
if (this.config.workManagerConstraints.maxRetries < 0) {
errors.push('WorkManager maxRetries must be non-negative');
}
if (this.config.workManagerConstraints.backoffDelay < 0) {
errors.push('WorkManager backoffDelay must be non-negative');
}
return {
valid: errors.length === 0,
errors
};
}
}

312
src/android/DailyNotificationDatabase.java

@ -1,312 +0,0 @@
/**
* DailyNotificationDatabase.java
*
* SQLite database management for shared notification storage
* Implements the three-table schema with WAL mode for concurrent access
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.dailynotification;
import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.util.Log;
import java.io.File;
/**
* Manages SQLite database for shared notification storage
*
* This class implements the shared database approach where:
* - App owns schema/migrations (PRAGMA user_version)
* - Plugin opens the same path with WAL mode
* - Background writes are short & serialized
* - Foreground reads proceed during background commits
*/
public class DailyNotificationDatabase extends SQLiteOpenHelper {
private static final String TAG = "DailyNotificationDatabase";
private static final String DATABASE_NAME = "daily_notifications.db";
private static final int DATABASE_VERSION = 1;
// Table names
public static final String TABLE_NOTIF_CONTENTS = "notif_contents";
public static final String TABLE_NOTIF_DELIVERIES = "notif_deliveries";
public static final String TABLE_NOTIF_CONFIG = "notif_config";
// Column names for notif_contents
public static final String COL_CONTENTS_ID = "id";
public static final String COL_CONTENTS_SLOT_ID = "slot_id";
public static final String COL_CONTENTS_PAYLOAD_JSON = "payload_json";
public static final String COL_CONTENTS_FETCHED_AT = "fetched_at";
public static final String COL_CONTENTS_ETAG = "etag";
// Column names for notif_deliveries
public static final String COL_DELIVERIES_ID = "id";
public static final String COL_DELIVERIES_SLOT_ID = "slot_id";
public static final String COL_DELIVERIES_FIRE_AT = "fire_at";
public static final String COL_DELIVERIES_DELIVERED_AT = "delivered_at";
public static final String COL_DELIVERIES_STATUS = "status";
public static final String COL_DELIVERIES_ERROR_CODE = "error_code";
public static final String COL_DELIVERIES_ERROR_MESSAGE = "error_message";
// Column names for notif_config
public static final String COL_CONFIG_K = "k";
public static final String COL_CONFIG_V = "v";
// Status values
public static final String STATUS_SCHEDULED = "scheduled";
public static final String STATUS_SHOWN = "shown";
public static final String STATUS_ERROR = "error";
public static final String STATUS_CANCELED = "canceled";
/**
* Constructor
*
* @param context Application context
* @param dbPath Database file path (null for default location)
*/
public DailyNotificationDatabase(Context context, String dbPath) {
super(context, dbPath != null ? dbPath : DATABASE_NAME, null, DATABASE_VERSION);
}
/**
* Constructor with default database location
*
* @param context Application context
*/
public DailyNotificationDatabase(Context context) {
this(context, null);
}
@Override
public void onCreate(SQLiteDatabase db) {
Log.d(TAG, "Creating database tables");
// Configure database for WAL mode and concurrent access
configureDatabase(db);
// Create tables
createTables(db);
// Create indexes
createIndexes(db);
Log.i(TAG, "Database created successfully");
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
Log.d(TAG, "Upgrading database from version " + oldVersion + " to " + newVersion);
// For now, drop and recreate tables
// In production, implement proper migration logic
dropTables(db);
onCreate(db);
Log.i(TAG, "Database upgraded successfully");
}
@Override
public void onOpen(SQLiteDatabase db) {
super.onOpen(db);
// Ensure WAL mode is enabled on every open
configureDatabase(db);
// Verify schema version
verifySchemaVersion(db);
Log.d(TAG, "Database opened with WAL mode");
}
/**
* Configure database for optimal performance and concurrency
*
* @param db SQLite database instance
*/
private void configureDatabase(SQLiteDatabase db) {
// Enable WAL mode for concurrent reads during writes
db.execSQL("PRAGMA journal_mode=WAL");
// Set synchronous mode to NORMAL for better performance
db.execSQL("PRAGMA synchronous=NORMAL");
// Set busy timeout to handle concurrent access
db.execSQL("PRAGMA busy_timeout=5000");
// Enable foreign key constraints
db.execSQL("PRAGMA foreign_keys=ON");
// Set cache size for better performance
db.execSQL("PRAGMA cache_size=1000");
Log.d(TAG, "Database configured with WAL mode and optimizations");
}
/**
* Create all database tables
*
* @param db SQLite database instance
*/
private void createTables(SQLiteDatabase db) {
// notif_contents: keep history, fast newest-first reads
String createContentsTable = String.format(
"CREATE TABLE IF NOT EXISTS %s(" +
"%s INTEGER PRIMARY KEY AUTOINCREMENT," +
"%s TEXT NOT NULL," +
"%s TEXT NOT NULL," +
"%s INTEGER NOT NULL," + // epoch ms
"%s TEXT," +
"UNIQUE(%s, %s)" +
")",
TABLE_NOTIF_CONTENTS,
COL_CONTENTS_ID,
COL_CONTENTS_SLOT_ID,
COL_CONTENTS_PAYLOAD_JSON,
COL_CONTENTS_FETCHED_AT,
COL_CONTENTS_ETAG,
COL_CONTENTS_SLOT_ID,
COL_CONTENTS_FETCHED_AT
);
// notif_deliveries: track many deliveries per slot/time
String createDeliveriesTable = String.format(
"CREATE TABLE IF NOT EXISTS %s(" +
"%s INTEGER PRIMARY KEY AUTOINCREMENT," +
"%s TEXT NOT NULL," +
"%s INTEGER NOT NULL," + // epoch ms
"%s INTEGER," + // epoch ms
"%s TEXT NOT NULL DEFAULT '%s'," +
"%s TEXT," +
"%s TEXT" +
")",
TABLE_NOTIF_DELIVERIES,
COL_DELIVERIES_ID,
COL_DELIVERIES_SLOT_ID,
COL_DELIVERIES_FIRE_AT,
COL_DELIVERIES_DELIVERED_AT,
COL_DELIVERIES_STATUS,
STATUS_SCHEDULED,
COL_DELIVERIES_ERROR_CODE,
COL_DELIVERIES_ERROR_MESSAGE
);
// notif_config: generic configuration KV
String createConfigTable = String.format(
"CREATE TABLE IF NOT EXISTS %s(" +
"%s TEXT PRIMARY KEY," +
"%s TEXT NOT NULL" +
")",
TABLE_NOTIF_CONFIG,
COL_CONFIG_K,
COL_CONFIG_V
);
db.execSQL(createContentsTable);
db.execSQL(createDeliveriesTable);
db.execSQL(createConfigTable);
Log.d(TAG, "Database tables created");
}
/**
* Create database indexes for optimal query performance
*
* @param db SQLite database instance
*/
private void createIndexes(SQLiteDatabase db) {
// Index for notif_contents: slot_id + fetched_at DESC for newest-first reads
String createContentsIndex = String.format(
"CREATE INDEX IF NOT EXISTS notif_idx_contents_slot_time ON %s(%s, %s DESC)",
TABLE_NOTIF_CONTENTS,
COL_CONTENTS_SLOT_ID,
COL_CONTENTS_FETCHED_AT
);
// Index for notif_deliveries: slot_id for delivery tracking
String createDeliveriesIndex = String.format(
"CREATE INDEX IF NOT EXISTS notif_idx_deliveries_slot ON %s(%s)",
TABLE_NOTIF_DELIVERIES,
COL_DELIVERIES_SLOT_ID
);
db.execSQL(createContentsIndex);
db.execSQL(createDeliveriesIndex);
Log.d(TAG, "Database indexes created");
}
/**
* Drop all database tables (for migration)
*
* @param db SQLite database instance
*/
private void dropTables(SQLiteDatabase db) {
db.execSQL("DROP TABLE IF EXISTS " + TABLE_NOTIF_CONTENTS);
db.execSQL("DROP TABLE IF EXISTS " + TABLE_NOTIF_DELIVERIES);
db.execSQL("DROP TABLE IF EXISTS " + TABLE_NOTIF_CONFIG);
Log.d(TAG, "Database tables dropped");
}
/**
* Verify schema version compatibility
*
* @param db SQLite database instance
*/
private void verifySchemaVersion(SQLiteDatabase db) {
try {
// Get current user_version
android.database.Cursor cursor = db.rawQuery("PRAGMA user_version", null);
int currentVersion = 0;
if (cursor.moveToFirst()) {
currentVersion = cursor.getInt(0);
}
cursor.close();
Log.d(TAG, "Current schema version: " + currentVersion);
// Set user_version to match our DATABASE_VERSION
db.execSQL("PRAGMA user_version=" + DATABASE_VERSION);
Log.d(TAG, "Schema version verified and set to " + DATABASE_VERSION);
} catch (Exception e) {
Log.e(TAG, "Error verifying schema version", e);
throw new RuntimeException("Schema version verification failed", e);
}
}
/**
* Get database file path
*
* @return Database file path
*/
public String getDatabasePath() {
return getReadableDatabase().getPath();
}
/**
* Check if database file exists
*
* @return true if database file exists
*/
public boolean databaseExists() {
File dbFile = new File(getDatabasePath());
return dbFile.exists();
}
/**
* Get database size in bytes
*
* @return Database file size in bytes
*/
public long getDatabaseSize() {
File dbFile = new File(getDatabasePath());
return dbFile.exists() ? dbFile.length() : 0;
}
}
Loading…
Cancel
Save