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 94425dca..0268dc7f 100644 --- a/mobile/src/main/java/net/activitywatch/android/workers/NotifyWorker.kt +++ b/mobile/src/main/java/net/activitywatch/android/workers/NotifyWorker.kt @@ -71,6 +71,54 @@ internal fun alertConfigHash(alert: CategoryAlert): Int = (alert.thresholdMinutes.toString() + alert.positive.toString()) .hashCode().and(0x3FFFFFFF) +/** + * Parse category durations from the androidQuery response. + * + * Groups by `$category[0]` (top-level only). This is intentional, not a leftover + * of the widget grouping that #231 briefly changed to full-path. + * + * Alert keys ([CategoryAlert.category]) are top-level names like `"Work"` / + * `"YouTube"` — the same shape as [DEFAULT_ALERTS] and as desktop aw-notify's + * AllLevels *parent* key. A "Work" alert means "notify me after 2h of Work", + * which must include Work > Coding, Work > Planning, etc. Matching on the + * full path would silently stop those default alerts from firing. + * + * Nested-path alerts (`"Work > Programming"`) are not supported on Android; + * desktop aw-notify AllLevels aggregation would be needed for that. Don't + * "fix" this to match the widget's full-path experiment in #231. + * + * See ActivityWatch/aw-android#231 (widget grouping) and #142. + */ +internal fun parseCategorySeconds(jsonResult: String): Map { + val categories = mutableMapOf() + var totalDuration = 0.0 + + try { + val resultArray = JSONArray(jsonResult) + if (resultArray.length() == 0) return emptyMap() + + val periodResult = resultArray.getJSONObject(0) + val catEvents = periodResult.optJSONArray("cat_events") ?: return emptyMap() + + for (i in 0 until catEvents.length()) { + val event = catEvents.getJSONObject(i) + val duration = event.optDouble("duration", 0.0) + val data = event.optJSONObject("data") ?: continue + val categoryArray = data.optJSONArray("\$category") + if (categoryArray == null || categoryArray.length() == 0) continue + + val topLevel = categoryArray.optString(0, "Uncategorized") + categories[topLevel] = (categories[topLevel] ?: 0.0) + duration + totalDuration += duration + } + } catch (e: Exception) { + Log.e(TAG, "Error parsing category JSON", e) + } + + categories[null] = totalDuration // aggregate "All" key + return categories +} + class NotifyWorker(context: Context, params: WorkerParameters) : Worker(context, params) { override fun doWork(): Result { @@ -118,36 +166,6 @@ class NotifyWorker(context: Context, params: WorkerParameters) : Worker(context, return parseCategorySeconds(ri.androidQuery(timeperiod)) } - private fun parseCategorySeconds(jsonResult: String): Map { - val categories = mutableMapOf() - var totalDuration = 0.0 - - try { - val resultArray = JSONArray(jsonResult) - if (resultArray.length() == 0) return emptyMap() - - val periodResult = resultArray.getJSONObject(0) - val catEvents = periodResult.optJSONArray("cat_events") ?: return emptyMap() - - for (i in 0 until catEvents.length()) { - val event = catEvents.getJSONObject(i) - val duration = event.optDouble("duration", 0.0) - val data = event.optJSONObject("data") ?: continue - val categoryArray = data.optJSONArray("\$category") - if (categoryArray == null || categoryArray.length() == 0) continue - - val topLevel = categoryArray.optString(0, "Uncategorized") - categories[topLevel] = (categories[topLevel] ?: 0.0) + duration - totalDuration += duration - } - } catch (e: Exception) { - Log.e(TAG, "Error parsing category JSON", e) - } - - categories[null] = totalDuration // aggregate "All" key - return categories - } - private fun checkAndNotify( categorySeconds: Map, logicalDate: LocalDate, diff --git a/mobile/src/main/res/drawable-nodpi/widget_category_time_preview.png b/mobile/src/main/res/drawable-nodpi/widget_category_time_preview.png new file mode 100644 index 00000000..5f3f5812 Binary files /dev/null and b/mobile/src/main/res/drawable-nodpi/widget_category_time_preview.png differ diff --git a/mobile/src/main/res/drawable/widget_preview_bar.png b/mobile/src/main/res/drawable/widget_preview_bar.png new file mode 100644 index 00000000..fa0e873c Binary files /dev/null and b/mobile/src/main/res/drawable/widget_preview_bar.png differ diff --git a/mobile/src/main/res/layout/widget_category_time_preview.xml b/mobile/src/main/res/layout/widget_category_time_preview.xml new file mode 100644 index 00000000..ad0134b6 --- /dev/null +++ b/mobile/src/main/res/layout/widget_category_time_preview.xml @@ -0,0 +1,211 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/src/main/res/xml/category_time_widget_info.xml b/mobile/src/main/res/xml/category_time_widget_info.xml index 8cc801a5..e2681584 100644 --- a/mobile/src/main/res/xml/category_time_widget_info.xml +++ b/mobile/src/main/res/xml/category_time_widget_info.xml @@ -1,9 +1,14 @@ + \ No newline at end of file + android:description="@string/widget_category_time_description" /> 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 6c286869..7950b6e9 100644 --- a/mobile/src/test/java/net/activitywatch/android/workers/NotifyWorkerTest.kt +++ b/mobile/src/test/java/net/activitywatch/android/workers/NotifyWorkerTest.kt @@ -74,4 +74,66 @@ class NotifyWorkerTest { "Changing positive flag must change the pref-key hash" } } + + private fun catEvent(duration: Double, vararg category: String): String { + val cats = category.joinToString(",") { "\"$it\"" } + return """{"duration":$duration,"data":{"${'$'}category":[$cats]}}""" + } + + private fun response(vararg events: String) = + """[{"cat_events":[${events.joinToString(",")}]}]""" + + /** + * NotifyWorker groups by `$category[0]` so a "Work" alert includes every + * Work subcategory. This is the intended match against [CategoryAlert.category] + * (top-level names in DEFAULT_ALERTS). See ActivityWatch/aw-android#231. + */ + @Test + fun parseCategorySeconds_rollsSubcategoriesIntoTopLevel() { + val result = parseCategorySeconds( + response( + catEvent(60.0, "Work", "Programming"), + catEvent(30.0, "Work", "Planning") + ) + ) + + assertEquals(90.0, result["Work"]!!, 0.0) + assertEquals(90.0, result[null]!!, 0.0) + assertNull(result["Work > Programming"]) + assertNull(result["Programming"]) + } + + @Test + fun parseCategorySeconds_keysMatchDefaultAlertCategories() { + val result = parseCategorySeconds( + response( + catEvent(120.0, "Work", "Coding"), + catEvent(30.0, "YouTube"), + catEvent(15.0, "Twitter", "Web") + ) + ) + + assertEquals(120.0, result["Work"]!!, 0.0) + assertEquals(30.0, result["YouTube"]!!, 0.0) + assertEquals(15.0, result["Twitter"]!!, 0.0) + assertEquals(165.0, result[null]!!, 0.0) + } + + @Test + fun parseCategorySeconds_collapsesUncategorizedSubcategories() { + val result = parseCategorySeconds( + response( + catEvent(20.0, "Uncategorized", "Browser"), + catEvent(10.0, "Uncategorized", "Games") + ) + ) + + assertEquals(30.0, result["Uncategorized"]!!, 0.0) + assertEquals(1, result.keys.filterNotNull().size) + } + + @Test + fun parseCategorySeconds_returnsEmptyForEmptyResult() { + assertEquals(emptyMap(), parseCategorySeconds("[]")) + } }