Browse Source
- 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 Raymermaster
7 changed files with 169 additions and 1285 deletions
@ -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; |
|
||||
} |
|
||||
} |
|
@ -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; |
|
||||
} |
|
||||
} |
|
Loading…
Reference in new issue