2 Commits

Author SHA1 Message Date
Jose Olarte III
b6f663121d chore(release): bump to 3.0.1
Set package and lockfile to 3.0.1 and document the Android dual-schedule
empty native fetch fix in CHANGELOG.
2026-04-16 17:07:36 +08:00
Jose Olarte III
5756178c23 fix(android): skip dual notify when native fetch is empty
Dual native prefetch used to map an empty NotificationContent list to
synthetic JSON and still arm the chained notify alarm, which led hosts
such as TimeSafari to show marketing copy via show_default even when the
API had no rows.

Persist {"skipNotification":true} for an empty native result, skip
DualScheduleNotifyScheduler for that successful cycle while still
enqueueing dual fetch recovery, and teach DualScheduleHelper to return
no content for fresh skip payloads and for stale cache when
fallbackBehavior is skip. Add Robolectric tests for DualScheduleHelper
and the skip payload helper.
2026-04-16 17:05:15 +08:00
6 changed files with 195 additions and 5 deletions

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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"

View File

@@ -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
View File

@@ -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/*"

View File

@@ -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",