Skip to content
Merged
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 @@ -50,6 +50,7 @@ class RustInterface(context: Context? = null) {
external fun heartbeat(bucket_id: String, event: String, pulsetime: Double): String
external fun query(query: String, timeperiods: String): String
external fun androidQuery(timeperiods: String): String
external fun getSetting(key: String): String
external fun migrateHostname(hostname: String): String

fun sayHello(to: String): String {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import org.threeten.bp.ZoneId
import org.threeten.bp.format.DateTimeFormatter

private const val TAG = "CategoryTimeWidget"
private val DEFAULT_START_OF_DAY = LocalTime.of(4, 0) // matches aw-webui default "04:00"

// Bar chart dimensions
private const val BAR_WIDTH = 400
Expand All @@ -45,6 +44,8 @@ private val CATEGORY_ACCENT_COLORS = intArrayOf(
*/
object CategoryTimeWidgetUpdater {

private val DEFAULT_START_OF_DAY = LocalTime.of(4, 0) // matches aw-webui default "04:00"

// App row IDs
private val appRowIds = intArrayOf(
R.id.app_row_1,
Expand Down Expand Up @@ -231,30 +232,8 @@ object CategoryTimeWidgetUpdater {
}

/**
* Fetch the startOfDay boundary from the AW server settings API.
* Falls back to DEFAULT_START_OF_DAY if the server is unavailable or the setting is unset.
*/
private fun fetchStartOfDay(): LocalTime {
return try {
val url = java.net.URL("http://127.0.0.1:5600/api/0/settings/startOfDay")
val conn = url.openConnection() as java.net.HttpURLConnection
conn.connectTimeout = 1000
conn.readTimeout = 1000
if (conn.responseCode == 200) {
parseStartOfDay(conn.inputStream.bufferedReader().readText())
} else {
Log.d(TAG, "startOfDay setting unavailable (HTTP ${conn.responseCode}), using default")
DEFAULT_START_OF_DAY
}
} catch (e: Exception) {
Log.d(TAG, "Could not fetch startOfDay setting, using default: ${e.message}")
DEFAULT_START_OF_DAY
}
}

/**
* Parse the server's startOfDay JSON response into a LocalTime.
* Server returns null (unset) or a quoted string like "04:00" or "04:30".
* Parse the datastore's startOfDay JSON value into a LocalTime.
* Native settings return null (unset) or a quoted string like "04:00" or "04:30".
*/
internal fun parseStartOfDay(response: String): LocalTime {
val v = response.trim()
Expand All @@ -264,20 +243,25 @@ object CategoryTimeWidgetUpdater {
val parts = v.trim('"').split(":")
val hour = parts.getOrNull(0)?.toIntOrNull() ?: return DEFAULT_START_OF_DAY
val minute = parts.getOrNull(1)?.toIntOrNull() ?: 0
LocalTime.of(hour, minute)
try {
LocalTime.of(hour, minute)
} catch (e: Exception) {
DEFAULT_START_OF_DAY
}
}
else -> DEFAULT_START_OF_DAY
}
}

/**
* Query today's category times, using the same day boundary as aw-webui.
* Reads startOfDay from the AW server settings so the widget matches the Activity view.
* Reads startOfDay directly from the datastore so the widget matches the Activity view
* even when the authenticated HTTP server is unavailable.
*/
private fun getCategoryTimesToday(ri: RustInterface): List<Pair<String, Long>> {
val zone = ZoneId.systemDefault()
val formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME
val startOfDayTime = fetchStartOfDay()
val startOfDayTime = parseStartOfDay(ri.getSetting("startOfDay"))

// Match aw-webui's day boundary: if current time is before startOfDay we're still
// in the previous day's period (e.g. 3:45 AM with startOfDay=04:00 → "yesterday")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,27 @@ internal fun parseAlerts(json: String): List<CategoryAlert> {
}
}

internal fun alertsFromSetting(json: String): List<CategoryAlert> {
val value = json.trim()
if (value.isEmpty() || value == "null") return DEFAULT_ALERTS

return try {
parseAlerts(value).takeIf { it.isNotEmpty() } ?: DEFAULT_ALERTS
} catch (e: Exception) {
DEFAULT_ALERTS
}
}

internal fun parseStartOfDayHour(response: String): Int {
val value = response.trim()
val hour = when {
value == "null" -> null
value.startsWith("\"") -> value.trim('"').split(":").firstOrNull()?.toIntOrNull()
else -> value.toIntOrNull()
}
return hour?.takeIf { it in 0..23 } ?: DEFAULT_START_OF_DAY_HOUR
}

// Include thresholds in the pref key so state resets when configuration changes.
// A lowered threshold mid-day would otherwise be silently skipped because the old
// triggered value is higher than all new thresholds.
Expand All @@ -89,10 +110,10 @@ class NotifyWorker(context: Context, params: WorkerParameters) : Worker(context,

return try {
val zone = ZoneId.systemDefault()
val startOfDayHour = fetchStartOfDayHour()
val startOfDayHour = parseStartOfDayHour(ri.getSetting("startOfDay"))
val now = LocalDateTime.now(zone)
val categorySeconds = getCategorySecondsToday(ri, now, zone, startOfDayHour)
val alerts = fetchAlerts()
val alerts = alertsFromSetting(ri.getSetting("aw-notify"))
checkAndNotify(categorySeconds, logicalDayDate(now, startOfDayHour), alerts)
Result.success()
} catch (e: Exception) {
Expand Down Expand Up @@ -228,51 +249,6 @@ class NotifyWorker(context: Context, params: WorkerParameters) : Worker(context,
}
}

private fun fetchStartOfDayHour(): Int {
return try {
val url = java.net.URL("http://127.0.0.1:5600/api/0/settings/startOfDay")
val conn = url.openConnection() as java.net.HttpURLConnection
conn.connectTimeout = 1_000
conn.readTimeout = 1_000
if (conn.responseCode == 200) {
parseStartOfDayHour(conn.inputStream.bufferedReader().readText())
} else {
DEFAULT_START_OF_DAY_HOUR
}
} catch (e: Exception) {
DEFAULT_START_OF_DAY_HOUR
}
}

private fun fetchAlerts(): List<CategoryAlert> {
return try {
val url = java.net.URL("http://127.0.0.1:5600/api/0/settings/aw-notify")
val conn = url.openConnection() as java.net.HttpURLConnection
conn.connectTimeout = 1_000
conn.readTimeout = 1_000
if (conn.responseCode == 200) {
val text = conn.inputStream.bufferedReader().readText().trim()
if (text == "null" || text.isBlank()) DEFAULT_ALERTS
else parseAlerts(text).takeIf { it.isNotEmpty() } ?: DEFAULT_ALERTS
} else {
DEFAULT_ALERTS
}
} catch (e: Exception) {
Log.d(TAG, "Could not fetch alert config from server, using defaults")
DEFAULT_ALERTS
}
}

private fun parseStartOfDayHour(response: String): Int {
val v = response.trim()
return when {
v == "null" -> DEFAULT_START_OF_DAY_HOUR
v.startsWith("\"") -> v.trim('"').split(":").firstOrNull()?.toIntOrNull()
?: DEFAULT_START_OF_DAY_HOUR
else -> v.toIntOrNull() ?: DEFAULT_START_OF_DAY_HOUR
}
}

private fun formatDuration(minutes: Int): String {
val h = minutes / 60
val m = minutes % 60
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,24 @@ package net.activitywatch.android.widget

import org.junit.Assert.assertEquals
import org.junit.Test
import org.threeten.bp.LocalTime

class CategoryTimeWidgetUpdaterTest {

@Test
fun parseStartOfDay_readsMinutesFromNativeSetting() {
assertEquals(
LocalTime.of(4, 30),
CategoryTimeWidgetUpdater.parseStartOfDay("\"04:30\"")
)
}

@Test
fun parseStartOfDay_fallsBackForMissingOrInvalidSetting() {
assertEquals(LocalTime.of(4, 0), CategoryTimeWidgetUpdater.parseStartOfDay("null"))
assertEquals(LocalTime.of(4, 0), CategoryTimeWidgetUpdater.parseStartOfDay("\"99:00\""))
}

private fun catEvent(duration: Double, vararg category: String): String {
val cats = category.joinToString(",") { "\"$it\"" }
return """{"duration":$duration,"data":{"${'$'}category":[$cats]}}"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,30 @@ import org.threeten.bp.LocalDate
import org.threeten.bp.LocalDateTime

class NotifyWorkerTest {
@Test
fun parseStartOfDayHour_readsNativeSettingAndFallsBack() {
assertEquals(6, parseStartOfDayHour("\"06:00\""))
assertEquals(4, parseStartOfDayHour("null"))
assertEquals(4, parseStartOfDayHour("\"99:00\""))
}

@Test
fun alertsFromSetting_readsNativeSetting() {
val alerts = alertsFromSetting(
"""[{"category":"Work","label":"Focus","thresholdMinutes":[30]}]"""
)

assertEquals(1, alerts.size)
assertEquals("Focus", alerts[0].label)
assertEquals(listOf(30), alerts[0].thresholdMinutes)
}

@Test
fun alertsFromSetting_fallsBackForMissingOrInvalidSetting() {
assertEquals("All", alertsFromSetting("null")[0].label)
assertEquals("All", alertsFromSetting("not-json")[0].label)
}

@Test
fun logicalDayDate_usesPreviousDateBeforeConfiguredBoundary() {
val now = LocalDateTime.of(2026, 7, 24, 3, 59)
Expand Down
Loading