feat(android): dual prefetch delay, native fetcher, scoped content cache

- Schedule dual content fetch with WorkManager initialDelay to the next
  contentFetch cron; reschedule from prefs after success and on boot when
  dual_fetch_* exists (DualScheduleFetchRecovery + ReactivationManager).
- When contentFetch has no URL, call NativeNotificationContentFetcher with
  FetchContext (prefetch + next notify time); else keep HTTP/mock behavior.
- Add content_cache.cacheScope (dual|daily|legacy), Room v4 migration,
  getLatestByScope; DualScheduleHelper reads dual only; daily fetch paths
  write daily; NotifyReceiver prefers daily/legacy for legacy cache reads.
- Extract ScheduleCronUtils.calculateNextRunTimeMillis for shared cron math.
- Document in README/CHANGELOG; bump package to 2.1.5.
This commit is contained in:
Jose Olarte III
2026-03-25 18:05:57 +08:00
parent a5c5a7e74e
commit 469167a55f
13 changed files with 375 additions and 105 deletions

View File

@@ -5,6 +5,18 @@ All notable changes to the Daily Notification Plugin will be documented in this
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.1.5] - 2026-03-25
### Changed
- **Android**: Dual (`scheduleDualNotification`) content prefetch uses **WorkManager** with **`initialDelay`** to the next `contentFetch.schedule` occurrence (not an immediate fetch at setup). After each successful dual fetch, the next prefetch is re-enqueued from persisted dual config.
- **Android**: Dual prefetch with no `contentFetch.url` invokes the registered **`NativeNotificationContentFetcher`** when present (same SPI as `DailyNotificationFetchWorker`); otherwise mock JSON is used for development.
- **Android**: `content_cache` rows include **`cacheScope`** (`dual` | `daily` | `legacy`). Dual notify resolution reads only **`dual`**; daily reminder fetches write **`daily`**, avoiding cross-feature overwrites. Database version **4** with migration from v3.
### Documentation
- **Android**: `doc/platform/android/ANDROID_DUAL_SCHEDULE_NATIVE_FETCH_AND_CACHE_SCOPE.md` (implementation plan; see repo for details).
## [2.1.4] - 2026-03-20
### Fixed

View File

@@ -324,6 +324,8 @@ If `contentFetch` omits `timeout`, `retryAttempts`, or `retryDelay`, Android app
If `userNotification` omits optional fields (`title`, `body`, `sound`, `vibration`, `priority`), Android parses them as omitted; scheduling uses the same defaults as `NotifyReceiver` / `DualScheduleHelper` (e.g. sound and vibration default to on, priority to `normal` where applicable).
**Android (dual prefetch timing & cache):** Prefetch work is scheduled with a delay to the next `contentFetch.schedule` instant (best-effort under Doze/OEM). Fetched content is stored in a **scoped** cache row (`dual`) so it is not overwritten by the daily reminder fetch (`daily`). With no `contentFetch.url`, the host apps **`NativeNotificationContentFetcher`** is used when registered.
### Callback Methods
#### `registerCallback(name, config)`

View File

@@ -0,0 +1,11 @@
package org.timesafari.dailynotification
/**
* [org.timesafari.dailynotification.ContentCache] row discriminator so dual-schedule
* prefetch does not overwrite daily-reminder cache (and vice versa).
*/
object ContentCacheScope {
const val DUAL = "dual"
const val DAILY = "daily"
const val LEGACY = "legacy"
}

View File

@@ -1395,13 +1395,11 @@ open class DailyNotificationPlugin : Plugin() {
CoroutineScope(Dispatchers.IO).launch {
try {
// Use FetchWorker.scheduleFetchForDual so cancelDualSchedule can cancel only dual fetch
val success = ScheduleHelper.scheduleDualNotification(
context,
getDatabase(),
contentFetchConfig,
userNotificationConfig,
FetchWorker::scheduleFetchForDual,
::calculateNextRunTime
)
@@ -1448,7 +1446,7 @@ open class DailyNotificationPlugin : Plugin() {
CoroutineScope(Dispatchers.IO).launch {
try {
val enabledSchedules = getDatabase().scheduleDao().getEnabled()
val latestCache = getDatabase().contentCacheDao().getLatest()
val latestCache = getDatabase().contentCacheDao().getLatestByScope(ContentCacheScope.DUAL)
val recentHistory = getDatabase().historyDao().getSince(System.currentTimeMillis() - (24 * 60 * 60 * 1000L))
val status = JSObject().apply {
@@ -1521,7 +1519,6 @@ open class DailyNotificationPlugin : Plugin() {
db,
contentFetchConfig,
userNotificationConfig,
FetchWorker::scheduleFetchForDual,
::calculateNextRunTime
)
if (success) {
@@ -1897,12 +1894,15 @@ open class DailyNotificationPlugin : Plugin() {
val ttlSeconds = contentJson.getInt("ttlSeconds")
?: return@launch call.reject("TTL seconds is required")
val scope = contentJson.getString("cacheScope")?.takeIf { it.isNotEmpty() }
?: ContentCacheScope.LEGACY
val cache = ContentCache(
id = id,
fetchedAt = System.currentTimeMillis(),
ttlSeconds = ttlSeconds,
payload = payload.toByteArray(),
meta = contentJson.getString("meta")
meta = contentJson.getString("meta"),
cacheScope = scope
)
getDatabase().contentCacheDao().upsert(cache)
@@ -2349,6 +2349,7 @@ open class DailyNotificationPlugin : Plugin() {
put("ttlSeconds", cache.ttlSeconds)
put("payload", String(cache.payload))
put("meta", cache.meta)
put("cacheScope", cache.cacheScope)
}
}
@@ -2439,48 +2440,7 @@ open class DailyNotificationPlugin : Plugin() {
}
private fun calculateNextRunTime(schedule: String): Long {
// Parse cron expression: "minute hour * * *" (daily schedule)
// Example: "9 7 * * *" = 07:09 daily
try {
val parts = schedule.trim().split("\\s+".toRegex())
if (parts.size < 2) {
Log.w(TAG, "Invalid cron format: $schedule, defaulting to 24h from now")
return System.currentTimeMillis() + (24 * 60 * 60 * 1000L)
}
val minute = parts[0].toIntOrNull() ?: 0
val hour = parts[1].toIntOrNull() ?: 9
if (minute < 0 || minute > 59 || hour < 0 || hour > 23) {
Log.w(TAG, "Invalid time values in cron: $schedule, defaulting to 24h from now")
return System.currentTimeMillis() + (24 * 60 * 60 * 1000L)
}
// Calculate next occurrence of this time
val calendar = java.util.Calendar.getInstance()
val now = calendar.timeInMillis
// Set to today at the specified time
calendar.set(java.util.Calendar.HOUR_OF_DAY, hour)
calendar.set(java.util.Calendar.MINUTE, minute)
calendar.set(java.util.Calendar.SECOND, 0)
calendar.set(java.util.Calendar.MILLISECOND, 0)
var nextRun = calendar.timeInMillis
// If the time has already passed today, schedule for tomorrow
if (nextRun <= now) {
calendar.add(java.util.Calendar.DAY_OF_YEAR, 1)
nextRun = calendar.timeInMillis
}
Log.d(TAG, "Calculated next run time: cron=$schedule, nextRun=${java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).format(java.util.Date(nextRun))}")
return nextRun
} catch (e: Exception) {
Log.e(TAG, "Error calculating next run time for schedule: $schedule", e)
// Fallback: 24 hours from now
return System.currentTimeMillis() + (24 * 60 * 60 * 1000L)
}
return ScheduleCronUtils.calculateNextRunTimeMillis(schedule)
}
/**
@@ -2876,7 +2836,6 @@ object ScheduleHelper {
* @param database Database instance
* @param contentFetchConfig Content fetch configuration
* @param userNotificationConfig User notification configuration
* @param scheduleFetch Function to schedule fetch (FetchWorker.scheduleFetch)
* @param calculateNextRunTime Function to calculate next run time from cron expression
* @return true if successful, false otherwise
*/
@@ -2885,15 +2844,20 @@ object ScheduleHelper {
database: DailyNotificationDatabase,
contentFetchConfig: ContentFetchConfig,
userNotificationConfig: UserNotificationConfig,
scheduleFetch: (Context, ContentFetchConfig) -> Unit,
calculateNextRunTime: (String) -> Long
): Boolean {
return try {
// Schedule fetch
scheduleFetch(context, contentFetchConfig)
val nextFetchAt = calculateNextRunTime(contentFetchConfig.schedule)
val nextNotifyAt = calculateNextRunTime(userNotificationConfig.schedule)
FetchWorker.enqueueDualFetch(
context,
contentFetchConfig,
nextFetchAt,
nextNotifyAt
)
// Schedule notification (use dual_notify_* so receiver can recognize dual and apply relationship)
val nextRunTime = calculateNextRunTime(userNotificationConfig.schedule)
val nextRunTime = nextNotifyAt
val scheduleId = "dual_notify_${System.currentTimeMillis()}"
NotifyReceiver.scheduleExactNotification(
context,
@@ -2909,7 +2873,7 @@ object ScheduleHelper {
kind = "fetch",
cron = contentFetchConfig.schedule,
enabled = contentFetchConfig.enabled,
nextRunAt = calculateNextRunTime(contentFetchConfig.schedule)
nextRunAt = nextFetchAt
)
val notifySchedule = Schedule(
id = scheduleId,

View File

@@ -33,7 +33,9 @@ data class ContentCache(
val fetchedAt: Long, // epoch ms
val ttlSeconds: Int,
val payload: ByteArray, // BLOB
val meta: String? = null
val meta: String? = null,
/** dual | daily | legacy — see [ContentCacheScope] */
val cacheScope: String = ContentCacheScope.LEGACY
)
@Entity(tableName = "schedules")
@@ -85,7 +87,7 @@ data class History(
NotificationDeliveryEntity::class,
NotificationConfigEntity::class
],
version = 3, // 3: add rollover_interval_minutes to schedules
version = 4, // 4: content_cache.cacheScope
exportSchema = false
)
@TypeConverters(Converters::class)
@@ -120,7 +122,7 @@ abstract class DailyNotificationDatabase : RoomDatabase() {
DailyNotificationDatabase::class.java,
DATABASE_NAME
)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3) // 1->2: unified; 2->3: rollover_interval_minutes
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
.addCallback(roomCallback)
.build()
INSTANCE = instance
@@ -277,6 +279,15 @@ abstract class DailyNotificationDatabase : RoomDatabase() {
database.execSQL("ALTER TABLE schedules ADD COLUMN rollover_interval_minutes INTEGER")
}
}
/** Add cacheScope to content_cache for dual vs daily isolation */
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"ALTER TABLE content_cache ADD COLUMN cacheScope TEXT NOT NULL DEFAULT 'legacy'"
)
}
}
}
}
@@ -287,6 +298,9 @@ interface ContentCacheDao {
@Query("SELECT * FROM content_cache ORDER BY fetchedAt DESC LIMIT 1")
suspend fun getLatest(): ContentCache?
@Query("SELECT * FROM content_cache WHERE cacheScope = :scope ORDER BY fetchedAt DESC LIMIT 1")
suspend fun getLatestByScope(scope: String): ContentCache?
@Query("SELECT * FROM content_cache ORDER BY fetchedAt DESC LIMIT :limit")
suspend fun getHistory(limit: Int): List<ContentCache>

View File

@@ -0,0 +1,82 @@
package org.timesafari.dailynotification
import android.content.Context
import android.util.Log
import org.json.JSONObject
/**
* Re-enqueues dual (New Activity) prefetch from persisted SharedPreferences config
* (boot recovery, after a successful dual fetch rollover).
*/
object DualScheduleFetchRecovery {
private const val TAG = "DNP-DUAL-RECOVER"
/**
* Parses [DailyNotificationConstants.DUAL_SCHEDULE_CONFIG_KEY] and enqueues the next delayed dual fetch.
* @return true if a job was scheduled
*/
@JvmStatic
fun enqueueFromPersistedConfig(context: Context): Boolean {
return try {
val prefs = context.getSharedPreferences(
DailyNotificationConstants.DUAL_SCHEDULE_PREFS,
Context.MODE_PRIVATE
)
val jsonStr = prefs.getString(DailyNotificationConstants.DUAL_SCHEDULE_CONFIG_KEY, null)
?: return false
val root = JSONObject(jsonStr)
val contentFetchObj = root.optJSONObject("contentFetch") ?: return false
val userNotificationObj = root.optJSONObject("userNotification") ?: return false
val contentFetchConfig = parseContentFetchConfigJson(contentFetchObj)
val userNotificationConfig = parseUserNotificationConfigJson(userNotificationObj)
if (!contentFetchConfig.enabled) {
Log.d(TAG, "contentFetch disabled, skip dual fetch recovery")
return false
}
val nextFetchAt = ScheduleCronUtils.calculateNextRunTimeMillis(contentFetchConfig.schedule)
val nextNotifyAt = ScheduleCronUtils.calculateNextRunTimeMillis(userNotificationConfig.schedule)
FetchWorker.enqueueDualFetch(
context,
contentFetchConfig,
nextFetchAt,
nextNotifyAt
)
true
} catch (e: Exception) {
Log.w(TAG, "enqueueFromPersistedConfig failed", e)
false
}
}
private fun parseContentFetchConfigJson(configJson: JSONObject): ContentFetchConfig {
val callbacksObj = configJson.optJSONObject("callbacks")
return ContentFetchConfig(
enabled = configJson.optBoolean("enabled", true),
schedule = configJson.optString("schedule", "0 9 * * *"),
url = configJson.optString("url").takeIf { it.isNotEmpty() },
timeout = configJson.takeUnless { !it.has("timeout") || JSONObject.NULL == it.get("timeout") }
?.optInt("timeout"),
retryAttempts = configJson.takeUnless { !it.has("retryAttempts") || JSONObject.NULL == it.get("retryAttempts") }
?.optInt("retryAttempts"),
retryDelay = configJson.takeUnless { !it.has("retryDelay") || JSONObject.NULL == it.get("retryDelay") }
?.optInt("retryDelay"),
callbacks = CallbackConfig(
apiService = callbacksObj?.optString("apiService")?.takeIf { it.isNotEmpty() },
database = callbacksObj?.optString("database")?.takeIf { it.isNotEmpty() },
reporting = callbacksObj?.optString("reporting")?.takeIf { it.isNotEmpty() }
)
)
}
private fun parseUserNotificationConfigJson(configJson: JSONObject): UserNotificationConfig {
return UserNotificationConfig(
enabled = configJson.optBoolean("enabled", true),
schedule = configJson.optString("schedule", "0 9 * * *"),
title = configJson.optString("title").takeIf { it.isNotEmpty() },
body = configJson.optString("body").takeIf { it.isNotEmpty() },
sound = if (configJson.has("sound") && JSONObject.NULL != configJson.get("sound")) configJson.optBoolean("sound") else null,
vibration = if (configJson.has("vibration") && JSONObject.NULL != configJson.get("vibration")) configJson.optBoolean("vibration") else null,
priority = configJson.optString("priority").takeIf { it.isNotEmpty() }
)
}
}

View File

@@ -35,7 +35,9 @@ object DualScheduleHelper {
val defaultBody = userNotification.optString("body", "Your daily update is ready")
val db = DailyNotificationDatabase.getDatabase(context)
val latestCache = runBlocking { db.contentCacheDao().getLatest() }
val latestCache = runBlocking {
db.contentCacheDao().getLatestByScope(ContentCacheScope.DUAL)
}
val nowMs = System.currentTimeMillis()
val (title: String, body: String) = if (latestCache != null && (nowMs - latestCache.fetchedAt) <= contentTimeoutMs) {

View File

@@ -4,18 +4,18 @@ import android.content.Context
import android.os.SystemClock
import android.util.Log
import androidx.work.*
import java.util.concurrent.TimeUnit
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
import org.json.JSONObject
/**
* WorkManager implementation for content fetching
* Implements exponential backoff and network constraints
*
*
* @author Matthew Raymer
* @version 2.0.0
*/
@@ -30,15 +30,59 @@ class FetchWorker(
/** Unique work name for dual (New Activity) schedule fetch; cancel via cancelDualSchedule only. */
const val WORK_NAME_DUAL = "fetch_dual"
private const val KEY_IS_DUAL = "is_dual"
private const val KEY_CACHE_SCOPE = "cache_scope"
private const val KEY_NEXT_NOTIFY_AT = "next_notify_at"
fun scheduleFetch(context: Context, config: ContentFetchConfig) {
enqueueFetch(context, config, WORK_NAME)
}
/**
* Schedule fetch for dual (New Activity) flow. Uses distinct work name so cancelDualSchedule can cancel only this.
* Dual (New Activity) prefetch: delayed to [nextFetchAtMillis], scoped cache, optional native fetcher.
*/
fun scheduleFetchForDual(context: Context, config: ContentFetchConfig) {
enqueueFetch(context, config, WORK_NAME_DUAL)
fun enqueueDualFetch(
context: Context,
contentFetchConfig: ContentFetchConfig,
nextFetchAtMillis: Long,
nextNotifyAtMillis: Long
) {
val now = System.currentTimeMillis()
val delayMs = (nextFetchAtMillis - now).coerceAtLeast(0L)
val requiresNetwork = !contentFetchConfig.url.isNullOrBlank() ||
DailyNotificationPlugin.getNativeFetcherStatic() != null
val constraints = Constraints.Builder()
.setRequiredNetworkType(
if (requiresNetwork) NetworkType.CONNECTED else NetworkType.NOT_REQUIRED
)
.build()
val workRequest = OneTimeWorkRequestBuilder<FetchWorker>()
.setConstraints(constraints)
.setInitialDelay(delayMs, TimeUnit.MILLISECONDS)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
30,
TimeUnit.SECONDS
)
.setInputData(
Data.Builder()
.putString("url", contentFetchConfig.url)
.putInt("timeout", contentFetchConfig.timeout ?: 30000)
.putInt("retryAttempts", contentFetchConfig.retryAttempts ?: 3)
.putInt("retryDelay", contentFetchConfig.retryDelay ?: 1000)
.putLong("notificationTime", 0L)
.putBoolean(KEY_IS_DUAL, true)
.putString(KEY_CACHE_SCOPE, ContentCacheScope.DUAL)
.putLong(KEY_NEXT_NOTIFY_AT, nextNotifyAtMillis)
.build()
)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(WORK_NAME_DUAL, ExistingWorkPolicy.REPLACE, workRequest)
Log.i(
TAG,
"Dual fetch enqueued: delayMs=$delayMs, nextFetchAt=$nextFetchAtMillis, nextNotifyAt=$nextNotifyAtMillis"
)
}
private fun enqueueFetch(context: Context, config: ContentFetchConfig, workName: String) {
@@ -58,16 +102,18 @@ class FetchWorker(
.putInt("timeout", config.timeout ?: 30000)
.putInt("retryAttempts", config.retryAttempts ?: 3)
.putInt("retryDelay", config.retryDelay ?: 1000)
.putBoolean(KEY_IS_DUAL, false)
.putString(KEY_CACHE_SCOPE, ContentCacheScope.DAILY)
.build()
)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(workName, ExistingWorkPolicy.REPLACE, workRequest)
}
/**
* Schedule a delayed fetch for prefetch (5 minutes before notification)
*
*
* @param context Application context
* @param fetchTime When to fetch (in milliseconds since epoch)
* @param notificationTime When the notification will be shown (in milliseconds since epoch)
@@ -81,15 +127,15 @@ class FetchWorker(
) {
val currentTime = System.currentTimeMillis()
val delayMs = fetchTime - currentTime
Log.i(TAG, "Scheduling delayed prefetch: fetchTime=$fetchTime, notificationTime=$notificationTime, delayMs=$delayMs")
if (delayMs <= 0) {
Log.w(TAG, "Fetch time is in the past, scheduling immediate fetch")
scheduleImmediateFetch(context, notificationTime, url)
return
}
// Only require network if URL is provided (mock content doesn't need network)
val constraints = Constraints.Builder()
.apply {
@@ -101,11 +147,11 @@ class FetchWorker(
}
}
.build()
// Create unique work name based on notification time to prevent duplicate fetches
val notificationTimeMinutes = notificationTime / (60 * 1000)
val workName = "prefetch_${notificationTimeMinutes}"
val workRequest = OneTimeWorkRequestBuilder<FetchWorker>()
.setConstraints(constraints)
.setInitialDelay(delayMs, TimeUnit.MILLISECONDS)
@@ -122,21 +168,23 @@ class FetchWorker(
.putInt("timeout", 30000)
.putInt("retryAttempts", 3)
.putInt("retryDelay", 1000)
.putBoolean(KEY_IS_DUAL, false)
.putString(KEY_CACHE_SCOPE, ContentCacheScope.DAILY)
.build()
)
.addTag("prefetch")
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(
workName,
ExistingWorkPolicy.REPLACE,
workRequest
)
Log.i(TAG, "Delayed prefetch scheduled: workName=$workName, delayMs=$delayMs")
}
/**
* Schedule an immediate fetch (fallback when delay is in the past)
*/
@@ -156,7 +204,7 @@ class FetchWorker(
}
}
.build()
val workRequest = OneTimeWorkRequestBuilder<FetchWorker>()
.setConstraints(constraints)
.setInputData(
@@ -167,14 +215,16 @@ class FetchWorker(
.putInt("retryAttempts", 3)
.putInt("retryDelay", 1000)
.putBoolean("immediate", true)
.putBoolean(KEY_IS_DUAL, false)
.putString(KEY_CACHE_SCOPE, ContentCacheScope.DAILY)
.build()
)
.addTag("prefetch")
.build()
WorkManager.getInstance(context)
.enqueue(workRequest)
Log.i(TAG, "Immediate prefetch scheduled")
}
}
@@ -186,30 +236,34 @@ class FetchWorker(
val retryAttempts = inputData.getInt("retryAttempts", 3)
val retryDelay = inputData.getInt("retryDelay", 1000)
val notificationTime = inputData.getLong("notificationTime", 0L)
val isDual = inputData.getBoolean(KEY_IS_DUAL, false)
val cacheScope = inputData.getString(KEY_CACHE_SCOPE) ?: ContentCacheScope.LEGACY
val nextNotifyAt = inputData.getLong(KEY_NEXT_NOTIFY_AT, 0L)
try {
Log.i(TAG, "Starting content fetch from: $url, notificationTime=$notificationTime")
val payload = fetchContent(url, timeout, retryAttempts, retryDelay)
Log.i(TAG, "Starting content fetch from: $url, notificationTime=$notificationTime, isDual=$isDual, scope=$cacheScope")
val payload = resolvePayload(url, timeout, retryAttempts, retryDelay, isDual, nextNotifyAt)
val contentCache = ContentCache(
id = generateId(),
fetchedAt = System.currentTimeMillis(),
ttlSeconds = 3600, // 1 hour default TTL
payload = payload,
meta = "fetched_by_workmanager"
meta = "fetched_by_workmanager",
cacheScope = cacheScope
)
// Store in database
val db = DailyNotificationDatabase.getDatabase(applicationContext)
db.contentCacheDao().upsert(contentCache)
// If this is a prefetch for a specific notification, create NotificationContentEntity
// so the notification worker can find it when the alarm fires
if (notificationTime > 0) {
try {
val notificationId = "notify_$notificationTime"
val (title, body) = parsePayload(payload)
val entity = org.timesafari.dailynotification.entities.NotificationContentEntity(
notificationId,
"2.1.0", // Plugin version
@@ -227,7 +281,7 @@ class FetchWorker(
entity.createdAt = System.currentTimeMillis()
entity.updatedAt = System.currentTimeMillis()
entity.ttlSeconds = contentCache.ttlSeconds.toLong()
// Save to Room database so notification worker can find it
db.notificationContentDao().insertNotification(entity)
Log.i(TAG, "Created NotificationContentEntity: id=$notificationId, scheduledTime=$notificationTime")
@@ -236,7 +290,7 @@ class FetchWorker(
// Continue - at least ContentCache was saved
}
}
// Record success in history
db.historyDao().insert(
History(
@@ -247,22 +301,83 @@ class FetchWorker(
outcome = "success"
)
)
Log.i(TAG, "Content fetch completed successfully")
if (isDual) {
DualScheduleFetchRecovery.enqueueFromPersistedConfig(applicationContext)
}
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 resolvePayload(
url: String?,
timeout: Int,
retryAttempts: Int,
retryDelay: Int,
isDual: Boolean,
nextNotifyAt: Long
): ByteArray {
if (isDual && url.isNullOrBlank()) {
val native = DailyNotificationPlugin.getNativeFetcherStatic()
return if (native != null) {
fetchNativeDualPayload(native, timeout, nextNotifyAt)
} else {
Log.w(TAG, "Dual fetch with no URL and no native fetcher; using mock content")
generateMockContent()
}
}
return fetchContent(url, timeout, retryAttempts, retryDelay)
}
private suspend fun fetchNativeDualPayload(
native: NativeNotificationContentFetcher,
timeoutMs: Int,
nextNotifyAtMillis: Long
): ByteArray = withContext(Dispatchers.IO) {
val metadata = java.util.HashMap<String, Any>()
metadata["retryCount"] = 0
metadata["immediate"] = false
val scheduledTime: Long? = if (nextNotifyAtMillis > 0L) nextNotifyAtMillis else null
val ctx = FetchContext(
"prefetch",
scheduledTime,
System.currentTimeMillis(),
metadata
)
val future = native.fetchContent(ctx)
try {
val contents = future.get(timeoutMs.toLong(), TimeUnit.MILLISECONDS)
val list = contents?.toList() ?: emptyList()
notificationContentsToDualPayloadBytes(list)
} catch (e: Exception) {
Log.e(TAG, "Native dual fetch failed", e)
throw IOException("native_fetch_failed: ${e.message}", e)
}
}
private fun notificationContentsToDualPayloadBytes(contents: List<NotificationContent>): ByteArray {
if (contents.isEmpty()) {
return """{"title":"No updates","body":"No new content"}""".toByteArray(Charsets.UTF_8)
}
val c = contents[0]
val title = c.getTitle() ?: "Daily Notification"
val body = c.getBody() ?: ""
val json = JSONObject()
json.put("title", title)
json.put("body", body)
json.put("content", body)
return json.toString().toByteArray(Charsets.UTF_8)
}
private suspend fun fetchContent(
url: String?,
timeout: Int,
@@ -273,23 +388,22 @@ class FetchWorker(
// 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) {
@@ -298,10 +412,10 @@ class FetchWorker(
}
}
}
throw lastException ?: IOException("All retry attempts failed")
}
private fun generateMockContent(): ByteArray {
val mockData = """
{
@@ -313,7 +427,7 @@ class FetchWorker(
""".trimIndent()
return mockData.toByteArray()
}
private suspend fun recordFailure(outcome: String, start: Long, error: Throwable) {
try {
val db = DailyNotificationDatabase.getDatabase(applicationContext)
@@ -331,22 +445,22 @@ class FetchWorker(
Log.e(TAG, "Failed to record failure", e)
}
}
private fun generateId(): String {
return "fetch_${System.currentTimeMillis()}_${(1000..9999).random()}"
}
/**
* Parse payload to extract title and body
* Handles both JSON and plain text payloads
*
*
* @param payload Raw payload bytes
* @return Pair of (title, body)
*/
private fun parsePayload(payload: ByteArray): Pair<String, String> {
return try {
val payloadString = String(payload, Charsets.UTF_8)
// Try to parse as JSON
val json = JSONObject(payloadString)
val title = json.optString("title", "Daily Notification")

View File

@@ -250,7 +250,9 @@ class NotifyReceiver : BroadcastReceiver() {
try {
runBlocking {
val db = DailyNotificationDatabase.getDatabase(context)
val contentCache = db.contentCacheDao().getLatest()
val contentCache = db.contentCacheDao().getLatestByScope(ContentCacheScope.DAILY)
?: db.contentCacheDao().getLatestByScope(ContentCacheScope.LEGACY)
?: db.contentCacheDao().getLatest()
// Always create a notification content entity for recovery tracking
// Phase 1: Recovery needs NotificationContentEntity to detect missed notifications
@@ -670,7 +672,9 @@ class NotifyReceiver : BroadcastReceiver() {
// Existing cached content logic for regular notifications
val db = DailyNotificationDatabase.getDatabase(context)
val latestCache = db.contentCacheDao().getLatest()
val latestCache = db.contentCacheDao().getLatestByScope(ContentCacheScope.DAILY)
?: db.contentCacheDao().getLatestByScope(ContentCacheScope.LEGACY)
?: db.contentCacheDao().getLatest()
if (latestCache == null) {
Log.w(TAG, "No cached content available for notification")

View File

@@ -154,6 +154,12 @@ class ReactivationManager(private val context: Context) {
Log.e(TAG, "Failed to recover schedule ${schedule.id}", e)
}
}
if (enabledSchedules.any { it.id.startsWith("dual_fetch_") }) {
if (DualScheduleFetchRecovery.enqueueFromPersistedConfig(context)) {
Log.i(TAG, "Dual prefetch WorkManager re-enqueued from persisted config")
}
}
// Record recovery in history
val result = RecoveryResult(

View File

@@ -0,0 +1,59 @@
package org.timesafari.dailynotification
import android.util.Log
/**
* Shared cron → next wall-clock instant (daily "minute hour * * *" style).
* Used by dual prefetch scheduling, rollover, and [DailyNotificationPlugin] scheduling.
*/
object ScheduleCronUtils {
private const val TAG = "DNP-CRON"
/**
* Next occurrence of the given daily cron after "now" (same logic as DailyNotificationPlugin.calculateNextRunTime).
*/
@JvmStatic
fun calculateNextRunTimeMillis(schedule: String): Long {
try {
val parts = schedule.trim().split("\\s+".toRegex())
if (parts.size < 2) {
Log.w(TAG, "Invalid cron format: $schedule, defaulting to 24h from now")
return System.currentTimeMillis() + (24 * 60 * 60 * 1000L)
}
val minute = parts[0].toIntOrNull() ?: 0
val hour = parts[1].toIntOrNull() ?: 9
if (minute < 0 || minute > 59 || hour < 0 || hour > 23) {
Log.w(TAG, "Invalid time values in cron: $schedule, defaulting to 24h from now")
return System.currentTimeMillis() + (24 * 60 * 60 * 1000L)
}
val calendar = java.util.Calendar.getInstance()
val now = calendar.timeInMillis
calendar.set(java.util.Calendar.HOUR_OF_DAY, hour)
calendar.set(java.util.Calendar.MINUTE, minute)
calendar.set(java.util.Calendar.SECOND, 0)
calendar.set(java.util.Calendar.MILLISECOND, 0)
var nextRun = calendar.timeInMillis
if (nextRun <= now) {
calendar.add(java.util.Calendar.DAY_OF_YEAR, 1)
nextRun = calendar.timeInMillis
}
Log.d(
TAG,
"Next run: cron=$schedule, nextRun=${
java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
.format(java.util.Date(nextRun))
}"
)
return nextRun
} catch (e: Exception) {
Log.e(TAG, "Error calculating next run for schedule: $schedule", e)
return System.currentTimeMillis() + (24 * 60 * 60 * 1000L)
}
}
}

View File

@@ -1,6 +1,6 @@
# Android dual schedule: native fetch, WorkManager timing, and scoped content cache
**Status:** Draft — implementation plan (pre-code)
**Status:** Implemented (Android) — see CHANGELOG [2.1.5]
**Date:** 2026-03-25
**Scope:** `daily-notification-plugin` Android (Kotlin/Java), dual / “New Activity” schedule (`scheduleDualNotification`)

View File

@@ -1,6 +1,6 @@
{
"name": "@timesafari/daily-notification-plugin",
"version": "2.1.4",
"version": "2.1.5",
"description": "TimeSafari Daily Notification Plugin - Enterprise-grade daily notification functionality with dual scheduling, callback support, TTL-at-fire logic, and comprehensive observability across Mobile (Capacitor) and Desktop (Electron) platforms",
"main": "dist/plugin.js",
"module": "dist/esm/index.js",