From 33f360bab7e2dee7550622f5f7255ee9c0a0363a Mon Sep 17 00:00:00 2001 From: Bob Date: Mon, 31 Aug 2026 19:48:02 +0000 Subject: [PATCH] fix(android): open Activity view with device hostname, not unknown The left-swipe drawer and notification taps hardcoded /#/activity/unknown/, so visualizations used desktop defaults for a host with no Android buckets. The in-app hamburger already built /activity/ from buckets. Use the same sanitized device hostname as bucket creation. --- .../activitywatch/android/DeviceHostname.kt | 28 ++++++++++++++++++ .../net/activitywatch/android/MainActivity.kt | 21 +++++++++----- .../activitywatch/android/RustInterface.kt | 14 +-------- .../activitywatch/android/SyncInterface.kt | 13 +-------- .../android/DeviceHostnameTest.kt | 21 ++++++++++++++ .../android/MainActivityNavigationTest.kt | 29 +++++++++++++++++-- 6 files changed, 91 insertions(+), 35 deletions(-) create mode 100644 mobile/src/main/java/net/activitywatch/android/DeviceHostname.kt create mode 100644 mobile/src/test/java/net/activitywatch/android/DeviceHostnameTest.kt diff --git a/mobile/src/main/java/net/activitywatch/android/DeviceHostname.kt b/mobile/src/main/java/net/activitywatch/android/DeviceHostname.kt new file mode 100644 index 00000000..1c57897e --- /dev/null +++ b/mobile/src/main/java/net/activitywatch/android/DeviceHostname.kt @@ -0,0 +1,28 @@ +package net.activitywatch.android + +import android.content.Context +import android.provider.Settings +import java.util.Locale + +/** + * Hostname used for Android buckets, sync, and the Activity webui route. + * + * Sentinel `"unknown"` is a valid stored hostname (stopwatch, unsynced rows) but a + * terrible default: `/#/activity/unknown/` loads desktop visualizations for a host + * that has no `aw-watcher-android_*` buckets. + */ +internal fun sanitizeDeviceHostname(raw: String?): String { + val value = raw?.trim()?.takeIf { it.isNotEmpty() } ?: return "unknown" + return value + .lowercase(Locale.ROOT) + .replace(Regex("[^a-z0-9_-]+"), "_") + .trim('_') + .ifEmpty { "unknown" } +} + +internal fun deviceHostname(context: Context): String { + val named = Settings.Global.getString(context.contentResolver, Settings.Global.DEVICE_NAME) + ?.trim() + ?.takeIf { it.isNotEmpty() } + return sanitizeDeviceHostname(named ?: android.os.Build.DEVICE) +} diff --git a/mobile/src/main/java/net/activitywatch/android/MainActivity.kt b/mobile/src/main/java/net/activitywatch/android/MainActivity.kt index 9bf60e4e..967af7c0 100644 --- a/mobile/src/main/java/net/activitywatch/android/MainActivity.kt +++ b/mobile/src/main/java/net/activitywatch/android/MainActivity.kt @@ -32,13 +32,18 @@ const val baseURL = "http://127.0.0.1:5600" // Same destination as the drawer "Activity" item. Notification taps set // EXTRA_OPEN_ACTIVITY_VIEW so we land here instead of dashboard home. -const val ACTIVITY_VIEW_ROUTE = "/#/activity/unknown/" +// Hostname must match the sanitized device name used for Android buckets — +// `/#/activity/unknown/` is a sentinel, not a host (see DeviceHostname.kt). const val EXTRA_OPEN_ACTIVITY_VIEW = "net.activitywatch.android.extra.OPEN_ACTIVITY_VIEW" -internal fun activityViewUrl(baseUrl: String = baseURL): String = "$baseUrl$ACTIVITY_VIEW_ROUTE" +internal fun activityViewUrl(hostname: String, baseUrl: String = baseURL): String = + "$baseUrl/#/activity/${sanitizeDeviceHostname(hostname)}/" -internal fun initialWebUiUrl(openActivityView: Boolean, baseUrl: String = baseURL): String = - if (openActivityView) activityViewUrl(baseUrl) else baseUrl +internal fun initialWebUiUrl( + openActivityView: Boolean, + hostname: String, + baseUrl: String = baseURL, +): String = if (openActivityView) activityViewUrl(hostname, baseUrl) else baseUrl internal fun shouldOpenActivityViewImmediately(openActivityView: Boolean, isResumed: Boolean): Boolean = openActivityView && isResumed @@ -135,7 +140,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte // home before onResume. Consume the extra here; onResume is the path // for reused instances (onNewIntent) and process-death restore. val openActivityView = intent.getBooleanExtra(EXTRA_OPEN_ACTIVITY_VIEW, false) - showWebUi(initialWebUiUrl(openActivityView), replace = false) + showWebUi(initialWebUiUrl(openActivityView, deviceHostname(this)), replace = false) if (openActivityView) { intent.removeExtra(EXTRA_OPEN_ACTIVITY_VIEW) } @@ -176,9 +181,11 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte transaction.commit() } + private fun activityViewUrlForDevice(): String = activityViewUrl(deviceHostname(this)) + private fun openPendingActivityView() { if (takeOpenActivityView(intent)) { - showWebUi(activityViewUrl(), replace = true) + showWebUi(activityViewUrlForDevice(), replace = true) } } @@ -232,7 +239,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte } R.id.nav_activity -> { fragmentClass = WebUIFragment::class.java - url = authenticatedUrl(activityViewUrl()) + url = authenticatedUrl(activityViewUrlForDevice()) } R.id.nav_buckets -> { fragmentClass = WebUIFragment::class.java diff --git a/mobile/src/main/java/net/activitywatch/android/RustInterface.kt b/mobile/src/main/java/net/activitywatch/android/RustInterface.kt index 29244ec3..e02b4678 100644 --- a/mobile/src/main/java/net/activitywatch/android/RustInterface.kt +++ b/mobile/src/main/java/net/activitywatch/android/RustInterface.kt @@ -4,7 +4,6 @@ import android.content.Context import android.content.pm.PackageManager import android.os.Handler import android.os.Looper -import android.provider.Settings import android.system.Os import android.util.Log import java.util.concurrent.Executors @@ -201,17 +200,6 @@ class RustInterface(context: Context? = null) { } } - fun getDeviceName(context: Context): String { - val raw = Settings.Global.getString(context.contentResolver, Settings.Global.DEVICE_NAME) - ?.trim() - ?.takeIf { it.isNotEmpty() } - ?: android.os.Build.DEVICE - ?: "unknown" - return raw.trim() - .lowercase(java.util.Locale.ROOT) - .replace(Regex("[^a-z0-9_-]+"), "_") - .trim('_') - .ifEmpty { "unknown" } - } + fun getDeviceName(context: Context): String = deviceHostname(context) } diff --git a/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt b/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt index 0f914f07..76dea58a 100644 --- a/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt +++ b/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt @@ -78,18 +78,7 @@ class SyncInterface(context: Context) { private external fun syncBoth(port: Int, hostname: String): String external fun getSyncDir(): String - private fun getDeviceName(): String { - val raw = android.provider.Settings.Global.getString( - appContext.contentResolver, - android.provider.Settings.Global.DEVICE_NAME - )?.trim()?.takeIf { it.isNotEmpty() } - ?: android.os.Build.DEVICE ?: "unknown" - return raw.trim() - .lowercase(java.util.Locale.ROOT) - .replace(Regex("[^a-z0-9_-]+"), "_") - .trim('_') - .ifEmpty { "unknown" } - } + private fun getDeviceName(): String = deviceHostname(appContext) // Async wrapper for syncPullAll fun syncPullAllAsync(callback: (Boolean, String) -> Unit) { diff --git a/mobile/src/test/java/net/activitywatch/android/DeviceHostnameTest.kt b/mobile/src/test/java/net/activitywatch/android/DeviceHostnameTest.kt new file mode 100644 index 00000000..16142602 --- /dev/null +++ b/mobile/src/test/java/net/activitywatch/android/DeviceHostnameTest.kt @@ -0,0 +1,21 @@ +package net.activitywatch.android + +import org.junit.Assert.assertEquals +import org.junit.Test + +class DeviceHostnameTest { + @Test + fun sanitizeDeviceHostname_fallsBackToUnknown() { + assertEquals("unknown", sanitizeDeviceHostname(null)) + assertEquals("unknown", sanitizeDeviceHostname("")) + assertEquals("unknown", sanitizeDeviceHostname(" ")) + assertEquals("unknown", sanitizeDeviceHostname("***")) + } + + @Test + fun sanitizeDeviceHostname_lowercasesAndCollapses() { + assertEquals("pixel_8", sanitizeDeviceHostname("Pixel 8")) + assertEquals("my-phone_1", sanitizeDeviceHostname("My-Phone_1")) + assertEquals("pixel_8", sanitizeDeviceHostname(" Pixel 8 ")) + } +} diff --git a/mobile/src/test/java/net/activitywatch/android/MainActivityNavigationTest.kt b/mobile/src/test/java/net/activitywatch/android/MainActivityNavigationTest.kt index 5c4ad286..8048073e 100644 --- a/mobile/src/test/java/net/activitywatch/android/MainActivityNavigationTest.kt +++ b/mobile/src/test/java/net/activitywatch/android/MainActivityNavigationTest.kt @@ -8,14 +8,37 @@ import org.junit.Test class MainActivityNavigationTest { @Test fun initialWebUiUrl_defaultsToDashboardHome() { - assertEquals(baseURL, initialWebUiUrl(openActivityView = false)) + assertEquals( + baseURL, + initialWebUiUrl(openActivityView = false, hostname = "pixel_8"), + ) + } + + @Test + fun initialWebUiUrl_opensActivityNavDestinationForDeviceHost() { + assertEquals( + "$baseURL/#/activity/pixel_8/", + initialWebUiUrl(openActivityView = true, hostname = "pixel_8"), + ) } @Test - fun initialWebUiUrl_opensActivityNavDestination() { + fun activityViewUrl_sanitizesRawDeviceName() { + assertEquals( + "$baseURL/#/activity/pixel_8/", + activityViewUrl("Pixel 8"), + ) + } + + @Test + fun activityViewUrl_unknownIsFallbackNotDefault() { + assertEquals( + "$baseURL/#/activity/unknown/", + activityViewUrl("unknown"), + ) assertEquals( "$baseURL/#/activity/unknown/", - initialWebUiUrl(openActivityView = true), + activityViewUrl(""), ) }