From 39cf182196c10fef70d364e01a4e4197e908071e Mon Sep 17 00:00:00 2001 From: Bob Date: Thu, 21 May 2026 19:06:58 +0000 Subject: [PATCH 01/19] feat(auth): add API key settings screen and config manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the Android-side UI for API authentication, now that aw-server-rust#608 landed and embedded config loads from app data dir. - ConfigManager.kt: reads/writes [auth].api_key in config.toml stored at context.filesDir; uses UUID-based key generation; pure string TOML manipulation, no extra library dependency - AuthSettingsActivity.kt: shows current API key, copy-to-clipboard, regenerate, and enable/disable toggle; prompts user to restart app after changes (server rereads config at startup) - activity_auth_settings.xml: layout for the settings screen - AndroidManifest.xml: registers AuthSettingsActivity - MainActivity.kt: wires the previously-stub settings button to open AuthSettingsActivity instead of a Snackbar - ConfigManagerTest.kt: 11 JVM unit tests covering parse/write logic including roundtrips and the [auth]-without-key edge case Closes ActivityWatch/aw-android#145 (partial — "Open in browser" URL token passing and server restart API are follow-up work) --- mobile/src/main/AndroidManifest.xml | 6 + .../android/AuthSettingsActivity.kt | 102 +++++++++++++++ .../activitywatch/android/ConfigManager.kt | 100 +++++++++++++++ .../net/activitywatch/android/MainActivity.kt | 3 +- .../res/layout/activity_auth_settings.xml | 100 +++++++++++++++ .../android/ConfigManagerTest.kt | 121 ++++++++++++++++++ 6 files changed, 430 insertions(+), 2 deletions(-) create mode 100644 mobile/src/main/java/net/activitywatch/android/AuthSettingsActivity.kt create mode 100644 mobile/src/main/java/net/activitywatch/android/ConfigManager.kt create mode 100644 mobile/src/main/res/layout/activity_auth_settings.xml create mode 100644 mobile/src/test/java/net/activitywatch/android/ConfigManagerTest.kt diff --git a/mobile/src/main/AndroidManifest.xml b/mobile/src/main/AndroidManifest.xml index c983de3a..d4653812 100644 --- a/mobile/src/main/AndroidManifest.xml +++ b/mobile/src/main/AndroidManifest.xml @@ -48,6 +48,12 @@ android:theme="@style/AppTheme.NoActionBar" android:exported="true"/> + + + if (isChecked) { + // Enable auth — generate a key if none exists + val current = configManager.readAuthConfig() + if (!current.isEnabled) { + val newKey = configManager.generateAndSetApiKey() + tvApiKey.text = newKey + } + tvStatus.text = "Authentication enabled (restart app to apply)" + } else { + configManager.clearApiKey() + tvApiKey.text = "(none)" + tvStatus.text = "Authentication disabled (restart app to apply)" + } + Toast.makeText(this, "Setting saved. Restart app to apply.", Toast.LENGTH_SHORT).show() + } + } + + private fun refreshUI() { + val auth = configManager.readAuthConfig() + if (auth.isEnabled) { + tvApiKey.text = auth.apiKey + tvStatus.text = "Authentication is enabled" + switchAuthEnabled.isChecked = true + btnCopy.visibility = View.VISIBLE + } else { + tvApiKey.text = "(none — authentication disabled)" + tvStatus.text = "Authentication is disabled" + switchAuthEnabled.isChecked = false + btnCopy.visibility = View.GONE + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + android.R.id.home -> { + onBackPressedDispatcher.onBackPressed() + true + } + else -> super.onOptionsItemSelected(item) + } + } +} diff --git a/mobile/src/main/java/net/activitywatch/android/ConfigManager.kt b/mobile/src/main/java/net/activitywatch/android/ConfigManager.kt new file mode 100644 index 00000000..1e4c49fb --- /dev/null +++ b/mobile/src/main/java/net/activitywatch/android/ConfigManager.kt @@ -0,0 +1,100 @@ +package net.activitywatch.android + +import android.content.Context +import android.util.Log +import java.io.File +import java.util.UUID + +private const val TAG = "ConfigManager" + +/** + * Manages reading and writing the embedded aw-server config.toml stored in + * the app's filesDir. The config format is TOML; we only need the [auth] + * section, so we handle it with simple string operations rather than pulling + * in a full TOML library. + * + * Changes take effect after the server restarts (i.e. app restart). + */ +class ConfigManager(context: Context) { + + private val configFile = File(context.filesDir, "config.toml") + + data class AuthConfig( + val apiKey: String? = null + ) { + val isEnabled: Boolean get() = !apiKey.isNullOrEmpty() + } + + fun readAuthConfig(): AuthConfig { + if (!configFile.exists()) { + Log.d(TAG, "config.toml not found, returning defaults") + return AuthConfig() + } + return try { + val content = configFile.readText() + val apiKey = parseApiKey(content) + AuthConfig(apiKey = apiKey) + } catch (e: Exception) { + Log.e(TAG, "Failed to read config.toml: ${e.message}") + AuthConfig() + } + } + + fun setApiKey(key: String?) { + try { + val current = if (configFile.exists()) configFile.readText() else "" + val updated = writeApiKey(current, key) + configFile.writeText(updated) + Log.d(TAG, "API key updated in config.toml") + } catch (e: Exception) { + Log.e(TAG, "Failed to write config.toml: ${e.message}") + } + } + + fun generateAndSetApiKey(): String { + val key = UUID.randomUUID().toString().replace("-", "") + setApiKey(key) + return key + } + + fun clearApiKey() = setApiKey(null) + + // ---- internal TOML manipulation ---- + + private fun parseApiKey(content: String): String? { + // Find the [auth] section, then look for api_key = "..." + val authSectionRegex = Regex("""(?m)^\[auth\].*?(?=^\[|\z)""", RegexOption.DOT_MATCHES_ALL) + val authSection = authSectionRegex.find(content)?.value ?: return null + val keyMatch = Regex("""api_key\s*=\s*"([^"]*)"""").find(authSection) + val key = keyMatch?.groupValues?.get(1) + return if (key.isNullOrEmpty()) null else key + } + + private fun writeApiKey(content: String, key: String?): String { + val authLine = if (key.isNullOrEmpty()) null else """api_key = "$key"""" + val authSectionPresent = Regex("""(?m)^\[auth\]""").containsMatchIn(content) + val apiKeyLinePresent = Regex("""(?m)^api_key\s*=""").containsMatchIn(content) + + if (authSectionPresent) { + return if (apiKeyLinePresent) { + // Replace or remove the existing api_key line + val replaced = Regex("""(?m)^api_key\s*=.*$""") + .replaceFirst(content, authLine ?: "") + if (authLine == null) replaced.replace(Regex("\n{3,}"), "\n\n") else replaced + } else if (authLine != null) { + // [auth] exists but no api_key line yet — insert after the [auth] header + content.replaceFirst(oldValue = "[auth]\n", newValue = "[auth]\n$authLine\n") + } else { + content + } + } + + // No [auth] section — append it if we have a key + return if (authLine != null) { + val base = content.trimEnd() + "$base\n\n[auth]\n$authLine\n" + } else { + content + } + } +} diff --git a/mobile/src/main/java/net/activitywatch/android/MainActivity.kt b/mobile/src/main/java/net/activitywatch/android/MainActivity.kt index 0c439e0f..e7bfe017 100644 --- a/mobile/src/main/java/net/activitywatch/android/MainActivity.kt +++ b/mobile/src/main/java/net/activitywatch/android/MainActivity.kt @@ -96,8 +96,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte // as you specify a parent activity in AndroidManifest.xml. return when (item.itemId) { R.id.action_settings -> { - Snackbar.make(binding.coordinatorLayout, "The settings button was clicked, but it's not yet implemented!", Snackbar.LENGTH_LONG) - .setAction("Action", null).show() + startActivity(Intent(this, AuthSettingsActivity::class.java)) true } else -> super.onOptionsItemSelected(item) diff --git a/mobile/src/main/res/layout/activity_auth_settings.xml b/mobile/src/main/res/layout/activity_auth_settings.xml new file mode 100644 index 00000000..4d0f8103 --- /dev/null +++ b/mobile/src/main/res/layout/activity_auth_settings.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + +