diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index d735f8d5..0630ae7c 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -99,16 +99,36 @@ jobs:
submodules: 'recursive'
+ - name: Detect Fastlane secrets
+ id: fastlane_secrets
+ env:
+ KEY_FASTLANE_API: ${{ secrets.KEY_FASTLANE_API }}
+ run: |
+ if [ -n "$KEY_FASTLANE_API" ]; then
+ echo "available=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "available=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Require Fastlane secrets outside pull requests
+ if: steps.fastlane_secrets.outputs.available != 'true' && github.event_name != 'pull_request'
+ run: |
+ echo "::error::KEY_FASTLANE_API is required for non-PR builds"
+ exit 1
+
# Ruby & Fastlane
# version set by .ruby-version
- name: Set up Ruby and install fastlane
+ if: steps.fastlane_secrets.outputs.available == 'true'
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
# Needed for `fastlane update_version`
- uses: adnsio/setup-age-action@v1.2.0
+ if: steps.fastlane_secrets.outputs.available == 'true'
- name: Load Fastlane secrets
+ if: steps.fastlane_secrets.outputs.available == 'true'
env:
KEY_FASTLANE_API: ${{ secrets.KEY_FASTLANE_API }}
run: |
@@ -121,12 +141,18 @@ jobs:
# Retry this, in case there are concurrent jobs, which may lead to the error:
# "Google Api Error: Invalid request - This Edit has been deleted."
- name: Update versionCode
+ if: steps.fastlane_secrets.outputs.available == 'true'
uses: Wandalen/wretry.action@master
with:
command: bundle exec fastlane update_version
attempt_limit: 3
attempt_delay: 20000
+ - name: Use checked-in versionCode fallback
+ if: steps.fastlane_secrets.outputs.available != 'true'
+ run: |
+ echo "KEY_FASTLANE_API unavailable for pull_request build; using checked-in versionCode."
+
- name: Output versionCode
id: versionCode
run: |
@@ -136,6 +162,9 @@ jobs:
name: Build ${{ matrix.type }}
runs-on: ubicloud-standard-4
needs: [build-rust, get-versionCode]
+ # Fork PRs cannot access signing secrets; build/test coverage still runs in
+ # build-rust, test, and test-e2e.
+ if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
strategy:
fail-fast: true
matrix:
@@ -222,15 +251,14 @@ jobs:
make dist/aw-android.${{ matrix.type }}
- name: Upload
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with:
- name: aw-android
+ name: aw-android-${{ matrix.type }}
path: dist/aw-android*.${{ matrix.type }}
test:
name: Test
- runs-on: ubuntu-20.04
- needs: [build-rust]
+ runs-on: ubuntu-22.04
env:
SUPPLY_TRACK: production # used by fastlane to determine track to publish to
@@ -239,40 +267,20 @@ jobs:
with:
submodules: 'recursive'
- - name: Set RELEASE
- run: |
- echo "RELEASE=${{ startsWith(github.ref_name, 'v') }}" >> $GITHUB_ENV
-
- name: Set up JDK
uses: actions/setup-java@v1
with:
java-version: ${{ env.JAVA_VERSION }}
- # Android SDK & NDK
+ # Android SDK & NDK (NDK required at Gradle configuration time even for JVM unit tests)
- name: Set up Android SDK
uses: android-actions/setup-android@v2
- name: Set up Android NDK
run: |
sdkmanager "ndk;${{ env.NDK_VERSION }}"
ANDROID_NDK_HOME="$ANDROID_SDK_ROOT/ndk/${{ env.NDK_VERSION }}"
- ls $ANDROID_NDK_HOME
echo "ANDROID_NDK_HOME=$ANDROID_NDK_HOME" >> $GITHUB_ENV
-
- # Restores jniLibs from cache
- # `actions/cache/restore` only restores, without saving back in a post-hook
- - uses: actions/cache/restore@v3
- id: cache-jniLibs
- env:
- cache-name: jniLibs
- with:
- path: mobile/src/main/jniLibs/
- key: ${{ env.cache-name }}-release-${{ env.RELEASE }}-ndk-${{ env.NDK_VERSION }}-${{ hashFiles('.git/modules/aw-server-rust/HEAD') }}
- fail-on-cache-miss: true
- - name: Check that jniLibs present
- run: |
- test -e mobile/src/main/jniLibs/x86_64/libaw_server.so
-
# Test
- name: Test
run: |
@@ -328,15 +336,18 @@ jobs:
if: runner.os == 'macOS'
run: brew install intel-haxm
+ - name: Set up Android SDK
+ uses: android-actions/setup-android@v2
+
# # # Below code is majorly from https://github.com/actions/runner-images/issues/6152#issuecomment-1243718140
- name: Create Android emulator
run: |
# Install AVD files
- echo "y" | $ANDROID_HOME/tools/bin/sdkmanager --install 'system-images;android-'$MATRIX_E_SDK';default;x86_64'
- echo "y" | $ANDROID_HOME/tools/bin/sdkmanager --licenses
+ echo "y" | sdkmanager --install 'system-images;android-'$MATRIX_E_SDK';default;x86_64'
+ echo "y" | sdkmanager --licenses
# Create emulator
- $ANDROID_HOME/tools/bin/avdmanager create avd -n $MATRIX_AVD -d pixel --package 'system-images;android-'$MATRIX_E_SDK';default;x86_64'
+ avdmanager create avd -n $MATRIX_AVD -d pixel --package 'system-images;android-'$MATRIX_E_SDK';default;x86_64'
$ANDROID_HOME/emulator/emulator -list-avds
if false; then
emulator_config=~/.android/avd/$MATRIX_AVD.avd/config.ini
@@ -369,14 +380,19 @@ jobs:
run: |
echo "Starting emulator and waiting for boot to complete...."
ls -la $ANDROID_HOME/emulator
- $ANDROID_HOME/tools/emulator --accel-check # check for hardware acceleration
- nohup $ANDROID_HOME/tools/emulator -avd $MATRIX_AVD -gpu host -no-audio -no-boot-anim -camera-back none -camera-front none -qemu -m 2048 2>&1 &
+ emulator_args=(-avd "$MATRIX_AVD" -gpu swiftshader_indirect -no-window -no-audio -no-boot-anim -camera-back none -camera-front none)
+ if $ANDROID_HOME/emulator/emulator -accel-check; then
+ echo "Hardware acceleration available."
+ else
+ echo "Hardware acceleration unavailable; using software acceleration."
+ emulator_args+=(-accel off)
+ fi
+ nohup $ANDROID_HOME/emulator/emulator "${emulator_args[@]}" -qemu -m 2048 2>&1 &
$ANDROID_HOME/platform-tools/adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed | tr -d '\r') ]]; do echo "wait..."; sleep 1; done; input keyevent 82'
echo "Emulator has finished booting"
$ANDROID_HOME/platform-tools/adb devices
sleep 30
mkdir -p screenshots
- screencapture screenshots/screenshot-$SUFFIX.jpg
$ANDROID_HOME/platform-tools/adb exec-out screencap -p > screenshots/emulator-$SUFFIX.png
# # # Have to re-setup everything since we need to run emulator for faster performance on masOS ? Other os'es emulator will not startup ?
@@ -422,8 +438,6 @@ jobs:
java-version: ${{ env.JAVA_VERSION }}
# Android SDK & NDK
- - name: Set up Android SDK
- uses: android-actions/setup-android@v2
- name: Set up Android NDK
run: |
sdkmanager "ndk;${{ env.NDK_VERSION }}"
@@ -449,15 +463,15 @@ jobs:
env:
SUFFIX: ${{ matrix.android_avd }}-eAPI-${{ matrix.android_emu_version }}
run: |
- adb shell monkey -p net.activitywatch.android.debug 1
+ adb shell monkey -p net.activitywatch.android.debug 1 || echo "App launch failed; capturing current emulator screen."
sleep 10
- screencapture screenshots/pscreenshot-$SUFFIX.jpg
- $ANDROID_HOME/platform-tools/adb exec-out screencap -p > screenshots/pemulator-$SUFFIX.png
+ mkdir -p screenshots
+ $ANDROID_HOME/platform-tools/adb exec-out screencap -p > screenshots/pemulator-$SUFFIX.png || echo "Screenshot capture failed."
ls -alh screenshots/
- name: Upload logcat
if: ${{ success() || steps.test.conclusion == 'failure'}}
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with:
name: logcat
# mobile\build\outputs\connected_android_test_additional_output\debugAndroidTest\connected\Pixel_XL_API_32(AVD) - 12\ScreenshotTest_saveDeviceScreenBitmap.png
@@ -473,7 +487,7 @@ jobs:
# path: ./*.mov # out.mov
- name: Upload screenshots
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
if: ${{ success() || steps.test.conclusion == 'failure'}}
with:
name: screenshots
@@ -500,10 +514,11 @@ jobs:
- uses: actions/checkout@v3
- name: Download APK & AAB
- uses: actions/download-artifact@v3
+ uses: actions/download-artifact@v4
with:
- name: aw-android
+ pattern: aw-android-*
path: dist
+ merge-multiple: true
- name: Display structure of downloaded files
working-directory: dist
@@ -556,10 +571,11 @@ jobs:
# Will download all artifacts to path
- name: Download release APK & AAB
- uses: actions/download-artifact@v3
+ uses: actions/download-artifact@v4
with:
- name: aw-android
+ pattern: aw-android-*
path: dist
+ merge-multiple: true
- name: Display structure of downloaded files
working-directory: dist
@@ -581,4 +597,3 @@ jobs:
dist/*.apk
dist/*.aab
# body_path: dist/release_notes/release_notes.md
-
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/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"/>
+
+
= Build.VERSION_CODES.TIRAMISU) {
+ clip.description.extras = PersistableBundle().also {
+ it.putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true)
+ }
+ }
+ clipboard.setPrimaryClip(clip)
+ Toast.makeText(this, "API key copied to clipboard", Toast.LENGTH_SHORT).show()
+ }
+
+ btnRegenerate.setOnClickListener {
+ val newKey = configManager.generateAndSetApiKey()
+ if (newKey == null) {
+ refreshUI()
+ Toast.makeText(this, "Failed to save API key", Toast.LENGTH_LONG).show()
+ return@setOnClickListener
+ }
+ tvApiKey.text = newKey
+ btnCopy.visibility = View.VISIBLE
+ // Update switch without triggering its listener (which would show a second toast)
+ isUpdatingSwitch = true
+ switchAuthEnabled.isChecked = true
+ isUpdatingSwitch = false
+ tvStatus.text = "Authentication enabled (restart app to apply)"
+ Toast.makeText(this, "New API key generated. Restart app to apply.", Toast.LENGTH_LONG).show()
+ }
+
+ switchAuthEnabled.setOnCheckedChangeListener { _: CompoundButton, isChecked: Boolean ->
+ if (isUpdatingSwitch) return@setOnCheckedChangeListener
+ if (isChecked) {
+ val current = configManager.readAuthConfig()
+ if (!current.isEnabled) {
+ val newKey = configManager.generateAndSetApiKey()
+ if (newKey == null) {
+ refreshUI()
+ Toast.makeText(this, "Failed to save API key", Toast.LENGTH_LONG).show()
+ return@setOnCheckedChangeListener
+ }
+ tvApiKey.text = newKey
+ }
+ btnCopy.visibility = View.VISIBLE
+ tvStatus.text = "Authentication enabled (restart app to apply)"
+ } else {
+ if (!configManager.clearApiKey()) {
+ refreshUI()
+ Toast.makeText(this, "Failed to save API key setting", Toast.LENGTH_LONG).show()
+ return@setOnCheckedChangeListener
+ }
+ tvApiKey.text = "(none)"
+ btnCopy.visibility = View.GONE
+ tvStatus.text = "Authentication disabled (restart app to apply)"
+ }
+ Toast.makeText(this, "Setting saved. Restart app to apply.", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ if (::configManager.isInitialized) {
+ refreshUI()
+ }
+ }
+
+ private fun refreshUI() {
+ val auth = configManager.readAuthConfig()
+ if (auth.isEnabled) {
+ tvApiKey.text = auth.apiKey
+ tvStatus.text = "Authentication is enabled"
+ isUpdatingSwitch = true
+ switchAuthEnabled.isChecked = true
+ isUpdatingSwitch = false
+ btnCopy.visibility = View.VISIBLE
+ } else {
+ tvApiKey.text = "(none — authentication disabled)"
+ tvStatus.text = "Authentication is disabled"
+ isUpdatingSwitch = true
+ switchAuthEnabled.isChecked = false
+ isUpdatingSwitch = 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..4ac749c7
--- /dev/null
+++ b/mobile/src/main/java/net/activitywatch/android/ConfigManager.kt
@@ -0,0 +1,110 @@
+package net.activitywatch.android
+
+import android.content.Context
+import android.util.Log
+import java.io.File
+import java.io.IOException
+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?): Boolean {
+ return try {
+ val current = if (configFile.exists()) configFile.readText() else ""
+ val updated = writeApiKey(current, key)
+ val tmpFile = File(configFile.parent, configFile.name + ".tmp")
+ tmpFile.writeText(updated)
+ if (!tmpFile.renameTo(configFile)) {
+ tmpFile.delete()
+ throw IOException("Failed to atomically replace config.toml")
+ }
+ Log.d(TAG, "API key updated in config.toml")
+ true
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to write config.toml: ${e.message}")
+ false
+ }
+ }
+
+ fun generateAndSetApiKey(): String? {
+ val key = UUID.randomUUID().toString().replace("-", "")
+ return if (setApiKey(key)) key else null
+ }
+
+ 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 authSectionRegex = Regex("""(?m)^\[auth\].*?(?=^\[|\z)""", RegexOption.DOT_MATCHES_ALL)
+ val authSectionMatch = authSectionRegex.find(content)
+
+ if (authSectionMatch != null) {
+ val authSectionContent = authSectionMatch.value
+ // Modify only within the [auth] section to avoid touching other sections
+ val apiKeyLinePresent = Regex("""(?m)^api_key\s*=""").containsMatchIn(authSectionContent)
+ val updatedSection = if (apiKeyLinePresent) {
+ val replaced = Regex("""(?m)^api_key\s*=.*$""")
+ .replaceFirst(authSectionContent, authLine ?: "")
+ if (authLine == null) replaced.replace(Regex("\n{3,}"), "\n\n") else replaced
+ } else if (authLine != null) {
+ val separator = if (authSectionContent.endsWith("\n")) "" else "\n"
+ "$authSectionContent$separator$authLine\n"
+ } else {
+ authSectionContent
+ }
+ return content.replaceRange(authSectionMatch.range, updatedSection)
+ }
+
+ // 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..6d2d3033
--- /dev/null
+++ b/mobile/src/main/res/layout/activity_auth_settings.xml
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/src/test/java/net/activitywatch/android/ConfigManagerTest.kt b/mobile/src/test/java/net/activitywatch/android/ConfigManagerTest.kt
new file mode 100644
index 00000000..2775f663
--- /dev/null
+++ b/mobile/src/test/java/net/activitywatch/android/ConfigManagerTest.kt
@@ -0,0 +1,142 @@
+package net.activitywatch.android
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * Unit tests for ConfigManager's TOML parsing/writing logic, tested via
+ * standalone helpers that mirror the private functions (Android-free JVM tests).
+ */
+class ConfigManagerTest {
+
+ private fun parseApiKey(content: String): String? {
+ 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 authSectionRegex = Regex("""(?m)^\[auth\].*?(?=^\[|\z)""", RegexOption.DOT_MATCHES_ALL)
+ val authSectionMatch = authSectionRegex.find(content)
+ if (authSectionMatch != null) {
+ val authSectionContent = authSectionMatch.value
+ val apiKeyLinePresent = Regex("""(?m)^api_key\s*=""").containsMatchIn(authSectionContent)
+ val updatedSection = if (apiKeyLinePresent) {
+ val replaced = Regex("""(?m)^api_key\s*=.*$""")
+ .replaceFirst(authSectionContent, authLine ?: "")
+ if (authLine == null) replaced.replace(Regex("\n{3,}"), "\n\n") else replaced
+ } else if (authLine != null) {
+ val separator = if (authSectionContent.endsWith("\n")) "" else "\n"
+ "$authSectionContent$separator$authLine\n"
+ } else {
+ authSectionContent
+ }
+ return content.replaceRange(authSectionMatch.range, updatedSection)
+ }
+ return if (authLine != null) {
+ val base = content.trimEnd()
+ "$base\n\n[auth]\n$authLine\n"
+ } else {
+ content
+ }
+ }
+
+ @Test
+ fun `parseApiKey returns null on empty config`() {
+ assertNull(parseApiKey(""))
+ }
+
+ @Test
+ fun `parseApiKey returns null when no auth section`() {
+ val config = "address = \"127.0.0.1\"\nport = 5600"
+ assertNull(parseApiKey(config))
+ }
+
+ @Test
+ fun `parseApiKey reads key from auth section`() {
+ val config = "address = \"127.0.0.1\"\n\n[auth]\napi_key = \"abc123\"\n"
+ assertEquals("abc123", parseApiKey(config))
+ }
+
+ @Test
+ fun `parseApiKey returns null for empty api_key`() {
+ val config = "[auth]\napi_key = \"\"\n"
+ assertNull(parseApiKey(config))
+ }
+
+ @Test
+ fun `writeApiKey appends auth section when absent`() {
+ val config = "address = \"127.0.0.1\"\nport = 5600"
+ val result = writeApiKey(config, "mykey")
+ assertTrue(result.contains("[auth]"))
+ assertTrue(result.contains("""api_key = "mykey""""))
+ }
+
+ @Test
+ fun `writeApiKey updates existing key`() {
+ val config = "[auth]\napi_key = \"oldkey\"\n"
+ val result = writeApiKey(config, "newkey")
+ assertTrue(result.contains("""api_key = "newkey""""))
+ assertFalse(result.contains("oldkey"))
+ }
+
+ @Test
+ fun `writeApiKey clears key when null`() {
+ val config = "[auth]\napi_key = \"oldkey\"\n"
+ val result = writeApiKey(config, null)
+ assertFalse(result.contains("oldkey"))
+ }
+
+ @Test
+ fun `writeApiKey leaves config unchanged when no auth and key is null`() {
+ val config = "address = \"127.0.0.1\"\n"
+ val result = writeApiKey(config, null)
+ assertFalse(result.contains("[auth]"))
+ }
+
+ @Test
+ fun `writeApiKey inserts key when auth section exists but no api_key line`() {
+ val config = "address = \"127.0.0.1\"\n\n[auth]\n"
+ val result = writeApiKey(config, "inserted-key")
+ assertEquals("inserted-key", parseApiKey(result))
+ }
+
+ @Test
+ fun `writeApiKey inserts key when auth section has no trailing newline`() {
+ val config = "address = \"127.0.0.1\"\n\n[auth]"
+ val result = writeApiKey(config, "inserted-key")
+ assertEquals("inserted-key", parseApiKey(result))
+ assertEquals("address = \"127.0.0.1\"\n\n[auth]\napi_key = \"inserted-key\"\n", result)
+ }
+
+ @Test
+ fun `roundtrip write then read returns same key`() {
+ val config = "address = \"127.0.0.1\"\n"
+ val written = writeApiKey(config, "roundtrip-key")
+ assertEquals("roundtrip-key", parseApiKey(written))
+ }
+
+ @Test
+ fun `roundtrip double write returns new key`() {
+ val config = "address = \"127.0.0.1\"\n"
+ val first = writeApiKey(config, "first-key")
+ val second = writeApiKey(first, "second-key")
+ assertEquals("second-key", parseApiKey(second))
+ assertFalse(second.contains("first-key"))
+ }
+
+ @Test
+ fun `writeApiKey does not overwrite api_key in other sections`() {
+ // Another section also has an api_key field — must not be touched
+ val config = "[logging]\napi_key = \"log-key\"\n\n[auth]\napi_key = \"auth-key\"\n"
+ val result = writeApiKey(config, "new-auth-key")
+ assertEquals("new-auth-key", parseApiKey(result))
+ assertTrue(result.contains("""api_key = "log-key""""))
+ }
+}