Browse Source
- Fix capacitor.build.gradle to comment out missing Capacitor integration file - Create separate plugin module with proper build.gradle configuration - Copy plugin source code to android/plugin/src/main/java/ - Update settings.gradle to include plugin module - Fix plugin build.gradle to remove Kotlin plugin dependency - Successfully build plugin AAR: android/plugin/build/outputs/aar/plugin-release.aar - Update BUILDING.md with correct build commands and troubleshooting This resolves the Android Studio build issues by creating a proper plugin library module separate from the test app.master
9 changed files with 1201 additions and 13 deletions
@ -0,0 +1,153 @@ |
|||
package com.timesafari.dailynotification |
|||
|
|||
import android.content.BroadcastReceiver |
|||
import android.content.Context |
|||
import android.content.Intent |
|||
import android.util.Log |
|||
import kotlinx.coroutines.CoroutineScope |
|||
import kotlinx.coroutines.Dispatchers |
|||
import kotlinx.coroutines.launch |
|||
|
|||
/** |
|||
* Boot recovery receiver to reschedule notifications after device reboot |
|||
* Implements RECEIVE_BOOT_COMPLETED functionality |
|||
* |
|||
* @author Matthew Raymer |
|||
* @version 1.1.0 |
|||
*/ |
|||
class BootReceiver : BroadcastReceiver() { |
|||
|
|||
companion object { |
|||
private const val TAG = "DNP-BOOT" |
|||
} |
|||
|
|||
override fun onReceive(context: Context, intent: Intent?) { |
|||
if (intent?.action == Intent.ACTION_BOOT_COMPLETED) { |
|||
Log.i(TAG, "Boot completed, rescheduling notifications") |
|||
|
|||
CoroutineScope(Dispatchers.IO).launch { |
|||
try { |
|||
rescheduleNotifications(context) |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "Failed to reschedule notifications after boot", e) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
private suspend fun rescheduleNotifications(context: Context) { |
|||
val db = DailyNotificationDatabase.getDatabase(context) |
|||
val enabledSchedules = db.scheduleDao().getEnabled() |
|||
|
|||
Log.i(TAG, "Found ${enabledSchedules.size} enabled schedules to reschedule") |
|||
|
|||
enabledSchedules.forEach { schedule -> |
|||
try { |
|||
when (schedule.kind) { |
|||
"fetch" -> { |
|||
// Reschedule WorkManager fetch |
|||
val config = ContentFetchConfig( |
|||
enabled = schedule.enabled, |
|||
schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *", |
|||
url = null, // Will use mock content |
|||
timeout = 30000, |
|||
retryAttempts = 3, |
|||
retryDelay = 1000, |
|||
callbacks = CallbackConfig() |
|||
) |
|||
FetchWorker.scheduleFetch(context, config) |
|||
Log.i(TAG, "Rescheduled fetch for schedule: ${schedule.id}") |
|||
} |
|||
"notify" -> { |
|||
// Reschedule AlarmManager notification |
|||
val nextRunTime = calculateNextRunTime(schedule) |
|||
if (nextRunTime > System.currentTimeMillis()) { |
|||
val config = UserNotificationConfig( |
|||
enabled = schedule.enabled, |
|||
schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *", |
|||
title = "Daily Notification", |
|||
body = "Your daily update is ready", |
|||
sound = true, |
|||
vibration = true, |
|||
priority = "normal" |
|||
) |
|||
NotifyReceiver.scheduleExactNotification(context, nextRunTime, config) |
|||
Log.i(TAG, "Rescheduled notification for schedule: ${schedule.id}") |
|||
} |
|||
} |
|||
else -> { |
|||
Log.w(TAG, "Unknown schedule kind: ${schedule.kind}") |
|||
} |
|||
} |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "Failed to reschedule ${schedule.kind} for ${schedule.id}", e) |
|||
} |
|||
} |
|||
|
|||
// Record boot recovery in history |
|||
try { |
|||
db.historyDao().insert( |
|||
History( |
|||
refId = "boot_recovery_${System.currentTimeMillis()}", |
|||
kind = "boot_recovery", |
|||
occurredAt = System.currentTimeMillis(), |
|||
outcome = "success", |
|||
diagJson = "{\"schedules_rescheduled\": ${enabledSchedules.size}}" |
|||
) |
|||
) |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "Failed to record boot recovery", e) |
|||
} |
|||
} |
|||
|
|||
private fun calculateNextRunTime(schedule: Schedule): Long { |
|||
val now = System.currentTimeMillis() |
|||
|
|||
// Simple implementation - for production, use proper cron parsing |
|||
return when { |
|||
schedule.cron != null -> { |
|||
// Parse cron expression and calculate next run |
|||
// For now, return next day at 9 AM |
|||
now + (24 * 60 * 60 * 1000L) |
|||
} |
|||
schedule.clockTime != null -> { |
|||
// Parse HH:mm and calculate next run |
|||
// For now, return next day at specified time |
|||
now + (24 * 60 * 60 * 1000L) |
|||
} |
|||
else -> { |
|||
// Default to next day at 9 AM |
|||
now + (24 * 60 * 60 * 1000L) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Data classes for configuration (simplified versions) |
|||
*/ |
|||
data class ContentFetchConfig( |
|||
val enabled: Boolean, |
|||
val schedule: String, |
|||
val url: String? = null, |
|||
val timeout: Int? = null, |
|||
val retryAttempts: Int? = null, |
|||
val retryDelay: Int? = null, |
|||
val callbacks: CallbackConfig |
|||
) |
|||
|
|||
data class UserNotificationConfig( |
|||
val enabled: Boolean, |
|||
val schedule: String, |
|||
val title: String? = null, |
|||
val body: String? = null, |
|||
val sound: Boolean? = null, |
|||
val vibration: Boolean? = null, |
|||
val priority: String? = null |
|||
) |
|||
|
|||
data class CallbackConfig( |
|||
val apiService: String? = null, |
|||
val database: String? = null, |
|||
val reporting: String? = null |
|||
) |
@ -0,0 +1,294 @@ |
|||
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 |
|||
} |
|||
} |
@ -0,0 +1,144 @@ |
|||
package com.timesafari.dailynotification |
|||
|
|||
import androidx.room.* |
|||
import androidx.room.migration.Migration |
|||
import androidx.sqlite.db.SupportSQLiteDatabase |
|||
|
|||
/** |
|||
* SQLite schema for Daily Notification Plugin |
|||
* Implements TTL-at-fire invariant and rolling window armed design |
|||
* |
|||
* @author Matthew Raymer |
|||
* @version 1.1.0 |
|||
*/ |
|||
@Entity(tableName = "content_cache") |
|||
data class ContentCache( |
|||
@PrimaryKey val id: String, |
|||
val fetchedAt: Long, // epoch ms |
|||
val ttlSeconds: Int, |
|||
val payload: ByteArray, // BLOB |
|||
val meta: String? = null |
|||
) |
|||
|
|||
@Entity(tableName = "schedules") |
|||
data class Schedule( |
|||
@PrimaryKey val id: String, |
|||
val kind: String, // 'fetch' or 'notify' |
|||
val cron: String? = null, // optional cron expression |
|||
val clockTime: String? = null, // optional HH:mm |
|||
val enabled: Boolean = true, |
|||
val lastRunAt: Long? = null, |
|||
val nextRunAt: Long? = null, |
|||
val jitterMs: Int = 0, |
|||
val backoffPolicy: String = "exp", |
|||
val stateJson: String? = null |
|||
) |
|||
|
|||
@Entity(tableName = "callbacks") |
|||
data class Callback( |
|||
@PrimaryKey val id: String, |
|||
val kind: String, // 'http', 'local', 'queue' |
|||
val target: String, // url_or_local |
|||
val headersJson: String? = null, |
|||
val enabled: Boolean = true, |
|||
val createdAt: Long |
|||
) |
|||
|
|||
@Entity(tableName = "history") |
|||
data class History( |
|||
@PrimaryKey(autoGenerate = true) val id: Int = 0, |
|||
val refId: String, // content or schedule id |
|||
val kind: String, // fetch/notify/callback |
|||
val occurredAt: Long, |
|||
val durationMs: Long? = null, |
|||
val outcome: String, // success|failure|skipped_ttl|circuit_open |
|||
val diagJson: String? = null |
|||
) |
|||
|
|||
@Database( |
|||
entities = [ContentCache::class, Schedule::class, Callback::class, History::class], |
|||
version = 1, |
|||
exportSchema = false |
|||
) |
|||
@TypeConverters(Converters::class) |
|||
abstract class DailyNotificationDatabase : RoomDatabase() { |
|||
abstract fun contentCacheDao(): ContentCacheDao |
|||
abstract fun scheduleDao(): ScheduleDao |
|||
abstract fun callbackDao(): CallbackDao |
|||
abstract fun historyDao(): HistoryDao |
|||
} |
|||
|
|||
@Dao |
|||
interface ContentCacheDao { |
|||
@Query("SELECT * FROM content_cache WHERE id = :id") |
|||
suspend fun getById(id: String): ContentCache? |
|||
|
|||
@Query("SELECT * FROM content_cache ORDER BY fetchedAt DESC LIMIT 1") |
|||
suspend fun getLatest(): ContentCache? |
|||
|
|||
@Insert(onConflict = OnConflictStrategy.REPLACE) |
|||
suspend fun upsert(contentCache: ContentCache) |
|||
|
|||
@Query("DELETE FROM content_cache WHERE fetchedAt < :cutoffTime") |
|||
suspend fun deleteOlderThan(cutoffTime: Long) |
|||
|
|||
@Query("SELECT COUNT(*) FROM content_cache") |
|||
suspend fun getCount(): Int |
|||
} |
|||
|
|||
@Dao |
|||
interface ScheduleDao { |
|||
@Query("SELECT * FROM schedules WHERE enabled = 1") |
|||
suspend fun getEnabled(): List<Schedule> |
|||
|
|||
@Query("SELECT * FROM schedules WHERE id = :id") |
|||
suspend fun getById(id: String): Schedule? |
|||
|
|||
@Insert(onConflict = OnConflictStrategy.REPLACE) |
|||
suspend fun upsert(schedule: Schedule) |
|||
|
|||
@Query("UPDATE schedules SET enabled = :enabled WHERE id = :id") |
|||
suspend fun setEnabled(id: String, enabled: Boolean) |
|||
|
|||
@Query("UPDATE schedules SET lastRunAt = :lastRunAt, nextRunAt = :nextRunAt WHERE id = :id") |
|||
suspend fun updateRunTimes(id: String, lastRunAt: Long?, nextRunAt: Long?) |
|||
} |
|||
|
|||
@Dao |
|||
interface CallbackDao { |
|||
@Query("SELECT * FROM callbacks WHERE enabled = 1") |
|||
suspend fun getEnabled(): List<Callback> |
|||
|
|||
@Insert(onConflict = OnConflictStrategy.REPLACE) |
|||
suspend fun upsert(callback: Callback) |
|||
|
|||
@Query("DELETE FROM callbacks WHERE id = :id") |
|||
suspend fun deleteById(id: String) |
|||
} |
|||
|
|||
@Dao |
|||
interface HistoryDao { |
|||
@Insert |
|||
suspend fun insert(history: History) |
|||
|
|||
@Query("SELECT * FROM history WHERE occurredAt >= :since ORDER BY occurredAt DESC") |
|||
suspend fun getSince(since: Long): List<History> |
|||
|
|||
@Query("DELETE FROM history WHERE occurredAt < :cutoffTime") |
|||
suspend fun deleteOlderThan(cutoffTime: Long) |
|||
|
|||
@Query("SELECT COUNT(*) FROM history") |
|||
suspend fun getCount(): Int |
|||
} |
|||
|
|||
class Converters { |
|||
@TypeConverter |
|||
fun fromByteArray(value: ByteArray?): String? { |
|||
return value?.let { String(it) } |
|||
} |
|||
|
|||
@TypeConverter |
|||
fun toByteArray(value: String?): ByteArray? { |
|||
return value?.toByteArray() |
|||
} |
|||
} |
@ -0,0 +1,202 @@ |
|||
package com.timesafari.dailynotification |
|||
|
|||
import android.content.Context |
|||
import android.util.Log |
|||
import androidx.work.* |
|||
import kotlinx.coroutines.Dispatchers |
|||
import kotlinx.coroutines.withContext |
|||
import java.io.IOException |
|||
import java.net.HttpURLConnection |
|||
import java.net.URL |
|||
import java.util.concurrent.TimeUnit |
|||
|
|||
/** |
|||
* WorkManager implementation for content fetching |
|||
* Implements exponential backoff and network constraints |
|||
* |
|||
* @author Matthew Raymer |
|||
* @version 1.1.0 |
|||
*/ |
|||
class FetchWorker( |
|||
appContext: Context, |
|||
workerParams: WorkerParameters |
|||
) : CoroutineWorker(appContext, workerParams) { |
|||
|
|||
companion object { |
|||
private const val TAG = "DNP-FETCH" |
|||
private const val WORK_NAME = "fetch_content" |
|||
|
|||
fun scheduleFetch(context: Context, config: ContentFetchConfig) { |
|||
val constraints = Constraints.Builder() |
|||
.setRequiredNetworkType(NetworkType.CONNECTED) |
|||
.build() |
|||
|
|||
val workRequest = OneTimeWorkRequestBuilder<FetchWorker>() |
|||
.setConstraints(constraints) |
|||
.setBackoffCriteria( |
|||
BackoffPolicy.EXPONENTIAL, |
|||
30, |
|||
TimeUnit.SECONDS |
|||
) |
|||
.setInputData( |
|||
Data.Builder() |
|||
.putString("url", config.url) |
|||
.putString("headers", config.headers?.toString()) |
|||
.putInt("timeout", config.timeout ?: 30000) |
|||
.putInt("retryAttempts", config.retryAttempts ?: 3) |
|||
.putInt("retryDelay", config.retryDelay ?: 1000) |
|||
.build() |
|||
) |
|||
.build() |
|||
|
|||
WorkManager.getInstance(context) |
|||
.enqueueUniqueWork( |
|||
WORK_NAME, |
|||
ExistingWorkPolicy.REPLACE, |
|||
workRequest |
|||
) |
|||
} |
|||
} |
|||
|
|||
override suspend fun doWork(): Result = withContext(Dispatchers.IO) { |
|||
val start = SystemClock.elapsedRealtime() |
|||
val url = inputData.getString("url") |
|||
val timeout = inputData.getInt("timeout", 30000) |
|||
val retryAttempts = inputData.getInt("retryAttempts", 3) |
|||
val retryDelay = inputData.getInt("retryDelay", 1000) |
|||
|
|||
try { |
|||
Log.i(TAG, "Starting content fetch from: $url") |
|||
|
|||
val payload = fetchContent(url, timeout, retryAttempts, retryDelay) |
|||
val contentCache = ContentCache( |
|||
id = generateId(), |
|||
fetchedAt = System.currentTimeMillis(), |
|||
ttlSeconds = 3600, // 1 hour default TTL |
|||
payload = payload, |
|||
meta = "fetched_by_workmanager" |
|||
) |
|||
|
|||
// Store in database |
|||
val db = DailyNotificationDatabase.getDatabase(applicationContext) |
|||
db.contentCacheDao().upsert(contentCache) |
|||
|
|||
// Record success in history |
|||
db.historyDao().insert( |
|||
History( |
|||
refId = contentCache.id, |
|||
kind = "fetch", |
|||
occurredAt = System.currentTimeMillis(), |
|||
durationMs = SystemClock.elapsedRealtime() - start, |
|||
outcome = "success" |
|||
) |
|||
) |
|||
|
|||
Log.i(TAG, "Content fetch completed successfully") |
|||
Result.success() |
|||
|
|||
} catch (e: IOException) { |
|||
Log.w(TAG, "Network error during fetch", e) |
|||
recordFailure("network_error", start, e) |
|||
Result.retry() |
|||
|
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "Unexpected error during fetch", e) |
|||
recordFailure("unexpected_error", start, e) |
|||
Result.failure() |
|||
} |
|||
} |
|||
|
|||
private suspend fun fetchContent( |
|||
url: String?, |
|||
timeout: Int, |
|||
retryAttempts: Int, |
|||
retryDelay: Int |
|||
): ByteArray { |
|||
if (url.isNullOrBlank()) { |
|||
// Generate mock content for testing |
|||
return generateMockContent() |
|||
} |
|||
|
|||
var lastException: Exception? = null |
|||
|
|||
repeat(retryAttempts) { attempt -> |
|||
try { |
|||
val connection = URL(url).openConnection() as HttpURLConnection |
|||
connection.connectTimeout = timeout |
|||
connection.readTimeout = timeout |
|||
connection.requestMethod = "GET" |
|||
|
|||
val responseCode = connection.responseCode |
|||
if (responseCode == HttpURLConnection.HTTP_OK) { |
|||
return connection.inputStream.readBytes() |
|||
} else { |
|||
throw IOException("HTTP $responseCode: ${connection.responseMessage}") |
|||
} |
|||
|
|||
} catch (e: Exception) { |
|||
lastException = e |
|||
if (attempt < retryAttempts - 1) { |
|||
Log.w(TAG, "Fetch attempt ${attempt + 1} failed, retrying in ${retryDelay}ms", e) |
|||
kotlinx.coroutines.delay(retryDelay.toLong()) |
|||
} |
|||
} |
|||
} |
|||
|
|||
throw lastException ?: IOException("All retry attempts failed") |
|||
} |
|||
|
|||
private fun generateMockContent(): ByteArray { |
|||
val mockData = """ |
|||
{ |
|||
"timestamp": ${System.currentTimeMillis()}, |
|||
"content": "Daily notification content", |
|||
"source": "mock_generator", |
|||
"version": "1.1.0" |
|||
} |
|||
""".trimIndent() |
|||
return mockData.toByteArray() |
|||
} |
|||
|
|||
private suspend fun recordFailure(outcome: String, start: Long, error: Throwable) { |
|||
try { |
|||
val db = DailyNotificationDatabase.getDatabase(applicationContext) |
|||
db.historyDao().insert( |
|||
History( |
|||
refId = "fetch_${System.currentTimeMillis()}", |
|||
kind = "fetch", |
|||
occurredAt = System.currentTimeMillis(), |
|||
durationMs = SystemClock.elapsedRealtime() - start, |
|||
outcome = outcome, |
|||
diagJson = "{\"error\": \"${error.message}\"}" |
|||
) |
|||
) |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "Failed to record failure", e) |
|||
} |
|||
} |
|||
|
|||
private fun generateId(): String { |
|||
return "fetch_${System.currentTimeMillis()}_${(1000..9999).random()}" |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Database singleton for Room |
|||
*/ |
|||
object DailyNotificationDatabase { |
|||
@Volatile |
|||
private var INSTANCE: DailyNotificationDatabase? = null |
|||
|
|||
fun getDatabase(context: Context): DailyNotificationDatabase { |
|||
return INSTANCE ?: synchronized(this) { |
|||
val instance = Room.databaseBuilder( |
|||
context.applicationContext, |
|||
DailyNotificationDatabase::class.java, |
|||
"daily_notification_database" |
|||
).build() |
|||
INSTANCE = instance |
|||
instance |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,336 @@ |
|||
package com.timesafari.dailynotification |
|||
|
|||
import android.app.AlarmManager |
|||
import android.app.NotificationChannel |
|||
import android.app.NotificationManager |
|||
import android.app.PendingIntent |
|||
import android.content.BroadcastReceiver |
|||
import android.content.Context |
|||
import android.content.Intent |
|||
import android.os.Build |
|||
import android.util.Log |
|||
import androidx.core.app.NotificationCompat |
|||
import kotlinx.coroutines.CoroutineScope |
|||
import kotlinx.coroutines.Dispatchers |
|||
import kotlinx.coroutines.launch |
|||
|
|||
/** |
|||
* AlarmManager implementation for user notifications |
|||
* Implements TTL-at-fire logic and notification delivery |
|||
* |
|||
* @author Matthew Raymer |
|||
* @version 1.1.0 |
|||
*/ |
|||
class NotifyReceiver : BroadcastReceiver() { |
|||
|
|||
companion object { |
|||
private const val TAG = "DNP-NOTIFY" |
|||
private const val CHANNEL_ID = "daily_notifications" |
|||
private const val NOTIFICATION_ID = 1001 |
|||
private const val REQUEST_CODE = 2001 |
|||
|
|||
fun scheduleExactNotification( |
|||
context: Context, |
|||
triggerAtMillis: Long, |
|||
config: UserNotificationConfig |
|||
) { |
|||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager |
|||
val intent = Intent(context, NotifyReceiver::class.java).apply { |
|||
putExtra("title", config.title) |
|||
putExtra("body", config.body) |
|||
putExtra("sound", config.sound ?: true) |
|||
putExtra("vibration", config.vibration ?: true) |
|||
putExtra("priority", config.priority ?: "normal") |
|||
} |
|||
|
|||
val pendingIntent = PendingIntent.getBroadcast( |
|||
context, |
|||
REQUEST_CODE, |
|||
intent, |
|||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE |
|||
) |
|||
|
|||
try { |
|||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { |
|||
alarmManager.setExactAndAllowWhileIdle( |
|||
AlarmManager.RTC_WAKEUP, |
|||
triggerAtMillis, |
|||
pendingIntent |
|||
) |
|||
} else { |
|||
alarmManager.setExact( |
|||
AlarmManager.RTC_WAKEUP, |
|||
triggerAtMillis, |
|||
pendingIntent |
|||
) |
|||
} |
|||
Log.i(TAG, "Exact notification scheduled for: $triggerAtMillis") |
|||
} catch (e: SecurityException) { |
|||
Log.w(TAG, "Cannot schedule exact alarm, falling back to inexact", e) |
|||
alarmManager.set( |
|||
AlarmManager.RTC_WAKEUP, |
|||
triggerAtMillis, |
|||
pendingIntent |
|||
) |
|||
} |
|||
} |
|||
|
|||
fun cancelNotification(context: Context) { |
|||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager |
|||
val intent = Intent(context, NotifyReceiver::class.java) |
|||
val pendingIntent = PendingIntent.getBroadcast( |
|||
context, |
|||
REQUEST_CODE, |
|||
intent, |
|||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE |
|||
) |
|||
alarmManager.cancel(pendingIntent) |
|||
Log.i(TAG, "Notification alarm cancelled") |
|||
} |
|||
} |
|||
|
|||
override fun onReceive(context: Context, intent: Intent?) { |
|||
Log.i(TAG, "Notification receiver triggered") |
|||
|
|||
CoroutineScope(Dispatchers.IO).launch { |
|||
try { |
|||
// Check if this is a static reminder (no content dependency) |
|||
val isStaticReminder = intent?.getBooleanExtra("is_static_reminder", false) ?: false |
|||
|
|||
if (isStaticReminder) { |
|||
// Handle static reminder without content cache |
|||
val title = intent?.getStringExtra("title") ?: "Daily Reminder" |
|||
val body = intent?.getStringExtra("body") ?: "Don't forget your daily check-in!" |
|||
val sound = intent?.getBooleanExtra("sound", true) ?: true |
|||
val vibration = intent?.getBooleanExtra("vibration", true) ?: true |
|||
val priority = intent?.getStringExtra("priority") ?: "normal" |
|||
val reminderId = intent?.getStringExtra("reminder_id") ?: "unknown" |
|||
|
|||
showStaticReminderNotification(context, title, body, sound, vibration, priority, reminderId) |
|||
|
|||
// Record reminder trigger in database |
|||
recordReminderTrigger(context, reminderId) |
|||
return@launch |
|||
} |
|||
|
|||
// Existing cached content logic for regular notifications |
|||
val db = DailyNotificationDatabase.getDatabase(context) |
|||
val latestCache = db.contentCacheDao().getLatest() |
|||
|
|||
if (latestCache == null) { |
|||
Log.w(TAG, "No cached content available for notification") |
|||
recordHistory(db, "notify", "no_content") |
|||
return@launch |
|||
} |
|||
|
|||
// TTL-at-fire check |
|||
val now = System.currentTimeMillis() |
|||
val ttlExpiry = latestCache.fetchedAt + (latestCache.ttlSeconds * 1000L) |
|||
|
|||
if (now > ttlExpiry) { |
|||
Log.i(TAG, "Content TTL expired, skipping notification") |
|||
recordHistory(db, "notify", "skipped_ttl") |
|||
return@launch |
|||
} |
|||
|
|||
// Show notification |
|||
val title = intent?.getStringExtra("title") ?: "Daily Notification" |
|||
val body = intent?.getStringExtra("body") ?: String(latestCache.payload) |
|||
val sound = intent?.getBooleanExtra("sound", true) ?: true |
|||
val vibration = intent?.getBooleanExtra("vibration", true) ?: true |
|||
val priority = intent?.getStringExtra("priority") ?: "normal" |
|||
|
|||
showNotification(context, title, body, sound, vibration, priority) |
|||
recordHistory(db, "notify", "success") |
|||
|
|||
// Fire callbacks |
|||
fireCallbacks(context, db, "onNotifyDelivered", latestCache) |
|||
|
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "Error in notification receiver", e) |
|||
try { |
|||
val db = DailyNotificationDatabase.getDatabase(context) |
|||
recordHistory(db, "notify", "failure", e.message) |
|||
} catch (dbError: Exception) { |
|||
Log.e(TAG, "Failed to record notification failure", dbError) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
private fun showNotification( |
|||
context: Context, |
|||
title: String, |
|||
body: String, |
|||
sound: Boolean, |
|||
vibration: Boolean, |
|||
priority: String |
|||
) { |
|||
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager |
|||
|
|||
// Create notification channel for Android 8.0+ |
|||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { |
|||
val channel = NotificationChannel( |
|||
CHANNEL_ID, |
|||
"Daily Notifications", |
|||
when (priority) { |
|||
"high" -> NotificationManager.IMPORTANCE_HIGH |
|||
"low" -> NotificationManager.IMPORTANCE_LOW |
|||
else -> NotificationManager.IMPORTANCE_DEFAULT |
|||
} |
|||
).apply { |
|||
enableVibration(vibration) |
|||
if (!sound) { |
|||
setSound(null, null) |
|||
} |
|||
} |
|||
notificationManager.createNotificationChannel(channel) |
|||
} |
|||
|
|||
val notification = NotificationCompat.Builder(context, CHANNEL_ID) |
|||
.setContentTitle(title) |
|||
.setContentText(body) |
|||
.setSmallIcon(android.R.drawable.ic_dialog_info) |
|||
.setPriority( |
|||
when (priority) { |
|||
"high" -> NotificationCompat.PRIORITY_HIGH |
|||
"low" -> NotificationCompat.PRIORITY_LOW |
|||
else -> NotificationCompat.PRIORITY_DEFAULT |
|||
} |
|||
) |
|||
.setAutoCancel(true) |
|||
.setVibrate(if (vibration) longArrayOf(0, 250, 250, 250) else null) |
|||
.build() |
|||
|
|||
notificationManager.notify(NOTIFICATION_ID, notification) |
|||
Log.i(TAG, "Notification displayed: $title") |
|||
} |
|||
|
|||
private suspend fun recordHistory( |
|||
db: DailyNotificationDatabase, |
|||
kind: String, |
|||
outcome: String, |
|||
diagJson: String? = null |
|||
) { |
|||
try { |
|||
db.historyDao().insert( |
|||
History( |
|||
refId = "notify_${System.currentTimeMillis()}", |
|||
kind = kind, |
|||
occurredAt = System.currentTimeMillis(), |
|||
outcome = outcome, |
|||
diagJson = diagJson |
|||
) |
|||
) |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "Failed to record history", e) |
|||
} |
|||
} |
|||
|
|||
private suspend fun fireCallbacks( |
|||
context: Context, |
|||
db: DailyNotificationDatabase, |
|||
eventType: String, |
|||
contentCache: ContentCache |
|||
) { |
|||
try { |
|||
val callbacks = db.callbackDao().getEnabled() |
|||
callbacks.forEach { callback -> |
|||
try { |
|||
when (callback.kind) { |
|||
"http" -> fireHttpCallback(callback, eventType, contentCache) |
|||
"local" -> fireLocalCallback(context, callback, eventType, contentCache) |
|||
else -> Log.w(TAG, "Unknown callback kind: ${callback.kind}") |
|||
} |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "Failed to fire callback ${callback.id}", e) |
|||
recordHistory(db, "callback", "failure", "{\"callback_id\": \"${callback.id}\", \"error\": \"${e.message}\"}") |
|||
} |
|||
} |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "Failed to fire callbacks", e) |
|||
} |
|||
} |
|||
|
|||
private suspend fun fireHttpCallback( |
|||
callback: Callback, |
|||
eventType: String, |
|||
contentCache: ContentCache |
|||
) { |
|||
// HTTP callback implementation would go here |
|||
Log.i(TAG, "HTTP callback fired: ${callback.id} for event: $eventType") |
|||
} |
|||
|
|||
private suspend fun fireLocalCallback( |
|||
context: Context, |
|||
callback: Callback, |
|||
eventType: String, |
|||
contentCache: ContentCache |
|||
) { |
|||
// Local callback implementation would go here |
|||
Log.i(TAG, "Local callback fired: ${callback.id} for event: $eventType") |
|||
} |
|||
|
|||
// Static Reminder Helper Methods |
|||
private fun showStaticReminderNotification( |
|||
context: Context, |
|||
title: String, |
|||
body: String, |
|||
sound: Boolean, |
|||
vibration: Boolean, |
|||
priority: String, |
|||
reminderId: String |
|||
) { |
|||
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager |
|||
|
|||
// Create notification channel for reminders |
|||
createReminderNotificationChannel(context, notificationManager) |
|||
|
|||
val notification = NotificationCompat.Builder(context, "daily_reminders") |
|||
.setSmallIcon(android.R.drawable.ic_dialog_info) |
|||
.setContentTitle(title) |
|||
.setContentText(body) |
|||
.setPriority( |
|||
when (priority) { |
|||
"high" -> NotificationCompat.PRIORITY_HIGH |
|||
"low" -> NotificationCompat.PRIORITY_LOW |
|||
else -> NotificationCompat.PRIORITY_DEFAULT |
|||
} |
|||
) |
|||
.setSound(if (sound) null else null) // Use default sound if enabled |
|||
.setAutoCancel(true) |
|||
.setVibrate(if (vibration) longArrayOf(0, 250, 250, 250) else null) |
|||
.setCategory(NotificationCompat.CATEGORY_REMINDER) |
|||
.build() |
|||
|
|||
notificationManager.notify(reminderId.hashCode(), notification) |
|||
Log.i(TAG, "Static reminder displayed: $title (ID: $reminderId)") |
|||
} |
|||
|
|||
private fun createReminderNotificationChannel(context: Context, notificationManager: NotificationManager) { |
|||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { |
|||
val channel = NotificationChannel( |
|||
"daily_reminders", |
|||
"Daily Reminders", |
|||
NotificationManager.IMPORTANCE_DEFAULT |
|||
).apply { |
|||
description = "Daily reminder notifications" |
|||
enableVibration(true) |
|||
setShowBadge(true) |
|||
} |
|||
notificationManager.createNotificationChannel(channel) |
|||
} |
|||
} |
|||
|
|||
private fun recordReminderTrigger(context: Context, reminderId: String) { |
|||
try { |
|||
val prefs = context.getSharedPreferences("daily_reminders", Context.MODE_PRIVATE) |
|||
val editor = prefs.edit() |
|||
editor.putLong("${reminderId}_lastTriggered", System.currentTimeMillis()) |
|||
editor.apply() |
|||
Log.d(TAG, "Reminder trigger recorded: $reminderId") |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "Error recording reminder trigger", e) |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,42 @@ |
|||
apply plugin: 'com.android.library' |
|||
|
|||
android { |
|||
namespace "com.timesafari.dailynotification" |
|||
compileSdkVersion rootProject.ext.compileSdkVersion |
|||
|
|||
defaultConfig { |
|||
minSdkVersion rootProject.ext.minSdkVersion |
|||
targetSdkVersion rootProject.ext.targetSdkVersion |
|||
versionCode 1 |
|||
versionName "1.0" |
|||
|
|||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" |
|||
consumerProguardFiles "consumer-rules.pro" |
|||
} |
|||
|
|||
buildTypes { |
|||
release { |
|||
minifyEnabled false |
|||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' |
|||
} |
|||
} |
|||
|
|||
compileOptions { |
|||
sourceCompatibility JavaVersion.VERSION_1_8 |
|||
targetCompatibility JavaVersion.VERSION_1_8 |
|||
} |
|||
} |
|||
|
|||
dependencies { |
|||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" |
|||
implementation "androidx.room:room-runtime:2.6.1" |
|||
implementation "androidx.work:work-runtime-ktx:2.9.0" |
|||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3" |
|||
implementation "org.jetbrains.kotlin:kotlin-stdlib:1.9.10" |
|||
|
|||
annotationProcessor "androidx.room:room-compiler:2.6.1" |
|||
|
|||
testImplementation "junit:junit:$junitVersion" |
|||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" |
|||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" |
|||
} |
@ -1,4 +1,5 @@ |
|||
include ':app' |
|||
include ':plugin' |
|||
include ':capacitor-cordova-android-plugins' |
|||
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/') |
|||
|
|||
|
Loading…
Reference in new issue