diff --git a/android/src/main/java/org/timesafari/dailynotification/DualScheduleHelper.kt b/android/src/main/java/org/timesafari/dailynotification/DualScheduleHelper.kt index a0eb8bc..e5709b8 100644 --- a/android/src/main/java/org/timesafari/dailynotification/DualScheduleHelper.kt +++ b/android/src/main/java/org/timesafari/dailynotification/DualScheduleHelper.kt @@ -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) } diff --git a/android/src/main/java/org/timesafari/dailynotification/FetchWorker.kt b/android/src/main/java/org/timesafari/dailynotification/FetchWorker.kt index 342031b..c6e11a4 100644 --- a/android/src/main/java/org/timesafari/dailynotification/FetchWorker.kt +++ b/android/src/main/java/org/timesafari/dailynotification/FetchWorker.kt @@ -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): 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" diff --git a/android/src/test/java/org/timesafari/dailynotification/DualScheduleHelperTest.kt b/android/src/test/java/org/timesafari/dailynotification/DualScheduleHelperTest.kt new file mode 100644 index 0000000..dd200d1 --- /dev/null +++ b/android/src/test/java/org/timesafari/dailynotification/DualScheduleHelperTest.kt @@ -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) + } + } +}