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
This commit is contained in:
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -40,7 +40,8 @@ public class DailyNotificationFetcher {
|
|||||||
private static final long RETRY_DELAY_MS = 60000; // 1 minute
|
private static final long RETRY_DELAY_MS = 60000; // 1 minute
|
||||||
|
|
||||||
private final Context context;
|
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;
|
private final WorkManager workManager;
|
||||||
|
|
||||||
// ETag manager for efficient fetching
|
// ETag manager for efficient fetching
|
||||||
@@ -53,8 +54,15 @@ public class DailyNotificationFetcher {
|
|||||||
* @param storage Storage instance for saving fetched content
|
* @param storage Storage instance for saving fetched content
|
||||||
*/
|
*/
|
||||||
public DailyNotificationFetcher(Context context, DailyNotificationStorage storage) {
|
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.context = context;
|
||||||
this.storage = storage;
|
this.storage = storage;
|
||||||
|
this.roomStorage = roomStorage;
|
||||||
this.workManager = WorkManager.getInstance(context);
|
this.workManager = WorkManager.getInstance(context);
|
||||||
this.etagManager = new DailyNotificationETagManager(storage);
|
this.etagManager = new DailyNotificationETagManager(storage);
|
||||||
|
|
||||||
@@ -154,9 +162,15 @@ public class DailyNotificationFetcher {
|
|||||||
NotificationContent content = fetchFromNetwork();
|
NotificationContent content = fetchFromNetwork();
|
||||||
|
|
||||||
if (content != null) {
|
if (content != null) {
|
||||||
// Save to storage
|
// Save to Room storage (authoritative)
|
||||||
storage.saveNotificationContent(content);
|
saveToRoomIfAvailable(content);
|
||||||
storage.setLastFetchTime(System.currentTimeMillis());
|
// 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");
|
Log.i(TAG, "Content fetched and saved successfully");
|
||||||
return content;
|
return content;
|
||||||
@@ -172,6 +186,50 @@ public class DailyNotificationFetcher {
|
|||||||
return getFallbackContent();
|
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
|
* Fetch content from network with ETag support
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ import java.util.List;
|
|||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import androidx.core.app.NotificationManagerCompat;
|
import androidx.core.app.NotificationManagerCompat;
|
||||||
|
|
||||||
|
import com.timesafari.dailynotification.storage.DailyNotificationStorageRoom;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main plugin class for handling daily notifications on Android
|
* Main plugin class for handling daily notifications on Android
|
||||||
*
|
*
|
||||||
@@ -78,6 +80,7 @@ public class DailyNotificationPlugin extends Plugin {
|
|||||||
private WorkManager workManager;
|
private WorkManager workManager;
|
||||||
private PowerManager powerManager;
|
private PowerManager powerManager;
|
||||||
private DailyNotificationStorage storage;
|
private DailyNotificationStorage storage;
|
||||||
|
private DailyNotificationStorageRoom roomStorage;
|
||||||
private DailyNotificationScheduler scheduler;
|
private DailyNotificationScheduler scheduler;
|
||||||
private DailyNotificationFetcher fetcher;
|
private DailyNotificationFetcher fetcher;
|
||||||
private ChannelManager channelManager;
|
private ChannelManager channelManager;
|
||||||
@@ -127,8 +130,15 @@ public class DailyNotificationPlugin extends Plugin {
|
|||||||
|
|
||||||
// Initialize components
|
// Initialize components
|
||||||
storage = new DailyNotificationStorage(getContext());
|
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);
|
scheduler = new DailyNotificationScheduler(getContext(), alarmManager);
|
||||||
fetcher = new DailyNotificationFetcher(getContext(), storage);
|
fetcher = new DailyNotificationFetcher(getContext(), storage, roomStorage);
|
||||||
channelManager = new ChannelManager(getContext());
|
channelManager = new ChannelManager(getContext());
|
||||||
|
|
||||||
// Ensure notification channel exists and is properly configured
|
// Ensure notification channel exists and is properly configured
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ import java.util.concurrent.TimeUnit;
|
|||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
import com.timesafari.dailynotification.storage.DailyNotificationStorageRoom;
|
||||||
|
import com.timesafari.dailynotification.entities.NotificationContentEntity;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WorkManager worker for processing daily notifications
|
* WorkManager worker for processing daily notifications
|
||||||
*
|
*
|
||||||
@@ -123,9 +126,8 @@ public class DailyNotificationWorker extends Worker {
|
|||||||
try {
|
try {
|
||||||
Log.d(TAG, "DN|DISPLAY_START id=" + notificationId);
|
Log.d(TAG, "DN|DISPLAY_START id=" + notificationId);
|
||||||
|
|
||||||
// Get notification content from storage
|
// Prefer Room storage; fallback to legacy SharedPreferences storage
|
||||||
DailyNotificationStorage storage = new DailyNotificationStorage(getApplicationContext());
|
NotificationContent content = getContentFromRoomOrLegacy(notificationId);
|
||||||
NotificationContent content = storage.getNotificationContent(notificationId);
|
|
||||||
|
|
||||||
if (content == null) {
|
if (content == null) {
|
||||||
Log.w(TAG, "DN|DISPLAY_ERR content_not_found id=" + notificationId);
|
Log.w(TAG, "DN|DISPLAY_ERR content_not_found id=" + notificationId);
|
||||||
@@ -174,7 +176,11 @@ public class DailyNotificationWorker extends Worker {
|
|||||||
try {
|
try {
|
||||||
Log.d(TAG, "DN|DISMISS_START id=" + notificationId);
|
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());
|
DailyNotificationStorage storage = new DailyNotificationStorage(getApplicationContext());
|
||||||
storage.removeNotification(notificationId);
|
storage.removeNotification(notificationId);
|
||||||
|
|
||||||
@@ -489,7 +495,9 @@ public class DailyNotificationWorker extends Worker {
|
|||||||
nextContent.setUrl(content.getUrl());
|
nextContent.setUrl(content.getUrl());
|
||||||
// fetchedAt is set in constructor, no need to set it again
|
// 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);
|
storage.saveNotificationContent(nextContent);
|
||||||
|
|
||||||
// Schedule the notification
|
// Schedule the notification
|
||||||
@@ -514,6 +522,89 @@ public class DailyNotificationWorker extends Worker {
|
|||||||
Trace.endSection();
|
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
|
* Calculate next scheduled time with DST-safe handling
|
||||||
|
|||||||
@@ -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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user