From 720fca59192a7862dd72323a9e741a76e1c03370 Mon Sep 17 00:00:00 2001 From: Pluto Date: Sun, 8 Mar 2026 14:11:34 -0500 Subject: [PATCH] Centralize config + EncryptedSharedPreferences for credentials - New AppConfig singleton backed by EncryptedSharedPreferences - Credentials stored in Android Keystore (AES256-GCM) - Removed hardcoded creds from ClippySession and SetupActivity - Access token caching to avoid re-login on each session - Defaults seeded on first run for backward compatibility Co-authored-by: Spirit --- .../farm/hobbs/clippyauto/ClippyAutoApp.kt | 12 +- .../hobbs/clippyauto/auto/ClippySession.kt | 33 +++--- .../farm/hobbs/clippyauto/config/AppConfig.kt | 108 ++++++++++++++++++ .../farm/hobbs/clippyauto/ui/SetupActivity.kt | 33 ++++-- 4 files changed, 156 insertions(+), 30 deletions(-) create mode 100644 app/src/main/java/farm/hobbs/clippyauto/config/AppConfig.kt diff --git a/app/src/main/java/farm/hobbs/clippyauto/ClippyAutoApp.kt b/app/src/main/java/farm/hobbs/clippyauto/ClippyAutoApp.kt index a8f77f9..b050cad 100644 --- a/app/src/main/java/farm/hobbs/clippyauto/ClippyAutoApp.kt +++ b/app/src/main/java/farm/hobbs/clippyauto/ClippyAutoApp.kt @@ -1,17 +1,23 @@ package farm.hobbs.clippyauto import android.app.Application +import farm.hobbs.clippyauto.config.AppConfig +import farm.hobbs.clippyauto.matrix.MatrixRestClient class ClippyAutoApp : Application() { - lateinit var matrixClient: farm.hobbs.clippyauto.matrix.MatrixRestClient + lateinit var matrixClient: MatrixRestClient private set override fun onCreate() { super.onCreate() instance = this - matrixClient = farm.hobbs.clippyauto.matrix.MatrixRestClient( - homeserverUrl = "https://mess.hobbs.farm" + + // Initialize encrypted config before anything else + AppConfig.init(this) + + matrixClient = MatrixRestClient( + homeserverUrl = AppConfig.homeserverUrl ) } diff --git a/app/src/main/java/farm/hobbs/clippyauto/auto/ClippySession.kt b/app/src/main/java/farm/hobbs/clippyauto/auto/ClippySession.kt index a027ec5..0f650bd 100644 --- a/app/src/main/java/farm/hobbs/clippyauto/auto/ClippySession.kt +++ b/app/src/main/java/farm/hobbs/clippyauto/auto/ClippySession.kt @@ -6,6 +6,7 @@ import androidx.car.app.Session import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import farm.hobbs.clippyauto.VoiceLoop +import farm.hobbs.clippyauto.config.AppConfig import farm.hobbs.clippyauto.matrix.MatrixRestClient import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -23,16 +24,6 @@ class ClippySession : Session(), DefaultLifecycleObserver { private var screen: ClippyScreen? = null private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - // TODO: Move to SharedPreferences via SetupActivity - companion object { - const val HOMESERVER = "https://mess.hobbs.farm" - const val ROOM_ID = "!KSovCtghshGUfVDbQg:hobbs.farm" - const val CLIPPY_USER = "@clippy:hobbs.farm" - // Hardcoded for v1 — move to secure storage (EncryptedSharedPreferences) - const val MATRIX_USER = "clippyauto" - const val MATRIX_PASS = "ClippyAuto2026!" - } - override fun onCreateScreen(intent: Intent): Screen { lifecycle.addObserver(this) val clippyScreen = ClippyScreen(carContext) @@ -41,21 +32,33 @@ class ClippySession : Session(), DefaultLifecycleObserver { } override fun onStart(owner: LifecycleOwner) { - val matrixClient = MatrixRestClient(HOMESERVER) + val matrixClient = MatrixRestClient(AppConfig.homeserverUrl) voiceLoop = VoiceLoop( context = carContext, matrixClient = matrixClient, - roomId = ROOM_ID, - clippyUserId = CLIPPY_USER, + roomId = AppConfig.roomId, + clippyUserId = AppConfig.clippyUserId, onStateChange = { _, status -> screen?.updateStatus(status) } ) - // Login then initialize voice loop scope.launch { - val loggedIn = matrixClient.login(MATRIX_USER, MATRIX_PASS) + // Try stored token first, fall back to login + val storedToken = AppConfig.matrixAccessToken + val loggedIn = if (storedToken != null) { + matrixClient.loginWithToken(storedToken) + true + } else { + val success = matrixClient.login(AppConfig.matrixUser, AppConfig.matrixPass) + if (success) { + // Cache the token for next session + AppConfig.matrixAccessToken = matrixClient.accessToken + } + success + } + withContext(Dispatchers.Main) { if (loggedIn) { voiceLoop?.initialize() diff --git a/app/src/main/java/farm/hobbs/clippyauto/config/AppConfig.kt b/app/src/main/java/farm/hobbs/clippyauto/config/AppConfig.kt new file mode 100644 index 0000000..508d7b8 --- /dev/null +++ b/app/src/main/java/farm/hobbs/clippyauto/config/AppConfig.kt @@ -0,0 +1,108 @@ +package farm.hobbs.clippyauto.config + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey + +/** + * Centralized configuration for ClippyAuto. + * + * Credentials are stored in EncryptedSharedPreferences (Android Keystore-backed). + * On first run, defaults are seeded so existing behavior is preserved. + * SetupActivity can update these values in the future. + */ +object AppConfig { + + private const val PREFS_NAME = "clippy_secure_prefs" + + // Keys + private const val KEY_HOMESERVER = "homeserver_url" + private const val KEY_ROOM_ID = "room_id" + private const val KEY_CLIPPY_USER = "clippy_user_id" + private const val KEY_MATRIX_USER = "matrix_username" + private const val KEY_MATRIX_PASS = "matrix_password" + private const val KEY_MATRIX_TOKEN = "matrix_access_token" + private const val KEY_INITIALIZED = "config_initialized" + + // Defaults (v1 — will be replaced by SetupActivity input) + private const val DEFAULT_HOMESERVER = "https://mess.hobbs.farm" + private const val DEFAULT_ROOM_ID = "!KSovCtghshGUfVDbQg:hobbs.farm" + private const val DEFAULT_CLIPPY_USER = "@clippy:hobbs.farm" + private const val DEFAULT_MATRIX_USER = "clippyauto" + private const val DEFAULT_MATRIX_PASS = "ClippyAuto2026!" + + private var prefs: SharedPreferences? = null + + /** + * Initialize the config. Must be called from Application.onCreate() or before first use. + */ + fun init(context: Context) { + if (prefs != null) return + + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + prefs = EncryptedSharedPreferences.create( + context, + PREFS_NAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + + // Seed defaults on first run + if (!prefs!!.getBoolean(KEY_INITIALIZED, false)) { + prefs!!.edit() + .putString(KEY_HOMESERVER, DEFAULT_HOMESERVER) + .putString(KEY_ROOM_ID, DEFAULT_ROOM_ID) + .putString(KEY_CLIPPY_USER, DEFAULT_CLIPPY_USER) + .putString(KEY_MATRIX_USER, DEFAULT_MATRIX_USER) + .putString(KEY_MATRIX_PASS, DEFAULT_MATRIX_PASS) + .putBoolean(KEY_INITIALIZED, true) + .apply() + } + } + + val homeserverUrl: String + get() = prefs?.getString(KEY_HOMESERVER, DEFAULT_HOMESERVER) ?: DEFAULT_HOMESERVER + + val roomId: String + get() = prefs?.getString(KEY_ROOM_ID, DEFAULT_ROOM_ID) ?: DEFAULT_ROOM_ID + + val clippyUserId: String + get() = prefs?.getString(KEY_CLIPPY_USER, DEFAULT_CLIPPY_USER) ?: DEFAULT_CLIPPY_USER + + val matrixUser: String + get() = prefs?.getString(KEY_MATRIX_USER, DEFAULT_MATRIX_USER) ?: DEFAULT_MATRIX_USER + + val matrixPass: String + get() = prefs?.getString(KEY_MATRIX_PASS, DEFAULT_MATRIX_PASS) ?: DEFAULT_MATRIX_PASS + + /** + * Store a Matrix access token for reuse (avoids re-login). + */ + var matrixAccessToken: String? + get() = prefs?.getString(KEY_MATRIX_TOKEN, null) + set(value) { + prefs?.edit()?.putString(KEY_MATRIX_TOKEN, value)?.apply() + } + + /** + * Update credentials (for future SetupActivity use). + */ + fun updateCredentials( + homeserver: String? = null, + roomId: String? = null, + username: String? = null, + password: String? = null + ) { + val editor = prefs?.edit() ?: return + homeserver?.let { editor.putString(KEY_HOMESERVER, it) } + roomId?.let { editor.putString(KEY_ROOM_ID, it) } + username?.let { editor.putString(KEY_MATRIX_USER, it) } + password?.let { editor.putString(KEY_MATRIX_PASS, it) } + editor.apply() + } +} diff --git a/app/src/main/java/farm/hobbs/clippyauto/ui/SetupActivity.kt b/app/src/main/java/farm/hobbs/clippyauto/ui/SetupActivity.kt index 725b48c..1697c20 100644 --- a/app/src/main/java/farm/hobbs/clippyauto/ui/SetupActivity.kt +++ b/app/src/main/java/farm/hobbs/clippyauto/ui/SetupActivity.kt @@ -11,6 +11,7 @@ import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import farm.hobbs.clippyauto.VoiceLoop +import farm.hobbs.clippyauto.config.AppConfig import farm.hobbs.clippyauto.matrix.MatrixRestClient import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -26,11 +27,6 @@ import kotlinx.coroutines.withContext class SetupActivity : AppCompatActivity() { companion object { - private const val HOMESERVER = "https://mess.hobbs.farm" - private const val ROOM_ID = "!KSovCtghshGUfVDbQg:hobbs.farm" - private const val CLIPPY_USER = "@clippy:hobbs.farm" - private const val MATRIX_USER = "clippyauto" - private const val MATRIX_PASS = "ClippyAuto2026!" private const val MIC_PERMISSION_CODE = 100 } @@ -102,15 +98,15 @@ class SetupActivity : AppCompatActivity() { private fun connectToMatrix() { statusText.text = "Connecting to Matrix..." - log("Connecting to $HOMESERVER...") + log("Connecting to ${AppConfig.homeserverUrl}...") - val matrixClient = MatrixRestClient(HOMESERVER) + val matrixClient = MatrixRestClient(AppConfig.homeserverUrl) voiceLoop = VoiceLoop( context = this, matrixClient = matrixClient, - roomId = ROOM_ID, - clippyUserId = CLIPPY_USER, + roomId = AppConfig.roomId, + clippyUserId = AppConfig.clippyUserId, onStateChange = { state, status -> runOnUiThread { statusText.text = status @@ -120,11 +116,24 @@ class SetupActivity : AppCompatActivity() { ) scope.launch { - val loggedIn = withContext(Dispatchers.IO) { - matrixClient.login(MATRIX_USER, MATRIX_PASS) + // Try stored token first, fall back to login + val storedToken = AppConfig.matrixAccessToken + val loggedIn = if (storedToken != null) { + log("Using cached access token") + matrixClient.loginWithToken(storedToken) + true + } else { + val success = withContext(Dispatchers.IO) { + matrixClient.login(AppConfig.matrixUser, AppConfig.matrixPass) + } + if (success) { + AppConfig.matrixAccessToken = matrixClient.accessToken + } + success } + if (loggedIn) { - log("✅ Logged in as @clippyauto:hobbs.farm") + log("✅ Logged in as @${AppConfig.matrixUser}:hobbs.farm") voiceLoop?.initialize() voiceLoop?.startSession() talkButton.isEnabled = true