Centralize config + EncryptedSharedPreferences for credentials
ClippyAuto CI / build (push) Has been cancelled

- 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 <spirit@hobbs.farm>
This commit is contained in:
2026-03-08 14:11:34 -05:00
parent 8ce62b9673
commit 720fca5919
4 changed files with 156 additions and 30 deletions
@@ -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
)
}
@@ -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()
@@ -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()
}
}
@@ -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