diff --git a/aw-server-rust b/aw-server-rust index dc70318e..0c8b2adf 160000 --- a/aw-server-rust +++ b/aw-server-rust @@ -1 +1 @@ -Subproject commit dc70318e819efc0d0535a5d7bd35a0c7ab8e9106 +Subproject commit 0c8b2adf6dfa39e5d74d05df9466b8e7efd7f171 diff --git a/mobile/src/main/java/net/activitywatch/android/DashboardAuth.kt b/mobile/src/main/java/net/activitywatch/android/DashboardAuth.kt new file mode 100644 index 00000000..e86b218c --- /dev/null +++ b/mobile/src/main/java/net/activitywatch/android/DashboardAuth.kt @@ -0,0 +1,140 @@ +package net.activitywatch.android + +import android.content.Context +import java.io.File +import java.net.URI +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.util.UUID + +private const val CONFIG_FILE_NAME = "config.toml" +private const val AUTH_SECTION = "auth" + +private val sectionHeaderPattern = Regex("""^\s*\[([^\]]+)]\s*(?:#.*)?$""") +private val apiKeyPattern = Regex("""^\s*api_key\s*=\s*(['"])(.*?)\1\s*(?:#.*)?$""") + +fun ensureDashboardApiKey(context: Context): String { + val configFile = File(context.filesDir, CONFIG_FILE_NAME) + val currentConfig = if (configFile.isFile) configFile.readText() else "" + val existingApiKey = extractApiKey(currentConfig) + if (existingApiKey != null) { + return existingApiKey + } + + val generatedApiKey = UUID.randomUUID().toString() + configFile.writeText(upsertApiKey(currentConfig, generatedApiKey)) + return generatedApiKey +} + +internal fun buildDashboardUrl(baseUrl: String, apiKey: String?): String { + val normalizedApiKey = apiKey?.trim().orEmpty() + if (normalizedApiKey.isEmpty()) { + return baseUrl + } + val parsedUrl = URI(baseUrl) + val queryParts = mutableListOf() + + if (!parsedUrl.rawQuery.isNullOrBlank()) { + queryParts.add(parsedUrl.rawQuery) + } + queryParts.add( + "token=${URLEncoder.encode(normalizedApiKey, StandardCharsets.UTF_8.name())}" + ) + + val normalizedPath = parsedUrl.path?.takeIf { it.isNotEmpty() } ?: "/" + return URI( + parsedUrl.scheme, + parsedUrl.authority, + normalizedPath, + queryParts.joinToString("&"), + parsedUrl.fragment, + ).toASCIIString() +} + +internal fun extractApiKey(config: String): String? { + var inAuthSection = false + + for (line in config.lineSequence()) { + val trimmed = line.trim() + if (trimmed.startsWith("#")) { + continue + } + + val sectionName = parseSectionName(trimmed) + if (sectionName != null) { + inAuthSection = sectionName == AUTH_SECTION + continue + } + + if (!inAuthSection) { + continue + } + + val apiKeyMatch = apiKeyPattern.matchEntire(trimmed) ?: continue + val apiKey = apiKeyMatch.groupValues[2].trim() + if (apiKey.isNotEmpty()) { + return apiKey + } + } + + return null +} + +internal fun upsertApiKey(config: String, apiKey: String): String { + val escapedApiKey = escapeTomlString(apiKey) + val renderedApiKey = """api_key = "$escapedApiKey"""" + val result = mutableListOf() + var inAuthSection = false + var insertedApiKey = false + + for (line in config.lineSequence()) { + val trimmed = line.trim() + val sectionName = parseSectionName(trimmed) + + if (sectionName != null) { + if (inAuthSection && !insertedApiKey) { + result.add(renderedApiKey) + insertedApiKey = true + } + inAuthSection = sectionName == AUTH_SECTION + result.add(line) + continue + } + + if (inAuthSection && apiKeyPattern.matchEntire(trimmed) != null) { + if (!insertedApiKey) { + result.add(renderedApiKey) + insertedApiKey = true + } + continue + } + + result.add(line) + } + + if (inAuthSection && !insertedApiKey) { + result.add(renderedApiKey) + insertedApiKey = true + } + + if (!insertedApiKey) { + if (result.isNotEmpty() && result.last().isNotBlank()) { + result.add("") + } + result.add("[auth]") + result.add(renderedApiKey) + } + + return result.joinToString("\n").let { rendered -> + if (rendered.endsWith("\n")) rendered else "$rendered\n" + } +} + +private fun escapeTomlString(value: String): String { + return value.replace("\\", "\\\\").replace("\"", "\\\"") +} + +private fun parseSectionName(line: String): String? { + val match = sectionHeaderPattern.matchEntire(line) ?: return null + return match.groupValues[1].trim().lowercase() +} diff --git a/mobile/src/main/java/net/activitywatch/android/MainActivity.kt b/mobile/src/main/java/net/activitywatch/android/MainActivity.kt index 0c439e0f..21684767 100644 --- a/mobile/src/main/java/net/activitywatch/android/MainActivity.kt +++ b/mobile/src/main/java/net/activitywatch/android/MainActivity.kt @@ -24,12 +24,21 @@ const val baseURL = "http://127.0.0.1:5600" class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener, WebUIFragment.OnFragmentInteractionListener { private lateinit var binding: ActivityMainBinding + private lateinit var dashboardApiKey: String val version: String get() { return packageManager.getPackageInfo(packageName, 0).versionName } + private fun authenticatedUrl(url: String = baseURL): String { + return buildDashboardUrl(url, dashboardApiKey) + } + + private fun openDashboardInBrowser(url: String = baseURL) { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(authenticatedUrl(url)))) + } + override fun onFragmentInteraction(item: Uri) { Log.w(TAG, "URI onInteraction listener not implemented") } @@ -57,12 +66,13 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte binding.navView.setNavigationItemSelectedListener(this) val ri = RustInterface(this) + dashboardApiKey = ensureDashboardApiKey(this) ri.startServerTask(this) if (savedInstanceState != null) { return } - val firstFragment = WebUIFragment.newInstance(baseURL) + val firstFragment = WebUIFragment.newInstance(authenticatedUrl()) supportFragmentManager.beginTransaction() .add(R.id.fragment_container, firstFragment).commit() } @@ -115,19 +125,18 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte } R.id.nav_activity -> { fragmentClass = WebUIFragment::class.java - url = "$baseURL/#/activity/unknown/" + url = authenticatedUrl("$baseURL/#/activity/unknown/") } R.id.nav_buckets -> { fragmentClass = WebUIFragment::class.java - url = "$baseURL/#/buckets/" + url = authenticatedUrl("$baseURL/#/buckets/") } R.id.nav_settings -> { fragmentClass = WebUIFragment::class.java - url = "$baseURL/#/settings/" + url = authenticatedUrl("$baseURL/#/settings/") } R.id.nav_share -> { - Snackbar.make(binding.coordinatorLayout, "The share button was clicked, but it's not yet implemented!", Snackbar.LENGTH_LONG) - .setAction("Action", null).show() + openDashboardInBrowser() } R.id.nav_send -> { Snackbar.make(binding.coordinatorLayout, "The send button was clicked, but it's not yet implemented!", Snackbar.LENGTH_LONG) diff --git a/mobile/src/main/res/menu/activity_main_drawer.xml b/mobile/src/main/res/menu/activity_main_drawer.xml index c5db4b3e..99949b63 100644 --- a/mobile/src/main/res/menu/activity_main_drawer.xml +++ b/mobile/src/main/res/menu/activity_main_drawer.xml @@ -28,7 +28,7 @@ + android:title="@string/open_in_browser"/> v0.1.0 Navigation header Settings + Open in browser BucketsActivity Welcome, early user! diff --git a/mobile/src/test/java/net/activitywatch/android/DashboardAuthTest.kt b/mobile/src/test/java/net/activitywatch/android/DashboardAuthTest.kt new file mode 100644 index 00000000..40df47fe --- /dev/null +++ b/mobile/src/test/java/net/activitywatch/android/DashboardAuthTest.kt @@ -0,0 +1,72 @@ +package net.activitywatch.android + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class DashboardAuthTest { + @Test + fun buildDashboardUrl_skipsTokenWhenAuthDisabled() { + assertEquals("http://127.0.0.1:5600", buildDashboardUrl(baseURL, null)) + } + + @Test + fun buildDashboardUrl_appendsEncodedTokenBeforeHashRoute() { + assertEquals( + "http://127.0.0.1:5600/?token=secret%2B+%2F%3F%3D%26#/activity/unknown/", + buildDashboardUrl( + "$baseURL/#/activity/unknown/", + "secret+ /?=&", + ), + ) + } + + @Test + fun extractApiKey_readsExistingAuthSection() { + val config = """ + address = "127.0.0.1" + + [auth] + api_key = "existing-token" + """.trimIndent() + + assertEquals("existing-token", extractApiKey(config)) + } + + @Test + fun extractApiKey_ignoresCommentedDefaults() { + val config = """ + ### DEFAULT SETTINGS ### + #[auth] + #api_key = "commented" + """.trimIndent() + + assertNull(extractApiKey(config)) + } + + @Test + fun upsertApiKey_appendsAuthSectionWhenMissing() { + val updated = upsertApiKey("address = \"127.0.0.1\"\n", "generated-token") + + assertEquals( + "address = \"127.0.0.1\"\n\n[auth]\napi_key = \"generated-token\"\n", + updated, + ) + } + + @Test + fun upsertApiKey_replacesExistingValueWithoutDroppingOtherSections() { + val config = """ + [auth] + api_key = "old-token" + + [custom_static] + test = "/tmp/static" + """.trimIndent() + + assertEquals( + "[auth]\napi_key = \"new-token\"\n\n[custom_static]\ntest = \"/tmp/static\"\n", + upsertApiKey(config, "new-token"), + ) + } +}