From 787c36323cb16147ccb2c4dedb9a02b3cc87d1d6 Mon Sep 17 00:00:00 2001 From: test Date: Sun, 30 Aug 2026 15:13:19 +0000 Subject: [PATCH] fix(notify): accept canonical shared config schema Git-Session-Id: b686 --- .../android/workers/NotifyWorker.kt | 42 +++++++++--- .../android/workers/NotifyWorkerTest.kt | 68 ++++++++++++++++++- 2 files changed, 97 insertions(+), 13 deletions(-) diff --git a/mobile/src/main/java/net/activitywatch/android/workers/NotifyWorker.kt b/mobile/src/main/java/net/activitywatch/android/workers/NotifyWorker.kt index b746d184..75f0ac45 100644 --- a/mobile/src/main/java/net/activitywatch/android/workers/NotifyWorker.kt +++ b/mobile/src/main/java/net/activitywatch/android/workers/NotifyWorker.kt @@ -16,6 +16,7 @@ import net.activitywatch.android.R import net.activitywatch.android.RustInterface import org.json.JSONArray import org.json.JSONException +import org.json.JSONObject import org.threeten.bp.LocalDate import org.threeten.bp.LocalDateTime import org.threeten.bp.ZoneId @@ -51,25 +52,44 @@ private val DEFAULT_ALERTS = listOf( internal fun logicalDayDate(now: LocalDateTime, startOfDayHour: Int): LocalDate = if (now.hour < startOfDayHour) now.toLocalDate().minusDays(1) else now.toLocalDate() -internal fun parseAlerts(json: String): List { - val arr = JSONArray(json) - return (0 until arr.length()).mapNotNull { i -> - val obj = arr.getJSONObject(i) - val category = if (obj.isNull("category")) null else obj.optString("category").ifEmpty { null } - val label = obj.optString("label").ifEmpty { return@mapNotNull null } - val thresholdsArr = obj.optJSONArray("thresholdMinutes") ?: return@mapNotNull null - val thresholds = (0 until thresholdsArr.length()).map { thresholdsArr.getInt(it) } - val positive = obj.optBoolean("positive", false) +internal fun parseAlerts(json: String): List = parseAlertArray(JSONArray(json), legacy = true) + +private fun parseAlertArray(alerts: JSONArray, legacy: Boolean): List = + (0 until alerts.length()).mapNotNull { i -> + val alert = alerts.getJSONObject(i) + val rawCategory = if (alert.isNull("category")) null else alert.optString("category") + val category = rawCategory?.takeUnless { it.isEmpty() || it == "All" } + val label = if (alert.isNull("label")) { + rawCategory ?: "All" + } else { + alert.optString("label").ifEmpty { rawCategory ?: "All" } + } + val thresholdsKey = if (legacy) "thresholdMinutes" else "thresholds_minutes" + val thresholdsArray = alert.optJSONArray(thresholdsKey) ?: return@mapNotNull null + val thresholds = (0 until thresholdsArray.length()).mapNotNull { j -> + val minutes = thresholdsArray.opt(j) + (minutes as? Number)?.takeIf { + it.toDouble() > 0 && it.toDouble() % 1.0 == 0.0 && it.toLong() <= Int.MAX_VALUE + }?.toInt() + } + if (thresholds.size != thresholdsArray.length() || thresholds.isEmpty()) return@mapNotNull null + val positive = alert.optBoolean("positive", false) CategoryAlert(category, label, thresholds, positive) } -} internal fun alertsFromSetting(json: String): List { val value = json.trim() if (value.isEmpty() || value == "null") return DEFAULT_ALERTS return try { - parseAlerts(value).takeIf { it.isNotEmpty() } ?: DEFAULT_ALERTS + if (value.startsWith("[")) { + parseAlerts(value).takeIf { it.isNotEmpty() } ?: DEFAULT_ALERTS + } else { + val config = JSONObject(value) + val alertsArray = config.getJSONArray("alerts") + val alerts = parseAlertArray(alertsArray, legacy = false) + if (alertsArray.length() == 0) alerts else alerts.takeIf { it.isNotEmpty() } ?: DEFAULT_ALERTS + } } catch (e: Exception) { DEFAULT_ALERTS } diff --git a/mobile/src/test/java/net/activitywatch/android/workers/NotifyWorkerTest.kt b/mobile/src/test/java/net/activitywatch/android/workers/NotifyWorkerTest.kt index 067cf697..0976f8c2 100644 --- a/mobile/src/test/java/net/activitywatch/android/workers/NotifyWorkerTest.kt +++ b/mobile/src/test/java/net/activitywatch/android/workers/NotifyWorkerTest.kt @@ -15,14 +15,78 @@ class NotifyWorkerTest { } @Test - fun alertsFromSetting_readsNativeSetting() { + fun alertsFromSetting_readsCanonicalSetting() { val alerts = alertsFromSetting( - """[{"category":"Work","label":"Focus","thresholdMinutes":[30]}]""" + """{"alerts":[{"category":"Work","label":"Focus","thresholds_minutes":[30],"positive":true}]}""" + ) + + assertEquals(1, alerts.size) + assertEquals("Work", alerts[0].category) + assertEquals("Focus", alerts[0].label) + assertEquals(listOf(30), alerts[0].thresholdMinutes) + assertEquals(true, alerts[0].positive) + } + + @Test + fun alertsFromSetting_mapsCanonicalAllCategoryToAggregate() { + val alerts = alertsFromSetting( + """{"alerts":[{"category":"All","label":null,"thresholds_minutes":[60],"positive":false}]}""" + ) + + assertEquals(1, alerts.size) + assertNull(alerts[0].category) + assertEquals("All", alerts[0].label) + } + + @Test + fun alertsFromSetting_preservesEmptyCanonicalAlerts() { + assertEquals(emptyList(), alertsFromSetting("""{"alerts":[]}""")) + } + + @Test + fun alertsFromSetting_skipsMalformedCanonicalAlertWithoutDroppingValidAlerts() { + val alerts = alertsFromSetting( + """{"alerts":[ + {"category":"Work","label":"Focus","thresholds_minutes":[30],"positive":true}, + {"category":"Media","label":"Media","positive":false} + ]}""" + ) + + assertEquals(1, alerts.size) + assertEquals("Focus", alerts[0].label) + } + + @Test + fun alertsFromSetting_fallsBackWhenAllCanonicalAlertsAreMalformed() { + val alerts = alertsFromSetting( + """{"alerts":[{"category":"Work","label":"Focus","positive":true}]}""" + ) + + assertEquals("All", alerts[0].label) + } + + @Test + fun alertsFromSetting_rejectsNonPositiveOrFractionalThresholds() { + val alerts = alertsFromSetting( + """{"alerts":[ + {"category":"Work","label":"Zero","thresholds_minutes":[0],"positive":true}, + {"category":"Media","label":"Fractional","thresholds_minutes":[1.5],"positive":false} + ]}""" + ) + + assertEquals("All", alerts[0].label) + } + + @Test + fun alertsFromSetting_preservesLegacySettingCompatibility() { + val alerts = alertsFromSetting( + """[{"category":"Work","label":"Focus","thresholdMinutes":[30],"positive":true}]""" ) assertEquals(1, alerts.size) assertEquals("Focus", alerts[0].label) assertEquals(listOf(30), alerts[0].thresholdMinutes) + assertEquals(true, alerts[0].positive) } @Test