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.
This commit is contained in:
Jose Olarte III
2026-04-16 17:05:15 +08:00
parent fbb5a94071
commit 5756178c23
3 changed files with 182 additions and 2 deletions

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