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