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/), 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). 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 ## [2.1.4] - 2026-03-20
### Fixed ### 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). 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 ### Callback Methods
#### `registerCallback(name, config)` #### `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 { CoroutineScope(Dispatchers.IO).launch {
try { try {
// Use FetchWorker.scheduleFetchForDual so cancelDualSchedule can cancel only dual fetch
val success = ScheduleHelper.scheduleDualNotification( val success = ScheduleHelper.scheduleDualNotification(
context, context,
getDatabase(), getDatabase(),
contentFetchConfig, contentFetchConfig,
userNotificationConfig, userNotificationConfig,
FetchWorker::scheduleFetchForDual,
::calculateNextRunTime ::calculateNextRunTime
) )
@@ -1448,7 +1446,7 @@ open class DailyNotificationPlugin : Plugin() {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
try { try {
val enabledSchedules = getDatabase().scheduleDao().getEnabled() 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 recentHistory = getDatabase().historyDao().getSince(System.currentTimeMillis() - (24 * 60 * 60 * 1000L))
val status = JSObject().apply { val status = JSObject().apply {
@@ -1521,7 +1519,6 @@ open class DailyNotificationPlugin : Plugin() {
db, db,
contentFetchConfig, contentFetchConfig,
userNotificationConfig, userNotificationConfig,
FetchWorker::scheduleFetchForDual,
::calculateNextRunTime ::calculateNextRunTime
) )
if (success) { if (success) {
@@ -1897,12 +1894,15 @@ open class DailyNotificationPlugin : Plugin() {
val ttlSeconds = contentJson.getInt("ttlSeconds") val ttlSeconds = contentJson.getInt("ttlSeconds")
?: return@launch call.reject("TTL seconds is required") ?: return@launch call.reject("TTL seconds is required")
val scope = contentJson.getString("cacheScope")?.takeIf { it.isNotEmpty() }
?: ContentCacheScope.LEGACY
val cache = ContentCache( val cache = ContentCache(
id = id, id = id,
fetchedAt = System.currentTimeMillis(), fetchedAt = System.currentTimeMillis(),
ttlSeconds = ttlSeconds, ttlSeconds = ttlSeconds,
payload = payload.toByteArray(), payload = payload.toByteArray(),
meta = contentJson.getString("meta") meta = contentJson.getString("meta"),
cacheScope = scope
) )
getDatabase().contentCacheDao().upsert(cache) getDatabase().contentCacheDao().upsert(cache)
@@ -2349,6 +2349,7 @@ open class DailyNotificationPlugin : Plugin() {
put("ttlSeconds", cache.ttlSeconds) put("ttlSeconds", cache.ttlSeconds)
put("payload", String(cache.payload)) put("payload", String(cache.payload))
put("meta", cache.meta) put("meta", cache.meta)
put("cacheScope", cache.cacheScope)
} }
} }
@@ -2439,48 +2440,7 @@ open class DailyNotificationPlugin : Plugin() {
} }
private fun calculateNextRunTime(schedule: String): Long { private fun calculateNextRunTime(schedule: String): Long {
// Parse cron expression: "minute hour * * *" (daily schedule) return ScheduleCronUtils.calculateNextRunTimeMillis(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)
}
} }
/** /**
@@ -2876,7 +2836,6 @@ object ScheduleHelper {
* @param database Database instance * @param database Database instance
* @param contentFetchConfig Content fetch configuration * @param contentFetchConfig Content fetch configuration
* @param userNotificationConfig User notification 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 * @param calculateNextRunTime Function to calculate next run time from cron expression
* @return true if successful, false otherwise * @return true if successful, false otherwise
*/ */
@@ -2885,15 +2844,20 @@ object ScheduleHelper {
database: DailyNotificationDatabase, database: DailyNotificationDatabase,
contentFetchConfig: ContentFetchConfig, contentFetchConfig: ContentFetchConfig,
userNotificationConfig: UserNotificationConfig, userNotificationConfig: UserNotificationConfig,
scheduleFetch: (Context, ContentFetchConfig) -> Unit,
calculateNextRunTime: (String) -> Long calculateNextRunTime: (String) -> Long
): Boolean { ): Boolean {
return try { return try {
// Schedule fetch val nextFetchAt = calculateNextRunTime(contentFetchConfig.schedule)
scheduleFetch(context, contentFetchConfig) val nextNotifyAt = calculateNextRunTime(userNotificationConfig.schedule)
FetchWorker.enqueueDualFetch(
context,
contentFetchConfig,
nextFetchAt,
nextNotifyAt
)
// Schedule notification (use dual_notify_* so receiver can recognize dual and apply relationship) // 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()}" val scheduleId = "dual_notify_${System.currentTimeMillis()}"
NotifyReceiver.scheduleExactNotification( NotifyReceiver.scheduleExactNotification(
context, context,
@@ -2909,7 +2873,7 @@ object ScheduleHelper {
kind = "fetch", kind = "fetch",
cron = contentFetchConfig.schedule, cron = contentFetchConfig.schedule,
enabled = contentFetchConfig.enabled, enabled = contentFetchConfig.enabled,
nextRunAt = calculateNextRunTime(contentFetchConfig.schedule) nextRunAt = nextFetchAt
) )
val notifySchedule = Schedule( val notifySchedule = Schedule(
id = scheduleId, id = scheduleId,

View File

@@ -33,7 +33,9 @@ data class ContentCache(
val fetchedAt: Long, // epoch ms val fetchedAt: Long, // epoch ms
val ttlSeconds: Int, val ttlSeconds: Int,
val payload: ByteArray, // BLOB 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") @Entity(tableName = "schedules")
@@ -85,7 +87,7 @@ data class History(
NotificationDeliveryEntity::class, NotificationDeliveryEntity::class,
NotificationConfigEntity::class NotificationConfigEntity::class
], ],
version = 3, // 3: add rollover_interval_minutes to schedules version = 4, // 4: content_cache.cacheScope
exportSchema = false exportSchema = false
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
@@ -120,7 +122,7 @@ abstract class DailyNotificationDatabase : RoomDatabase() {
DailyNotificationDatabase::class.java, DailyNotificationDatabase::class.java,
DATABASE_NAME 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) .addCallback(roomCallback)
.build() .build()
INSTANCE = instance INSTANCE = instance
@@ -277,6 +279,15 @@ abstract class DailyNotificationDatabase : RoomDatabase() {
database.execSQL("ALTER TABLE schedules ADD COLUMN rollover_interval_minutes INTEGER") 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") @Query("SELECT * FROM content_cache ORDER BY fetchedAt DESC LIMIT 1")
suspend fun getLatest(): ContentCache? 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") @Query("SELECT * FROM content_cache ORDER BY fetchedAt DESC LIMIT :limit")
suspend fun getHistory(limit: Int): List<ContentCache> 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 defaultBody = userNotification.optString("body", "Your daily update is ready")
val db = DailyNotificationDatabase.getDatabase(context) val db = DailyNotificationDatabase.getDatabase(context)
val latestCache = runBlocking { db.contentCacheDao().getLatest() } val latestCache = runBlocking {
db.contentCacheDao().getLatestByScope(ContentCacheScope.DUAL)
}
val nowMs = System.currentTimeMillis() val nowMs = System.currentTimeMillis()
val (title: String, body: String) = if (latestCache != null && (nowMs - latestCache.fetchedAt) <= contentTimeoutMs) { 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.os.SystemClock
import android.util.Log import android.util.Log
import androidx.work.* import androidx.work.*
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.IOException import java.io.IOException
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.util.concurrent.TimeUnit
import org.json.JSONObject import org.json.JSONObject
/** /**
* WorkManager implementation for content fetching * WorkManager implementation for content fetching
* Implements exponential backoff and network constraints * Implements exponential backoff and network constraints
* *
* @author Matthew Raymer * @author Matthew Raymer
* @version 2.0.0 * @version 2.0.0
*/ */
@@ -30,15 +30,59 @@ class FetchWorker(
/** Unique work name for dual (New Activity) schedule fetch; cancel via cancelDualSchedule only. */ /** Unique work name for dual (New Activity) schedule fetch; cancel via cancelDualSchedule only. */
const val WORK_NAME_DUAL = "fetch_dual" 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) { fun scheduleFetch(context: Context, config: ContentFetchConfig) {
enqueueFetch(context, config, WORK_NAME) 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) { fun enqueueDualFetch(
enqueueFetch(context, config, WORK_NAME_DUAL) 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) { private fun enqueueFetch(context: Context, config: ContentFetchConfig, workName: String) {
@@ -58,16 +102,18 @@ class FetchWorker(
.putInt("timeout", config.timeout ?: 30000) .putInt("timeout", config.timeout ?: 30000)
.putInt("retryAttempts", config.retryAttempts ?: 3) .putInt("retryAttempts", config.retryAttempts ?: 3)
.putInt("retryDelay", config.retryDelay ?: 1000) .putInt("retryDelay", config.retryDelay ?: 1000)
.putBoolean(KEY_IS_DUAL, false)
.putString(KEY_CACHE_SCOPE, ContentCacheScope.DAILY)
.build() .build()
) )
.build() .build()
WorkManager.getInstance(context) WorkManager.getInstance(context)
.enqueueUniqueWork(workName, ExistingWorkPolicy.REPLACE, workRequest) .enqueueUniqueWork(workName, ExistingWorkPolicy.REPLACE, workRequest)
} }
/** /**
* Schedule a delayed fetch for prefetch (5 minutes before notification) * Schedule a delayed fetch for prefetch (5 minutes before notification)
* *
* @param context Application context * @param context Application context
* @param fetchTime When to fetch (in milliseconds since epoch) * @param fetchTime When to fetch (in milliseconds since epoch)
* @param notificationTime When the notification will be shown (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 currentTime = System.currentTimeMillis()
val delayMs = fetchTime - currentTime val delayMs = fetchTime - currentTime
Log.i(TAG, "Scheduling delayed prefetch: fetchTime=$fetchTime, notificationTime=$notificationTime, delayMs=$delayMs") Log.i(TAG, "Scheduling delayed prefetch: fetchTime=$fetchTime, notificationTime=$notificationTime, delayMs=$delayMs")
if (delayMs <= 0) { if (delayMs <= 0) {
Log.w(TAG, "Fetch time is in the past, scheduling immediate fetch") Log.w(TAG, "Fetch time is in the past, scheduling immediate fetch")
scheduleImmediateFetch(context, notificationTime, url) scheduleImmediateFetch(context, notificationTime, url)
return return
} }
// Only require network if URL is provided (mock content doesn't need network) // Only require network if URL is provided (mock content doesn't need network)
val constraints = Constraints.Builder() val constraints = Constraints.Builder()
.apply { .apply {
@@ -101,11 +147,11 @@ class FetchWorker(
} }
} }
.build() .build()
// Create unique work name based on notification time to prevent duplicate fetches // Create unique work name based on notification time to prevent duplicate fetches
val notificationTimeMinutes = notificationTime / (60 * 1000) val notificationTimeMinutes = notificationTime / (60 * 1000)
val workName = "prefetch_${notificationTimeMinutes}" val workName = "prefetch_${notificationTimeMinutes}"
val workRequest = OneTimeWorkRequestBuilder<FetchWorker>() val workRequest = OneTimeWorkRequestBuilder<FetchWorker>()
.setConstraints(constraints) .setConstraints(constraints)
.setInitialDelay(delayMs, TimeUnit.MILLISECONDS) .setInitialDelay(delayMs, TimeUnit.MILLISECONDS)
@@ -122,21 +168,23 @@ class FetchWorker(
.putInt("timeout", 30000) .putInt("timeout", 30000)
.putInt("retryAttempts", 3) .putInt("retryAttempts", 3)
.putInt("retryDelay", 1000) .putInt("retryDelay", 1000)
.putBoolean(KEY_IS_DUAL, false)
.putString(KEY_CACHE_SCOPE, ContentCacheScope.DAILY)
.build() .build()
) )
.addTag("prefetch") .addTag("prefetch")
.build() .build()
WorkManager.getInstance(context) WorkManager.getInstance(context)
.enqueueUniqueWork( .enqueueUniqueWork(
workName, workName,
ExistingWorkPolicy.REPLACE, ExistingWorkPolicy.REPLACE,
workRequest workRequest
) )
Log.i(TAG, "Delayed prefetch scheduled: workName=$workName, delayMs=$delayMs") Log.i(TAG, "Delayed prefetch scheduled: workName=$workName, delayMs=$delayMs")
} }
/** /**
* Schedule an immediate fetch (fallback when delay is in the past) * Schedule an immediate fetch (fallback when delay is in the past)
*/ */
@@ -156,7 +204,7 @@ class FetchWorker(
} }
} }
.build() .build()
val workRequest = OneTimeWorkRequestBuilder<FetchWorker>() val workRequest = OneTimeWorkRequestBuilder<FetchWorker>()
.setConstraints(constraints) .setConstraints(constraints)
.setInputData( .setInputData(
@@ -167,14 +215,16 @@ class FetchWorker(
.putInt("retryAttempts", 3) .putInt("retryAttempts", 3)
.putInt("retryDelay", 1000) .putInt("retryDelay", 1000)
.putBoolean("immediate", true) .putBoolean("immediate", true)
.putBoolean(KEY_IS_DUAL, false)
.putString(KEY_CACHE_SCOPE, ContentCacheScope.DAILY)
.build() .build()
) )
.addTag("prefetch") .addTag("prefetch")
.build() .build()
WorkManager.getInstance(context) WorkManager.getInstance(context)
.enqueue(workRequest) .enqueue(workRequest)
Log.i(TAG, "Immediate prefetch scheduled") Log.i(TAG, "Immediate prefetch scheduled")
} }
} }
@@ -186,30 +236,34 @@ class FetchWorker(
val retryAttempts = inputData.getInt("retryAttempts", 3) val retryAttempts = inputData.getInt("retryAttempts", 3)
val retryDelay = inputData.getInt("retryDelay", 1000) val retryDelay = inputData.getInt("retryDelay", 1000)
val notificationTime = inputData.getLong("notificationTime", 0L) 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 { try {
Log.i(TAG, "Starting content fetch from: $url, notificationTime=$notificationTime") Log.i(TAG, "Starting content fetch from: $url, notificationTime=$notificationTime, isDual=$isDual, scope=$cacheScope")
val payload = fetchContent(url, timeout, retryAttempts, retryDelay) val payload = resolvePayload(url, timeout, retryAttempts, retryDelay, isDual, nextNotifyAt)
val contentCache = ContentCache( val contentCache = ContentCache(
id = generateId(), id = generateId(),
fetchedAt = System.currentTimeMillis(), fetchedAt = System.currentTimeMillis(),
ttlSeconds = 3600, // 1 hour default TTL ttlSeconds = 3600, // 1 hour default TTL
payload = payload, payload = payload,
meta = "fetched_by_workmanager" meta = "fetched_by_workmanager",
cacheScope = cacheScope
) )
// Store in database // Store in database
val db = DailyNotificationDatabase.getDatabase(applicationContext) val db = DailyNotificationDatabase.getDatabase(applicationContext)
db.contentCacheDao().upsert(contentCache) db.contentCacheDao().upsert(contentCache)
// If this is a prefetch for a specific notification, create NotificationContentEntity // If this is a prefetch for a specific notification, create NotificationContentEntity
// so the notification worker can find it when the alarm fires // so the notification worker can find it when the alarm fires
if (notificationTime > 0) { if (notificationTime > 0) {
try { try {
val notificationId = "notify_$notificationTime" val notificationId = "notify_$notificationTime"
val (title, body) = parsePayload(payload) val (title, body) = parsePayload(payload)
val entity = org.timesafari.dailynotification.entities.NotificationContentEntity( val entity = org.timesafari.dailynotification.entities.NotificationContentEntity(
notificationId, notificationId,
"2.1.0", // Plugin version "2.1.0", // Plugin version
@@ -227,7 +281,7 @@ class FetchWorker(
entity.createdAt = System.currentTimeMillis() entity.createdAt = System.currentTimeMillis()
entity.updatedAt = System.currentTimeMillis() entity.updatedAt = System.currentTimeMillis()
entity.ttlSeconds = contentCache.ttlSeconds.toLong() entity.ttlSeconds = contentCache.ttlSeconds.toLong()
// Save to Room database so notification worker can find it // Save to Room database so notification worker can find it
db.notificationContentDao().insertNotification(entity) db.notificationContentDao().insertNotification(entity)
Log.i(TAG, "Created NotificationContentEntity: id=$notificationId, scheduledTime=$notificationTime") Log.i(TAG, "Created NotificationContentEntity: id=$notificationId, scheduledTime=$notificationTime")
@@ -236,7 +290,7 @@ class FetchWorker(
// Continue - at least ContentCache was saved // Continue - at least ContentCache was saved
} }
} }
// Record success in history // Record success in history
db.historyDao().insert( db.historyDao().insert(
History( History(
@@ -247,22 +301,83 @@ class FetchWorker(
outcome = "success" outcome = "success"
) )
) )
Log.i(TAG, "Content fetch completed successfully") Log.i(TAG, "Content fetch completed successfully")
if (isDual) {
DualScheduleFetchRecovery.enqueueFromPersistedConfig(applicationContext)
}
Result.success() Result.success()
} catch (e: IOException) { } catch (e: IOException) {
Log.w(TAG, "Network error during fetch", e) Log.w(TAG, "Network error during fetch", e)
recordFailure("network_error", start, e) recordFailure("network_error", start, e)
Result.retry() Result.retry()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Unexpected error during fetch", e) Log.e(TAG, "Unexpected error during fetch", e)
recordFailure("unexpected_error", start, e) recordFailure("unexpected_error", start, e)
Result.failure() 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( private suspend fun fetchContent(
url: String?, url: String?,
timeout: Int, timeout: Int,
@@ -273,23 +388,22 @@ class FetchWorker(
// Generate mock content for testing // Generate mock content for testing
return generateMockContent() return generateMockContent()
} }
var lastException: Exception? = null var lastException: Exception? = null
repeat(retryAttempts) { attempt -> repeat(retryAttempts) { attempt ->
try { try {
val connection = URL(url).openConnection() as HttpURLConnection val connection = URL(url).openConnection() as HttpURLConnection
connection.connectTimeout = timeout connection.connectTimeout = timeout
connection.readTimeout = timeout connection.readTimeout = timeout
connection.requestMethod = "GET" connection.requestMethod = "GET"
val responseCode = connection.responseCode val responseCode = connection.responseCode
if (responseCode == HttpURLConnection.HTTP_OK) { if (responseCode == HttpURLConnection.HTTP_OK) {
return connection.inputStream.readBytes() return connection.inputStream.readBytes()
} else { } else {
throw IOException("HTTP $responseCode: ${connection.responseMessage}") throw IOException("HTTP $responseCode: ${connection.responseMessage}")
} }
} catch (e: Exception) { } catch (e: Exception) {
lastException = e lastException = e
if (attempt < retryAttempts - 1) { if (attempt < retryAttempts - 1) {
@@ -298,10 +412,10 @@ class FetchWorker(
} }
} }
} }
throw lastException ?: IOException("All retry attempts failed") throw lastException ?: IOException("All retry attempts failed")
} }
private fun generateMockContent(): ByteArray { private fun generateMockContent(): ByteArray {
val mockData = """ val mockData = """
{ {
@@ -313,7 +427,7 @@ class FetchWorker(
""".trimIndent() """.trimIndent()
return mockData.toByteArray() return mockData.toByteArray()
} }
private suspend fun recordFailure(outcome: String, start: Long, error: Throwable) { private suspend fun recordFailure(outcome: String, start: Long, error: Throwable) {
try { try {
val db = DailyNotificationDatabase.getDatabase(applicationContext) val db = DailyNotificationDatabase.getDatabase(applicationContext)
@@ -331,22 +445,22 @@ class FetchWorker(
Log.e(TAG, "Failed to record failure", e) Log.e(TAG, "Failed to record failure", e)
} }
} }
private fun generateId(): String { private fun generateId(): String {
return "fetch_${System.currentTimeMillis()}_${(1000..9999).random()}" return "fetch_${System.currentTimeMillis()}_${(1000..9999).random()}"
} }
/** /**
* Parse payload to extract title and body * Parse payload to extract title and body
* Handles both JSON and plain text payloads * Handles both JSON and plain text payloads
* *
* @param payload Raw payload bytes * @param payload Raw payload bytes
* @return Pair of (title, body) * @return Pair of (title, body)
*/ */
private fun parsePayload(payload: ByteArray): Pair<String, String> { private fun parsePayload(payload: ByteArray): Pair<String, String> {
return try { return try {
val payloadString = String(payload, Charsets.UTF_8) val payloadString = String(payload, Charsets.UTF_8)
// Try to parse as JSON // Try to parse as JSON
val json = JSONObject(payloadString) val json = JSONObject(payloadString)
val title = json.optString("title", "Daily Notification") val title = json.optString("title", "Daily Notification")

View File

@@ -250,7 +250,9 @@ class NotifyReceiver : BroadcastReceiver() {
try { try {
runBlocking { runBlocking {
val db = DailyNotificationDatabase.getDatabase(context) 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 // Always create a notification content entity for recovery tracking
// Phase 1: Recovery needs NotificationContentEntity to detect missed notifications // Phase 1: Recovery needs NotificationContentEntity to detect missed notifications
@@ -670,7 +672,9 @@ class NotifyReceiver : BroadcastReceiver() {
// Existing cached content logic for regular notifications // Existing cached content logic for regular notifications
val db = DailyNotificationDatabase.getDatabase(context) 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) { if (latestCache == null) {
Log.w(TAG, "No cached content available for notification") 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) 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 // Record recovery in history
val result = RecoveryResult( 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 # 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 **Date:** 2026-03-25
**Scope:** `daily-notification-plugin` Android (Kotlin/Java), dual / “New Activity” schedule (`scheduleDualNotification`) **Scope:** `daily-notification-plugin` Android (Kotlin/Java), dual / “New Activity” schedule (`scheduleDualNotification`)

View File

@@ -1,6 +1,6 @@
{ {
"name": "@timesafari/daily-notification-plugin", "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", "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", "main": "dist/plugin.js",
"module": "dist/esm/index.js", "module": "dist/esm/index.js",