Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<String?, Double> {
val categories = mutableMapOf<String?, Double>()
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 {
Expand Down Expand Up @@ -118,36 +166,6 @@ class NotifyWorker(context: Context, params: WorkerParameters) : Worker(context,
return parseCategorySeconds(ri.androidQuery(timeperiod))
}

private fun parseCategorySeconds(jsonResult: String): Map<String?, Double> {
val categories = mutableMapOf<String?, Double>()
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<String?, Double>,
logicalDate: LocalDate,
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added mobile/src/main/res/drawable/widget_preview_bar.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
211 changes: 211 additions & 0 deletions mobile/src/main/res/layout/widget_category_time_preview.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Static sample used as android:previewLayout (API 31+).
Mirrors widget_category_time with realistic placeholder values so the
widget picker doesn't show the live layout's 0h 0m / "App 1" defaults.
The bar chart ImageView is filled at runtime in the live widget; here it
uses a static stacked-bar drawable. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:background="@drawable/widget_background"
android:padding="16dp"
android:gravity="top">

<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">

<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Screen time"
android:textColor="@color/widget_text_primary"
android:textSize="14sp"
android:textStyle="bold" />

<ImageView
android:layout_width="18dp"
android:layout_height="18dp"
android:src="@drawable/ic_open"
android:tint="@color/widget_text_muted"
android:contentDescription="@null" />

</LinearLayout>

<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:baselineAligned="false">

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="4"
android:textColor="@color/widget_text_primary"
android:textSize="36sp"
android:textStyle="bold" />

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="h"
android:textColor="@color/widget_text_primary"
android:textSize="18sp"
android:layout_marginStart="2dp"
android:layout_marginEnd="8dp"
android:layout_gravity="bottom"
android:paddingBottom="6dp" />

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="23"
android:textColor="@color/widget_text_primary"
android:textSize="36sp"
android:textStyle="bold" />

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="m"
android:textColor="@color/widget_text_primary"
android:textSize="18sp"
android:layout_marginStart="2dp"
android:layout_gravity="bottom"
android:paddingBottom="6dp" />
</LinearLayout>
</LinearLayout>

<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1.2"
android:orientation="vertical"
android:paddingStart="12dp">

<ImageView
android:layout_width="match_parent"
android:layout_height="12dp"
android:layout_marginBottom="2dp"
android:src="@drawable/widget_preview_bar"
android:scaleType="fitXY"
android:contentDescription="@null" />

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="1dp"
android:gravity="center_vertical">

<ImageView
android:layout_width="8dp"
android:layout_height="8dp"
android:src="@drawable/widget_dot_1"
android:contentDescription="@null" />

<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Work"
android:textColor="@color/widget_text_tertiary"
android:textSize="12sp"
android:layout_marginStart="6dp"
android:ellipsize="end"
android:maxLines="1" />

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="2h 15m"
android:textColor="@color/widget_text_primary"
android:textSize="12sp" />
</LinearLayout>

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="1dp"
android:gravity="center_vertical">

<ImageView
android:layout_width="8dp"
android:layout_height="8dp"
android:src="@drawable/widget_dot_2"
android:contentDescription="@null" />

<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Media"
android:textColor="@color/widget_text_tertiary"
android:textSize="12sp"
android:layout_marginStart="6dp"
android:ellipsize="end"
android:maxLines="1" />

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="1h 8m"
android:textColor="@color/widget_text_primary"
android:textSize="12sp" />
</LinearLayout>

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="1dp"
android:gravity="center_vertical">

<ImageView
android:layout_width="8dp"
android:layout_height="8dp"
android:src="@drawable/widget_dot_3"
android:contentDescription="@null" />

<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Uncategorized"
android:textColor="@color/widget_text_tertiary"
android:textSize="12sp"
android:layout_marginStart="6dp"
android:ellipsize="end"
android:maxLines="1" />

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="42m"
android:textColor="@color/widget_text_primary"
android:textSize="12sp" />
</LinearLayout>

<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="end"
android:text="8/24, 12:00"
android:textColor="@color/widget_text_muted"
android:textSize="11sp" />

</LinearLayout>
</LinearLayout>
7 changes: 6 additions & 1 deletion mobile/src/main/res/xml/category_time_widget_info.xml
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- previewLayout (API 31+) renders the sample layout in the widget picker.
previewImage is the fallback for minSdk 26–30. Both are required: see
https://developer.android.com/develop/ui/views/appwidgets/previews -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="40dp"
android:updatePeriodMillis="1800000"
android:initialLayout="@layout/widget_category_time"
android:previewLayout="@layout/widget_category_time_preview"
android:previewImage="@drawable/widget_category_time_preview"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:description="@string/widget_category_time_description" />
android:description="@string/widget_category_time_description" />
Original file line number Diff line number Diff line change
Expand Up @@ -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<String?, Double>(), parseCategorySeconds("[]"))
}
}
Loading