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:
12
CHANGELOG.md
12
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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 app’s **`NativeNotificationContentFetcher`** is used when registered.
|
||||
|
||||
### Callback Methods
|
||||
|
||||
#### `registerCallback(name, config)`
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user