From 469167a55fbebb91b3e61d6c8b3aec6fc873a13c Mon Sep 17 00:00:00 2001 From: Jose Olarte III Date: Wed, 25 Mar 2026 18:05:57 +0800 Subject: [PATCH] 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. --- CHANGELOG.md | 12 ++ README.md | 2 + .../dailynotification/ContentCacheScope.kt | 11 + .../DailyNotificationPlugin.kt | 72 ++----- .../dailynotification/DatabaseSchema.kt | 20 +- .../DualScheduleFetchRecovery.kt | 82 +++++++ .../dailynotification/DualScheduleHelper.kt | 4 +- .../dailynotification/FetchWorker.kt | 200 ++++++++++++++---- .../dailynotification/NotifyReceiver.kt | 8 +- .../dailynotification/ReactivationManager.kt | 6 + .../dailynotification/ScheduleCronUtils.kt | 59 ++++++ ...L_SCHEDULE_NATIVE_FETCH_AND_CACHE_SCOPE.md | 2 +- package.json | 2 +- 13 files changed, 375 insertions(+), 105 deletions(-) create mode 100644 android/src/main/java/org/timesafari/dailynotification/ContentCacheScope.kt create mode 100644 android/src/main/java/org/timesafari/dailynotification/DualScheduleFetchRecovery.kt create mode 100644 android/src/main/java/org/timesafari/dailynotification/ScheduleCronUtils.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index a9c5b0f..395533e 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/README.md b/README.md index 7057174..0ac7e7d 100644 --- a/README.md +++ b/README.md @@ -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)` diff --git a/android/src/main/java/org/timesafari/dailynotification/ContentCacheScope.kt b/android/src/main/java/org/timesafari/dailynotification/ContentCacheScope.kt new file mode 100644 index 0000000..4d158bd --- /dev/null +++ b/android/src/main/java/org/timesafari/dailynotification/ContentCacheScope.kt @@ -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" +} diff --git a/android/src/main/java/org/timesafari/dailynotification/DailyNotificationPlugin.kt b/android/src/main/java/org/timesafari/dailynotification/DailyNotificationPlugin.kt index 32045fa..ec54ae6 100644 --- a/android/src/main/java/org/timesafari/dailynotification/DailyNotificationPlugin.kt +++ b/android/src/main/java/org/timesafari/dailynotification/DailyNotificationPlugin.kt @@ -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, diff --git a/android/src/main/java/org/timesafari/dailynotification/DatabaseSchema.kt b/android/src/main/java/org/timesafari/dailynotification/DatabaseSchema.kt index 5fe307b..b77e797 100644 --- a/android/src/main/java/org/timesafari/dailynotification/DatabaseSchema.kt +++ b/android/src/main/java/org/timesafari/dailynotification/DatabaseSchema.kt @@ -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 diff --git a/android/src/main/java/org/timesafari/dailynotification/DualScheduleFetchRecovery.kt b/android/src/main/java/org/timesafari/dailynotification/DualScheduleFetchRecovery.kt new file mode 100644 index 0000000..5e1cc8b --- /dev/null +++ b/android/src/main/java/org/timesafari/dailynotification/DualScheduleFetchRecovery.kt @@ -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() } + ) + } +} diff --git a/android/src/main/java/org/timesafari/dailynotification/DualScheduleHelper.kt b/android/src/main/java/org/timesafari/dailynotification/DualScheduleHelper.kt index 0e59986..a0eb8bc 100644 --- a/android/src/main/java/org/timesafari/dailynotification/DualScheduleHelper.kt +++ b/android/src/main/java/org/timesafari/dailynotification/DualScheduleHelper.kt @@ -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) { diff --git a/android/src/main/java/org/timesafari/dailynotification/FetchWorker.kt b/android/src/main/java/org/timesafari/dailynotification/FetchWorker.kt index bffd4f3..b83a306 100644 --- a/android/src/main/java/org/timesafari/dailynotification/FetchWorker.kt +++ b/android/src/main/java/org/timesafari/dailynotification/FetchWorker.kt @@ -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() + .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() .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() .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() + 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): 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 { 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") diff --git a/android/src/main/java/org/timesafari/dailynotification/NotifyReceiver.kt b/android/src/main/java/org/timesafari/dailynotification/NotifyReceiver.kt index 2cfbb6f..b188cb0 100644 --- a/android/src/main/java/org/timesafari/dailynotification/NotifyReceiver.kt +++ b/android/src/main/java/org/timesafari/dailynotification/NotifyReceiver.kt @@ -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") diff --git a/android/src/main/java/org/timesafari/dailynotification/ReactivationManager.kt b/android/src/main/java/org/timesafari/dailynotification/ReactivationManager.kt index aaa2b2e..911e0d7 100644 --- a/android/src/main/java/org/timesafari/dailynotification/ReactivationManager.kt +++ b/android/src/main/java/org/timesafari/dailynotification/ReactivationManager.kt @@ -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( diff --git a/android/src/main/java/org/timesafari/dailynotification/ScheduleCronUtils.kt b/android/src/main/java/org/timesafari/dailynotification/ScheduleCronUtils.kt new file mode 100644 index 0000000..4fe4e0b --- /dev/null +++ b/android/src/main/java/org/timesafari/dailynotification/ScheduleCronUtils.kt @@ -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) + } + } +} diff --git a/doc/platform/android/ANDROID_DUAL_SCHEDULE_NATIVE_FETCH_AND_CACHE_SCOPE.md b/doc/platform/android/ANDROID_DUAL_SCHEDULE_NATIVE_FETCH_AND_CACHE_SCOPE.md index 9aebb17..fd1c038 100644 --- a/doc/platform/android/ANDROID_DUAL_SCHEDULE_NATIVE_FETCH_AND_CACHE_SCOPE.md +++ b/doc/platform/android/ANDROID_DUAL_SCHEDULE_NATIVE_FETCH_AND_CACHE_SCOPE.md @@ -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`) diff --git a/package.json b/package.json index af4a8b9..520ade5 100644 --- a/package.json +++ b/package.json @@ -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",