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 ':app' |
||||
|
include ':plugin' |
||||
include ':capacitor-cordova-android-plugins' |
include ':capacitor-cordova-android-plugins' |
||||
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/') |
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/') |
||||
|
|
||||
|
Loading…
Reference in new issue