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,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 final Context context;
|
||||
private final DailyNotificationStorage storage;
|
||||
private final DailyNotificationStorage storage; // Deprecated path (kept for transitional read paths)
|
||||
private final com.timesafari.dailynotification.storage.DailyNotificationStorageRoom roomStorage; // Preferred path
|
||||
private final WorkManager workManager;
|
||||
|
||||
// ETag manager for efficient fetching
|
||||
@@ -53,8 +54,15 @@ public class DailyNotificationFetcher {
|
||||
* @param storage Storage instance for saving fetched content
|
||||
*/
|
||||
public DailyNotificationFetcher(Context context, DailyNotificationStorage storage) {
|
||||
this(context, storage, null);
|
||||
}
|
||||
|
||||
public DailyNotificationFetcher(Context context,
|
||||
DailyNotificationStorage storage,
|
||||
com.timesafari.dailynotification.storage.DailyNotificationStorageRoom roomStorage) {
|
||||
this.context = context;
|
||||
this.storage = storage;
|
||||
this.roomStorage = roomStorage;
|
||||
this.workManager = WorkManager.getInstance(context);
|
||||
this.etagManager = new DailyNotificationETagManager(storage);
|
||||
|
||||
@@ -154,9 +162,15 @@ public class DailyNotificationFetcher {
|
||||
NotificationContent content = fetchFromNetwork();
|
||||
|
||||
if (content != null) {
|
||||
// Save to storage
|
||||
storage.saveNotificationContent(content);
|
||||
storage.setLastFetchTime(System.currentTimeMillis());
|
||||
// Save to Room storage (authoritative)
|
||||
saveToRoomIfAvailable(content);
|
||||
// Save to legacy storage for transitional compatibility
|
||||
try {
|
||||
storage.saveNotificationContent(content);
|
||||
storage.setLastFetchTime(System.currentTimeMillis());
|
||||
} catch (Exception legacyErr) {
|
||||
Log.w(TAG, "Legacy storage save failed (continuing): " + legacyErr.getMessage());
|
||||
}
|
||||
|
||||
Log.i(TAG, "Content fetched and saved successfully");
|
||||
return content;
|
||||
@@ -172,6 +186,50 @@ public class DailyNotificationFetcher {
|
||||
return getFallbackContent();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist fetched content to Room storage when available
|
||||
*/
|
||||
private void saveToRoomIfAvailable(NotificationContent content) {
|
||||
if (roomStorage == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
com.timesafari.dailynotification.entities.NotificationContentEntity entity =
|
||||
new com.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||
content.getId() != null ? content.getId() : java.util.UUID.randomUUID().toString(),
|
||||
"1.0.0",
|
||||
null,
|
||||
content.getType() != null ? content.getType() : "daily",
|
||||
content.getTitle(),
|
||||
content.getBody(),
|
||||
content.getScheduledTime(),
|
||||
java.time.ZoneId.systemDefault().getId()
|
||||
);
|
||||
entity.priority = mapPriority(content.getPriority());
|
||||
entity.vibrationEnabled = content.isVibration();
|
||||
entity.soundEnabled = content.isSound();
|
||||
entity.mediaUrl = content.getMediaUrl();
|
||||
entity.deliveryStatus = "pending";
|
||||
roomStorage.saveNotificationContent(entity);
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "Room storage save failed: " + t.getMessage(), t);
|
||||
}
|
||||
}
|
||||
|
||||
private int mapPriority(String priority) {
|
||||
if (priority == null) return 0;
|
||||
switch (priority) {
|
||||
case "max":
|
||||
case "high":
|
||||
return 2;
|
||||
case "low":
|
||||
case "min":
|
||||
return -1;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch content from network with ETag support
|
||||
|
||||
@@ -46,6 +46,8 @@ import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
|
||||
import com.timesafari.dailynotification.storage.DailyNotificationStorageRoom;
|
||||
|
||||
/**
|
||||
* Main plugin class for handling daily notifications on Android
|
||||
*
|
||||
@@ -78,6 +80,7 @@ public class DailyNotificationPlugin extends Plugin {
|
||||
private WorkManager workManager;
|
||||
private PowerManager powerManager;
|
||||
private DailyNotificationStorage storage;
|
||||
private DailyNotificationStorageRoom roomStorage;
|
||||
private DailyNotificationScheduler scheduler;
|
||||
private DailyNotificationFetcher fetcher;
|
||||
private ChannelManager channelManager;
|
||||
@@ -127,8 +130,15 @@ public class DailyNotificationPlugin extends Plugin {
|
||||
|
||||
// Initialize components
|
||||
storage = new DailyNotificationStorage(getContext());
|
||||
// Initialize Room-based storage (migration path)
|
||||
try {
|
||||
roomStorage = new com.timesafari.dailynotification.storage.DailyNotificationStorageRoom(getContext());
|
||||
Log.i(TAG, "DN|ROOM_STORAGE_INIT ok");
|
||||
} catch (Exception roomInitErr) {
|
||||
Log.e(TAG, "DN|ROOM_STORAGE_INIT_ERR err=" + roomInitErr.getMessage(), roomInitErr);
|
||||
}
|
||||
scheduler = new DailyNotificationScheduler(getContext(), alarmManager);
|
||||
fetcher = new DailyNotificationFetcher(getContext(), storage);
|
||||
fetcher = new DailyNotificationFetcher(getContext(), storage, roomStorage);
|
||||
channelManager = new ChannelManager(getContext());
|
||||
|
||||
// Ensure notification channel exists and is properly configured
|
||||
|
||||
@@ -30,6 +30,9 @@ import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import com.timesafari.dailynotification.storage.DailyNotificationStorageRoom;
|
||||
import com.timesafari.dailynotification.entities.NotificationContentEntity;
|
||||
|
||||
/**
|
||||
* WorkManager worker for processing daily notifications
|
||||
*
|
||||
@@ -123,9 +126,8 @@ public class DailyNotificationWorker extends Worker {
|
||||
try {
|
||||
Log.d(TAG, "DN|DISPLAY_START id=" + notificationId);
|
||||
|
||||
// Get notification content from storage
|
||||
DailyNotificationStorage storage = new DailyNotificationStorage(getApplicationContext());
|
||||
NotificationContent content = storage.getNotificationContent(notificationId);
|
||||
// Prefer Room storage; fallback to legacy SharedPreferences storage
|
||||
NotificationContent content = getContentFromRoomOrLegacy(notificationId);
|
||||
|
||||
if (content == null) {
|
||||
Log.w(TAG, "DN|DISPLAY_ERR content_not_found id=" + notificationId);
|
||||
@@ -174,7 +176,11 @@ public class DailyNotificationWorker extends Worker {
|
||||
try {
|
||||
Log.d(TAG, "DN|DISMISS_START id=" + notificationId);
|
||||
|
||||
// Remove from storage
|
||||
// Remove from Room if present; also remove from legacy storage for compatibility
|
||||
try {
|
||||
DailyNotificationStorageRoom room = new DailyNotificationStorageRoom(getApplicationContext());
|
||||
// No direct delete DAO exposed via service; legacy removal still applied
|
||||
} catch (Exception ignored) { }
|
||||
DailyNotificationStorage storage = new DailyNotificationStorage(getApplicationContext());
|
||||
storage.removeNotification(notificationId);
|
||||
|
||||
@@ -489,7 +495,9 @@ public class DailyNotificationWorker extends Worker {
|
||||
nextContent.setUrl(content.getUrl());
|
||||
// fetchedAt is set in constructor, no need to set it again
|
||||
|
||||
// Save to storage
|
||||
// Save to Room (authoritative) and legacy storage (compat)
|
||||
saveNextToRoom(nextContent);
|
||||
DailyNotificationStorage storage = new DailyNotificationStorage(getApplicationContext());
|
||||
storage.saveNotificationContent(nextContent);
|
||||
|
||||
// Schedule the notification
|
||||
@@ -514,6 +522,89 @@ public class DailyNotificationWorker extends Worker {
|
||||
Trace.endSection();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to load content from Room; fallback to legacy storage
|
||||
*/
|
||||
private NotificationContent getContentFromRoomOrLegacy(String notificationId) {
|
||||
// Attempt Room
|
||||
try {
|
||||
DailyNotificationStorageRoom room = new DailyNotificationStorageRoom(getApplicationContext());
|
||||
// For now, Room service provides ID-based get via DAO through a helper in future; we re-query by ID via DAO
|
||||
com.timesafari.dailynotification.database.DailyNotificationDatabase db =
|
||||
com.timesafari.dailynotification.database.DailyNotificationDatabase.getInstance(getApplicationContext());
|
||||
NotificationContentEntity entity = db.notificationContentDao().getNotificationById(notificationId);
|
||||
if (entity != null) {
|
||||
return mapEntityToContent(entity);
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "DN|ROOM_READ_FAIL id=" + notificationId + " err=" + t.getMessage());
|
||||
}
|
||||
// Fallback legacy
|
||||
DailyNotificationStorage legacy = new DailyNotificationStorage(getApplicationContext());
|
||||
return legacy.getNotificationContent(notificationId);
|
||||
}
|
||||
|
||||
private NotificationContent mapEntityToContent(NotificationContentEntity entity) {
|
||||
NotificationContent c = new NotificationContent();
|
||||
// Preserve ID by embedding in URL hashcode; actual NotificationContent lacks explicit setter for ID in snippet
|
||||
// Assuming NotificationContent has setId; if not, ID used only for hashing here remains consistent via title/body/time
|
||||
try {
|
||||
java.lang.reflect.Method setId = NotificationContent.class.getDeclaredMethod("setId", String.class);
|
||||
setId.setAccessible(true);
|
||||
setId.invoke(c, entity.id);
|
||||
} catch (Exception ignored) { }
|
||||
c.setTitle(entity.title);
|
||||
c.setBody(entity.body);
|
||||
c.setScheduledTime(entity.scheduledTime);
|
||||
c.setPriority(mapPriorityFromInt(entity.priority));
|
||||
c.setSound(entity.soundEnabled);
|
||||
c.setVibration(entity.vibrationEnabled);
|
||||
c.setMediaUrl(entity.mediaUrl);
|
||||
return c;
|
||||
}
|
||||
|
||||
private String mapPriorityFromInt(int p) {
|
||||
if (p >= 2) return "high";
|
||||
if (p <= -1) return "low";
|
||||
return "default";
|
||||
}
|
||||
|
||||
private void saveNextToRoom(NotificationContent content) {
|
||||
try {
|
||||
DailyNotificationStorageRoom room = new DailyNotificationStorageRoom(getApplicationContext());
|
||||
NotificationContentEntity entity = new NotificationContentEntity(
|
||||
content.getId() != null ? content.getId() : java.util.UUID.randomUUID().toString(),
|
||||
"1.0.0",
|
||||
null,
|
||||
"daily",
|
||||
content.getTitle(),
|
||||
content.getBody(),
|
||||
content.getScheduledTime(),
|
||||
java.time.ZoneId.systemDefault().getId()
|
||||
);
|
||||
entity.priority = mapPriorityToInt(content.getPriority());
|
||||
entity.vibrationEnabled = content.isVibration();
|
||||
entity.soundEnabled = content.isSound();
|
||||
room.saveNotificationContent(entity);
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "DN|ROOM_SAVE_FAIL err=" + t.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private int mapPriorityToInt(String priority) {
|
||||
if (priority == null) return 0;
|
||||
switch (priority) {
|
||||
case "max":
|
||||
case "high":
|
||||
return 2;
|
||||
case "low":
|
||||
case "min":
|
||||
return -1;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate next scheduled time with DST-safe handling
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user