From 422f771c66635e98a0cb25aa15bac7f439ec2577 Mon Sep 17 00:00:00 2001 From: Brayo Date: Thu, 8 Oct 2026 22:22:35 +0300 Subject: [PATCH 1/2] fix(notify): ingest new usage events before checking alert thresholds NotifyWorker queried category time without ingesting first, so alerts were judged against data last ingested by the hourly (inexact) alarm, an app open or a widget refresh. --- .../android/workers/NotifyWorker.kt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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 f7349e97..a9186e1a 100644 --- a/mobile/src/main/java/net/activitywatch/android/workers/NotifyWorker.kt +++ b/mobile/src/main/java/net/activitywatch/android/workers/NotifyWorker.kt @@ -15,6 +15,8 @@ import net.activitywatch.android.EXTRA_OPEN_ACTIVITY_VIEW import net.activitywatch.android.MainActivity import net.activitywatch.android.R import net.activitywatch.android.RustInterface +import net.activitywatch.android.watcher.SessionEventWatcher +import net.activitywatch.android.watcher.UsageStatsWatcher import org.json.JSONArray import org.json.JSONException import org.json.JSONObject @@ -200,6 +202,8 @@ class NotifyWorker internal constructor( return Result.retry() } + ingestLatestUsage() + return try { val alerts = alertsFromSetting(backend.getSetting("aw-notify")) // Keep the periodic worker scheduled so enabling later takes effect, @@ -217,6 +221,18 @@ class NotifyWorker internal constructor( } } + // Usage events otherwise reach the datastore only on the hourly (inexact) alarm, app + // open or widget refresh, so alerts were judged against data up to an hour old. + private fun ingestLatestUsage() { + if (!UsageStatsWatcher.isUsageAllowed(applicationContext)) return + try { + SessionEventWatcher(applicationContext).processEventsSinceLastUpdate() + } catch (e: Exception) { + // Still check thresholds against what is already stored. + Log.w(TAG, "Failed to ingest usage events before checking thresholds", e) + } + } + private fun getCategorySecondsToday( backend: NotifyBackend, now: LocalDateTime, From a4d0b823501d0e879dc340b15bebcc5c75c260f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Bj=C3=A4reholt?= Date: Sun, 11 Oct 2026 19:02:03 +0200 Subject: [PATCH 2/2] fix(notify): ingest via the backend seam, only when alerts are enabled Rebased onto #329: ingestion now runs after the disabled/malformed early return, so a disabled aw-notify never touches activity data, and goes through NotifyBackend so the execution tests cover the ordering and the ingest-failure fallback. --- .../android/workers/NotifyWorker.kt | 30 ++++++++++--------- .../workers/NotifyWorkerExecutionTest.kt | 23 ++++++++++++-- 2 files changed, 37 insertions(+), 16 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 a9186e1a..5820a5d1 100644 --- a/mobile/src/main/java/net/activitywatch/android/workers/NotifyWorker.kt +++ b/mobile/src/main/java/net/activitywatch/android/workers/NotifyWorker.kt @@ -170,14 +170,24 @@ internal interface NotifyBackend { fun checkServer() fun getSetting(key: String): String fun androidQuery(timeperiod: String): String + fun ingestLatestUsage() } private class RustNotifyBackend(context: Context) : NotifyBackend { + private val appContext = context.applicationContext private val ri = RustInterface(context) override fun checkServer() { ri.getBucketsJSON() } override fun getSetting(key: String): String = ri.getSetting(key) override fun androidQuery(timeperiod: String): String = ri.androidQuery(timeperiod) + + // Usage events otherwise reach the datastore only on the hourly (inexact) alarm, app + // open or widget refresh, so alerts were judged against data up to an hour old. + // processEventsSinceLastUpdate() skips if another ingestion run holds its lock. + override fun ingestLatestUsage() { + if (!UsageStatsWatcher.isUsageAllowed(appContext)) return + SessionEventWatcher(appContext).processEventsSinceLastUpdate() + } } class NotifyWorker internal constructor( @@ -202,13 +212,17 @@ class NotifyWorker internal constructor( return Result.retry() } - ingestLatestUsage() - return try { val alerts = alertsFromSetting(backend.getSetting("aw-notify")) // Keep the periodic worker scheduled so enabling later takes effect, // but never query activity or create notifications when disabled. if (alerts.isEmpty()) return Result.success() + try { + backend.ingestLatestUsage() + } catch (e: Exception) { + // Still check thresholds against what is already stored. + Log.w(TAG, "Failed to ingest usage events before checking thresholds", e) + } val zone = ZoneId.systemDefault() val startOfDayHour = parseStartOfDayHour(backend.getSetting("startOfDay")) val now = LocalDateTime.now(zone) @@ -221,18 +235,6 @@ class NotifyWorker internal constructor( } } - // Usage events otherwise reach the datastore only on the hourly (inexact) alarm, app - // open or widget refresh, so alerts were judged against data up to an hour old. - private fun ingestLatestUsage() { - if (!UsageStatsWatcher.isUsageAllowed(applicationContext)) return - try { - SessionEventWatcher(applicationContext).processEventsSinceLastUpdate() - } catch (e: Exception) { - // Still check thresholds against what is already stored. - Log.w(TAG, "Failed to ingest usage events before checking thresholds", e) - } - } - private fun getCategorySecondsToday( backend: NotifyBackend, now: LocalDateTime, diff --git a/mobile/src/test/java/net/activitywatch/android/workers/NotifyWorkerExecutionTest.kt b/mobile/src/test/java/net/activitywatch/android/workers/NotifyWorkerExecutionTest.kt index 369a41c6..df211ea0 100644 --- a/mobile/src/test/java/net/activitywatch/android/workers/NotifyWorkerExecutionTest.kt +++ b/mobile/src/test/java/net/activitywatch/android/workers/NotifyWorkerExecutionTest.kt @@ -19,7 +19,10 @@ import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) @Config(sdk = [28], application = Application::class) class NotifyWorkerExecutionTest { - private class FakeBackend(private val setting: String) : NotifyBackend { + private class FakeBackend( + private val setting: String, + private val failIngest: Boolean = false, + ) : NotifyBackend { val calls = mutableListOf() override fun checkServer() { @@ -35,6 +38,11 @@ class NotifyWorkerExecutionTest { calls += "query" return """[{"cat_events":[{"duration":120,"data":{"${'$'}category":["Work"]}}]}]""" } + + override fun ingestLatestUsage() { + calls += "ingest" + if (failIngest) throw IllegalStateException("ingest failed") + } } private fun runWorker(backend: FakeBackend): ListenableWorker.Result { @@ -92,7 +100,18 @@ class NotifyWorkerExecutionTest { """{"enabled":true,"alerts":[{"category":"Work","thresholds_minutes":[1]}]}""" ) assertEquals(ListenableWorker.Result.success(), runWorker(backend)) - assertEquals(listOf("server", "aw-notify", "startOfDay", "query"), backend.calls) + assertEquals(listOf("server", "aw-notify", "ingest", "startOfDay", "query"), backend.calls) + assertEquals(1, notificationCount()) + } + + @Test + fun ingestFailureStillChecksStoredData() { + val backend = FakeBackend( + """{"enabled":true,"alerts":[{"category":"Work","thresholds_minutes":[1]}]}""", + failIngest = true, + ) + assertEquals(ListenableWorker.Result.success(), runWorker(backend)) + assertEquals(listOf("server", "aw-notify", "ingest", "startOfDay", "query"), backend.calls) assertEquals(1, notificationCount()) } }