Compare commits
2 Commits
fbb5a94071
...
b6f663121d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6f663121d | ||
|
|
5756178c23 |
10
CHANGELOG.md
10
CHANGELOG.md
@@ -5,6 +5,16 @@ 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).
|
||||
|
||||
## [3.0.1] - 2026-04-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android**: Dual native prefetch with an empty `NotificationContent` list no longer maps to placeholder title/body or arms the chained notify alarm for that cycle. The cache stores `skipNotification`, `DualScheduleHelper` skips display for fresh payloads (and for stale cache when `relationship.fallbackBehavior` is `skip`), and `DualScheduleFetchRecovery` still schedules the next prefetch.
|
||||
|
||||
### Added
|
||||
|
||||
- **Android**: Unit tests (`DualScheduleHelperTest`) for dual empty-cache resolution and skip payload detection.
|
||||
|
||||
## [3.0.0] - 2026-04-02
|
||||
|
||||
### Added
|
||||
|
||||
@@ -44,6 +44,9 @@ object DualScheduleHelper {
|
||||
val payloadStr = String(latestCache.payload, Charsets.UTF_8)
|
||||
try {
|
||||
val payload = JSONObject(payloadStr)
|
||||
if (payload.optBoolean("skipNotification", false)) {
|
||||
return null
|
||||
}
|
||||
Pair(
|
||||
payload.optString("title", defaultTitle),
|
||||
payload.optString("body", payload.optString("content", defaultBody))
|
||||
@@ -52,6 +55,16 @@ object DualScheduleHelper {
|
||||
Pair(defaultTitle, defaultBody)
|
||||
}
|
||||
} else {
|
||||
val staleSkip = latestCache?.let { cache ->
|
||||
try {
|
||||
JSONObject(String(cache.payload, Charsets.UTF_8)).optBoolean("skipNotification", false)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
} ?: false
|
||||
if (staleSkip && fallbackBehavior == "skip") {
|
||||
return null
|
||||
}
|
||||
if (fallbackBehavior != "show_default") return null
|
||||
Pair(defaultTitle, defaultBody)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import kotlinx.coroutines.withContext
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import kotlin.text.Charsets
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
@@ -34,6 +35,23 @@ class FetchWorker(
|
||||
private const val KEY_CACHE_SCOPE = "cache_scope"
|
||||
private const val KEY_NEXT_NOTIFY_AT = "next_notify_at"
|
||||
|
||||
/**
|
||||
* Persisted for dual native fetch when the host returns no rows.
|
||||
* [DualScheduleHelper] must not display a notification; [doWork] skips chained notify.
|
||||
*/
|
||||
internal val dualEmptyNativeFetchPayload: ByteArray =
|
||||
"""{"skipNotification":true}""".toByteArray(Charsets.UTF_8)
|
||||
|
||||
/** True when [payload] is the dual-cache sentinel for “API / native had no content”. */
|
||||
@JvmStatic
|
||||
fun isDualSkipNotificationPayload(payload: ByteArray): Boolean {
|
||||
return try {
|
||||
JSONObject(String(payload, Charsets.UTF_8)).optBoolean("skipNotification", false)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun scheduleFetch(context: Context, config: ContentFetchConfig) {
|
||||
enqueueFetch(context, config, WORK_NAME)
|
||||
}
|
||||
@@ -244,6 +262,8 @@ class FetchWorker(
|
||||
Log.i(TAG, "Starting content fetch from: $url, notificationTime=$notificationTime, isDual=$isDual, scope=$cacheScope")
|
||||
|
||||
val payload = resolvePayload(url, timeout, retryAttempts, retryDelay, isDual, nextNotifyAt)
|
||||
val skipDualChainedNotify =
|
||||
isDual && nextNotifyAt > 0L && isDualSkipNotificationPayload(payload)
|
||||
val contentCache = ContentCache(
|
||||
id = generateId(),
|
||||
fetchedAt = System.currentTimeMillis(),
|
||||
@@ -304,7 +324,11 @@ class FetchWorker(
|
||||
|
||||
Log.i(TAG, "Content fetch completed successfully")
|
||||
if (isDual && nextNotifyAt > 0L) {
|
||||
DualScheduleNotifyScheduler.scheduleChainedNotifyAlarm(applicationContext, nextNotifyAt)
|
||||
if (!skipDualChainedNotify) {
|
||||
DualScheduleNotifyScheduler.scheduleChainedNotifyAlarm(applicationContext, nextNotifyAt)
|
||||
} else {
|
||||
Log.i(TAG, "Dual fetch: empty native content — skip chained notify")
|
||||
}
|
||||
DualScheduleFetchRecovery.enqueueFromPersistedConfig(applicationContext)
|
||||
}
|
||||
Result.success()
|
||||
@@ -370,7 +394,7 @@ class FetchWorker(
|
||||
|
||||
private fun notificationContentsToDualPayloadBytes(contents: List<NotificationContent>): ByteArray {
|
||||
if (contents.isEmpty()) {
|
||||
return """{"title":"No updates","body":"No new content"}""".toByteArray(Charsets.UTF_8)
|
||||
return dualEmptyNativeFetchPayload
|
||||
}
|
||||
val c = contents[0]
|
||||
val title = c.getTitle() ?: "Daily Notification"
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package org.timesafari.dailynotification
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.json.JSONObject
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import kotlin.text.Charsets
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28])
|
||||
class DualScheduleHelperTest {
|
||||
|
||||
private lateinit var context: Context
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
context.getSharedPreferences(DailyNotificationConstants.DUAL_SCHEDULE_PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.clear()
|
||||
.commit()
|
||||
runBlocking {
|
||||
DailyNotificationDatabase.getDatabase(context).contentCacheDao().deleteAll()
|
||||
}
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
context.getSharedPreferences(DailyNotificationConstants.DUAL_SCHEDULE_PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.clear()
|
||||
.commit()
|
||||
runBlocking {
|
||||
DailyNotificationDatabase.getDatabase(context).contentCacheDao().deleteAll()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun freshCache_skipNotification_returnsNull_evenWithShowDefault() {
|
||||
putDualConfig("""{"fallbackBehavior":"show_default","contentTimeout":600000}""")
|
||||
insertDualCache(
|
||||
payload = FetchWorker.dualEmptyNativeFetchPayload,
|
||||
fetchedAt = System.currentTimeMillis()
|
||||
)
|
||||
assertNull(DualScheduleHelper.resolveDualContentBlocking(context, "dual_notify_test"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun freshCache_skipNotification_returnsNull_withSkipFallback() {
|
||||
putDualConfig("""{"fallbackBehavior":"skip","contentTimeout":600000}""")
|
||||
insertDualCache(
|
||||
payload = FetchWorker.dualEmptyNativeFetchPayload,
|
||||
fetchedAt = System.currentTimeMillis()
|
||||
)
|
||||
assertNull(DualScheduleHelper.resolveDualContentBlocking(context, "dual_notify_test"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleCache_skipNotification_skipFallback_returnsNull() {
|
||||
putDualConfig("""{"fallbackBehavior":"skip","contentTimeout":1000}""")
|
||||
insertDualCache(
|
||||
payload = FetchWorker.dualEmptyNativeFetchPayload,
|
||||
fetchedAt = System.currentTimeMillis() - 60_000L
|
||||
)
|
||||
assertNull(DualScheduleHelper.resolveDualContentBlocking(context, "dual_notify_test"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleCache_skipNotification_showDefault_usesUserDefaults() {
|
||||
putDualConfig("""{"fallbackBehavior":"show_default","contentTimeout":1000}""")
|
||||
insertDualCache(
|
||||
payload = FetchWorker.dualEmptyNativeFetchPayload,
|
||||
fetchedAt = System.currentTimeMillis() - 60_000L
|
||||
)
|
||||
val content = DualScheduleHelper.resolveDualContentBlocking(context, "dual_notify_test")
|
||||
assertNotNull(content)
|
||||
assertTrue(content!!.title == "New Activity")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun freshCache_realPayload_returnsContent() {
|
||||
putDualConfig("""{"fallbackBehavior":"skip","contentTimeout":600000}""")
|
||||
val json = JSONObject()
|
||||
json.put("title", "API Title")
|
||||
json.put("body", "API Body")
|
||||
insertDualCache(
|
||||
payload = json.toString().toByteArray(Charsets.UTF_8),
|
||||
fetchedAt = System.currentTimeMillis()
|
||||
)
|
||||
val content = DualScheduleHelper.resolveDualContentBlocking(context, "dual_notify_test")
|
||||
assertNotNull(content)
|
||||
assertTrue(content!!.title == "API Title" && content.body == "API Body")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isDualSkipNotificationPayload_emptyObject_false() {
|
||||
assertFalse(FetchWorker.isDualSkipNotificationPayload("{}".toByteArray(Charsets.UTF_8)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isDualSkipNotificationPayload_sentinel_true() {
|
||||
assertTrue(FetchWorker.isDualSkipNotificationPayload(FetchWorker.dualEmptyNativeFetchPayload))
|
||||
}
|
||||
|
||||
private fun putDualConfig(relationshipJson: String) {
|
||||
val root = JSONObject()
|
||||
val user = JSONObject()
|
||||
user.put("enabled", true)
|
||||
user.put("schedule", "0 9 * * *")
|
||||
user.put("title", "New Activity")
|
||||
user.put("body", "Check your starred projects for updates")
|
||||
root.put("userNotification", user)
|
||||
root.put("relationship", JSONObject(relationshipJson))
|
||||
context.getSharedPreferences(DailyNotificationConstants.DUAL_SCHEDULE_PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(DailyNotificationConstants.DUAL_SCHEDULE_CONFIG_KEY, root.toString())
|
||||
.commit()
|
||||
}
|
||||
|
||||
private fun insertDualCache(payload: ByteArray, fetchedAt: Long) {
|
||||
val cache = ContentCache(
|
||||
id = "test_dual_${System.nanoTime()}",
|
||||
fetchedAt = fetchedAt,
|
||||
ttlSeconds = 3600,
|
||||
payload = payload,
|
||||
meta = "test",
|
||||
cacheScope = ContentCacheScope.DUAL
|
||||
)
|
||||
runBlocking {
|
||||
DailyNotificationDatabase.getDatabase(context).contentCacheDao().upsert(cache)
|
||||
}
|
||||
}
|
||||
}
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@timesafari/daily-notification-plugin",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@timesafari/daily-notification-plugin",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.1",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@timesafari/daily-notification-plugin",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.1",
|
||||
"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