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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user