Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
736433b326
commit
220779bf19
15 files changed
+413
-176
No files matched your search
@@ -10,29 +10,24 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
class ServerStore(
|
||||
class ServerStore private constructor(
|
||||
private val dataStore: DataStore<ServerStoreState>,
|
||||
private val scope: CoroutineScope,
|
||||
initial: ServerStoreState,
|
||||
) {
|
||||
private val _stateFlow: MutableStateFlow<ServerStoreState>
|
||||
val stateFlow: StateFlow<ServerStoreState>
|
||||
private val _stateFlow = MutableStateFlow(initial)
|
||||
val stateFlow: StateFlow<ServerStoreState> = _stateFlow.asStateFlow()
|
||||
|
||||
init {
|
||||
val initial =
|
||||
runBlocking(Dispatchers.IO) {
|
||||
try {
|
||||
dataStore.data.first().selfHealed()
|
||||
} catch (e: Exception) {
|
||||
ServerStoreSerializer.defaultValue
|
||||
}
|
||||
}
|
||||
_stateFlow = MutableStateFlow(initial)
|
||||
stateFlow = _stateFlow.asStateFlow()
|
||||
companion object {
|
||||
// Do not expose a default snapshot before disk/migrations finish (issue #1171).
|
||||
suspend fun create(
|
||||
dataStore: DataStore<ServerStoreState>,
|
||||
scope: CoroutineScope,
|
||||
): ServerStore = ServerStore(dataStore, scope, dataStore.data.first().selfHealed())
|
||||
}
|
||||
|
||||
fun getLatestState(): ServerStoreState = _stateFlow.value
|
||||
|
||||
@@ -20,9 +20,12 @@ import com.m57.hermescontrol.data.remote.ServerEndpoint
|
||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
import com.m57.hermescontrol.theme.ThemePreference
|
||||
import com.m57.hermescontrol.theme.ThemePreset
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -32,7 +35,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Singleton that manages encrypted storage of the Hermes dashboard token
|
||||
@@ -128,33 +131,44 @@ object AuthManager {
|
||||
* Initialise the encrypted preferences.
|
||||
* Call this once from Application.onCreate() or MainActivity.onCreate().
|
||||
*/
|
||||
enum class InitializationState { Loading, Ready, Failed }
|
||||
|
||||
private val _initializationState = MutableStateFlow(InitializationState.Loading)
|
||||
val initializationState: StateFlow<InitializationState> = _initializationState.asStateFlow()
|
||||
|
||||
@Volatile
|
||||
private var initialization: Deferred<Unit>? = null
|
||||
|
||||
@Volatile
|
||||
private var readyPrefs: SharedPreferences? = null
|
||||
|
||||
private var cachedDatabasePassword: ByteArray? = null
|
||||
|
||||
// Issue #1171: publish readiness only after persisted profiles and credentials are loaded.
|
||||
fun init(context: Context) {
|
||||
if (_serverStore != null) return
|
||||
synchronized(this) {
|
||||
if (_serverStore != null) return
|
||||
|
||||
val dataStore =
|
||||
androidx.datastore.core.DataStoreFactory.create(
|
||||
serializer = ServerStoreSerializer,
|
||||
migrations =
|
||||
listOf(
|
||||
ServerStoreMigration(context),
|
||||
ServerUrlMigration(),
|
||||
),
|
||||
) {
|
||||
context.filesDir.resolve("server_store.json")
|
||||
}
|
||||
|
||||
val scope = CoroutineScope(Dispatchers.IO)
|
||||
if (initialization != null && _initializationState.value != InitializationState.Failed) return
|
||||
val previousJob = appScope?.coroutineContext?.get(kotlinx.coroutines.Job)
|
||||
appScope?.cancel()
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
appScope = scope
|
||||
val store = ServerStore(dataStore, scope)
|
||||
_serverStore = store
|
||||
_selectedProfileFlow.value = store.getLatestState().selectedProfileId
|
||||
_baseUrlFlow.value = store.getLatestState().resolvedBaseUrl
|
||||
|
||||
if (prefsDeferred == null) {
|
||||
prefsDeferred =
|
||||
CoroutineScope(Dispatchers.IO).async {
|
||||
_initializationState.value = InitializationState.Loading
|
||||
readyPrefs = null
|
||||
val prefs = CompletableDeferred<SharedPreferences>(scope.coroutineContext[kotlinx.coroutines.Job])
|
||||
prefsDeferred = prefs
|
||||
initialization =
|
||||
scope.async {
|
||||
try {
|
||||
previousJob?.join()
|
||||
val dataStore =
|
||||
androidx.datastore.core.DataStoreFactory.create(
|
||||
serializer = ServerStoreSerializer,
|
||||
migrations = listOf(ServerStoreMigration(context), ServerUrlMigration()),
|
||||
scope = scope,
|
||||
) { context.filesDir.resolve("server_store.json") }
|
||||
val store = ServerStore.create(dataStore, scope)
|
||||
_serverStore = store
|
||||
ensureDefaultProfile()
|
||||
val masterKey = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
|
||||
val p =
|
||||
EncryptedSharedPreferences.create(
|
||||
@@ -165,52 +179,60 @@ object AuthManager {
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
migrateLegacyDefaultIfNeeded(p)
|
||||
_tokenFlow.value = getTokenInternal(p)
|
||||
_activeProfileId.value = p.getString(KEY_ACTIVE_PROFILE_ID, null)?.takeIf { it.isNotBlank() }
|
||||
p
|
||||
synchronized(this@AuthManager) {
|
||||
readyPrefs = p
|
||||
cachedToken = getTokenInternal(p)
|
||||
tokenInitialized = true
|
||||
_tokenFlow.value = cachedToken
|
||||
_activeProfileId.value =
|
||||
p.getString(KEY_ACTIVE_PROFILE_ID, null)?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
prefs.complete(p)
|
||||
val state = store.getLatestState()
|
||||
_selectedProfileFlow.value = state.selectedProfileId
|
||||
_baseUrlFlow.value = state.resolvedBaseUrl
|
||||
val profileId = normalizedProfileId(state.selectedProfileId)
|
||||
CookieManager.initialize(context, prefs, profileId)
|
||||
CookieManager.useStore(profileId)
|
||||
_themePreferenceFlow.value = state.themePreference
|
||||
_useDynamicColorsFlow.value = state.useDynamicColors
|
||||
_themePresetFlow.value = state.themePreset
|
||||
_chatFontScaleFlow.value = state.chatFontScale
|
||||
scope.launch {
|
||||
store.stateFlow.collect { latest ->
|
||||
_themePreferenceFlow.value = latest.themePreference
|
||||
_useDynamicColorsFlow.value = latest.useDynamicColors
|
||||
_themePresetFlow.value = latest.themePreset
|
||||
_chatFontScaleFlow.value = latest.chatFontScale
|
||||
syncCookieStoreForProfile(latest.selectedProfileId)
|
||||
}
|
||||
}
|
||||
_initializationState.value = InitializationState.Ready
|
||||
} catch (e: CancellationException) {
|
||||
prefs.cancel(e)
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
prefs.completeExceptionally(e)
|
||||
_initializationState.value = InitializationState.Failed
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the encrypted cookie store (issue #470). The legacy
|
||||
// session-cookie prefs are passed as a Deferred so existing gated
|
||||
// sessions can be migrated on first load WITHOUT blocking startup.
|
||||
// The Deferred is created above, before this call.
|
||||
val initialProfileId =
|
||||
store.getLatestState().selectedProfileId?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_ID
|
||||
CookieManager.initialize(context, prefsDeferred, initialProfileId)
|
||||
|
||||
scope.launch {
|
||||
store.stateFlow.collect { state ->
|
||||
_themePreferenceFlow.value = state.themePreference
|
||||
_useDynamicColorsFlow.value = state.useDynamicColors
|
||||
_themePresetFlow.value = state.themePreset
|
||||
_chatFontScaleFlow.value = state.chatFontScale
|
||||
// B7 (Jul 08 2026, kanban t_470): keep cookie scope aligned with active profile.
|
||||
appScope?.launch { syncCookieStoreForProfile(state.selectedProfileId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the initialized [SharedPreferences] instance.
|
||||
*
|
||||
* WARNING: This method is synchronous and will block the caller thread (using [runBlocking])
|
||||
* if the asynchronous initialization is still in progress. Callers should avoid invoking this
|
||||
* on the main thread during early startup to prevent frame drops or potential ANRs.
|
||||
*
|
||||
* Times out and throws [IllegalStateException] if initialization takes longer than 2 seconds.
|
||||
*/
|
||||
/** Suspend without blocking the caller or imposing a keystore startup deadline. */
|
||||
suspend fun awaitInitialization() {
|
||||
checkNotNull(initialization) { "AuthManager.init(context) must be called first" }.await()
|
||||
}
|
||||
|
||||
internal suspend fun awaitPrefs(): SharedPreferences {
|
||||
awaitInitialization()
|
||||
return requirePrefs()
|
||||
}
|
||||
|
||||
/** Synchronous APIs are ready-only; entry points must await initialization first. */
|
||||
private fun requirePrefs(): SharedPreferences =
|
||||
runBlocking {
|
||||
val deferred =
|
||||
prefsDeferred ?: throw IllegalStateException(
|
||||
"AuthManager not initialized. Call init(context) first.",
|
||||
)
|
||||
kotlinx.coroutines.withTimeoutOrNull(2000) {
|
||||
deferred.await()
|
||||
} ?: throw IllegalStateException("AuthManager initialization timed out after 2 seconds.")
|
||||
}
|
||||
checkNotNull(readyPrefs) { "AuthManager is not ready. Await initialization before accessing credentials." }
|
||||
|
||||
fun setWsAuthParam(param: String) {
|
||||
serverStore.update { it.copy(wsAuthParam = param) }
|
||||
@@ -259,18 +281,34 @@ object AuthManager {
|
||||
|
||||
// ── Database Master Password ─────────────────────────────────────────
|
||||
|
||||
fun getDatabasePassword(): ByteArray {
|
||||
val prefs = requirePrefs()
|
||||
var dbPasswordBase64 = prefs.getString("db_password", null)
|
||||
if (dbPasswordBase64 == null) {
|
||||
val random = java.security.SecureRandom()
|
||||
val newPassword = ByteArray(32)
|
||||
random.nextBytes(newPassword)
|
||||
dbPasswordBase64 = android.util.Base64.encodeToString(newPassword, android.util.Base64.NO_WRAP)
|
||||
prefs.edit().putString("db_password", dbPasswordBase64).apply()
|
||||
suspend fun getDatabasePassword(): ByteArray =
|
||||
withContext(Dispatchers.IO) {
|
||||
val prefs = awaitPrefs()
|
||||
synchronized(this@AuthManager) {
|
||||
cachedDatabasePassword?.let { return@synchronized it.copyOf() }
|
||||
val stored = prefs.getString("db_password", null)
|
||||
val password =
|
||||
if (stored == null) {
|
||||
ByteArray(32).also { bytes ->
|
||||
java.security.SecureRandom().nextBytes(bytes)
|
||||
val encoded =
|
||||
java.util.Base64
|
||||
.getEncoder()
|
||||
.encodeToString(bytes)
|
||||
// Never create a database using a key that was not durably saved.
|
||||
check(prefs.edit().putString("db_password", encoded).commit()) {
|
||||
"Unable to persist database key"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
java.util.Base64
|
||||
.getDecoder()
|
||||
.decode(stored)
|
||||
}
|
||||
cachedDatabasePassword = password
|
||||
password.copyOf()
|
||||
}
|
||||
}
|
||||
return android.util.Base64.decode(dbPasswordBase64, android.util.Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
// ── Connection Profiles ──────────────────────────────────────────────
|
||||
|
||||
@@ -387,15 +425,15 @@ object AuthManager {
|
||||
token: String?,
|
||||
) {
|
||||
requirePrefs().edit().putString("token_$profileId", token).apply()
|
||||
if (getSelectedProfileId() == profileId) {
|
||||
if (getSelectedProfileId() == profileId || profileId == DEFAULT_PROFILE_ID) {
|
||||
if (token == null) ActiveSessionHolder.clear()
|
||||
// B7 (Jul 08 2026, kanban t_470): sync in-memory cachedToken
|
||||
// to prevent stale tokens during ticket refresh
|
||||
synchronized(this) {
|
||||
cachedToken = token
|
||||
cachedToken = if (getSelectedProfileId() == profileId) token else getTokenInternal(requirePrefs())
|
||||
tokenInitialized = true
|
||||
_tokenFlow.value = cachedToken
|
||||
}
|
||||
_tokenFlow.value = token
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,6 +477,9 @@ object AuthManager {
|
||||
*/
|
||||
fun setActiveProfileId(id: String?) {
|
||||
val normalized = id?.takeIf { it.isNotBlank() }
|
||||
// Flow first, persistence best-effort: a caller racing cold startup
|
||||
// still gets the correct in-memory scope instead of an exception, and
|
||||
// the write lands once initialization is Ready (#1171).
|
||||
_activeProfileId.value = normalized
|
||||
runCatching {
|
||||
requirePrefs().edit().putString(KEY_ACTIVE_PROFILE_ID, normalized).apply()
|
||||
@@ -482,6 +523,13 @@ object AuthManager {
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
suspend fun resetAndAwaitForTest() {
|
||||
val previousJob = appScope?.coroutineContext?.get(kotlinx.coroutines.Job)
|
||||
resetAuthStateForTest()
|
||||
previousJob?.join()
|
||||
}
|
||||
|
||||
// For testing purposes
|
||||
fun resetAuthStateForTest() {
|
||||
synchronized(this) {
|
||||
@@ -489,6 +537,14 @@ object AuthManager {
|
||||
tokenInitialized = false
|
||||
_serverStore = null
|
||||
prefsDeferred = null
|
||||
initialization?.cancel()
|
||||
initialization = null
|
||||
readyPrefs = null
|
||||
cachedDatabasePassword?.fill(0)
|
||||
cachedDatabasePassword = null
|
||||
_initializationState.value = InitializationState.Loading
|
||||
_tokenFlow.value = null
|
||||
_selectedProfileFlow.value = null
|
||||
appScope?.let {
|
||||
try {
|
||||
it.cancel()
|
||||
|
||||
@@ -7,6 +7,8 @@ import androidx.room3.RoomDatabase
|
||||
import androidx.room3.migration.Migration
|
||||
import androidx.sqlite.SQLiteConnection
|
||||
import androidx.sqlite.execSQL
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.zetetic.database.sqlcipher.driver.SQLCipherDriver
|
||||
import java.io.File
|
||||
|
||||
@@ -79,40 +81,45 @@ abstract class HermesDatabase : RoomDatabase() {
|
||||
}
|
||||
}
|
||||
|
||||
fun get(context: Context): HermesDatabase =
|
||||
instance ?: synchronized(this) {
|
||||
// SQLCipher can't open plaintext SQLite databases — if an old
|
||||
// unencrypted DB exists (v1), delete it so Room + SQLCipher can
|
||||
// create an encrypted replacement from scratch.
|
||||
val dbFile = context.getDatabasePath("hermes_control.db")
|
||||
if (dbFile.exists() && !isSqlCipherDatabase(dbFile)) {
|
||||
dbFile.delete()
|
||||
suspend fun get(context: Context): HermesDatabase =
|
||||
withContext(Dispatchers.IO) {
|
||||
instance?.let { return@withContext it }
|
||||
val password = AuthManager.getDatabasePassword()
|
||||
synchronized(this@Companion) {
|
||||
instance?.let { return@synchronized it }
|
||||
// SQLCipher can't open plaintext SQLite databases — if an old
|
||||
// unencrypted DB exists (v1), delete it so Room + SQLCipher can
|
||||
// create an encrypted replacement from scratch.
|
||||
val dbFile = context.getDatabasePath("hermes_control.db")
|
||||
if (dbFile.exists() && !isSqlCipherDatabase(dbFile)) {
|
||||
dbFile.delete()
|
||||
}
|
||||
|
||||
// Load SQLCipher native library before creating the driver
|
||||
System.loadLibrary("sqlcipher")
|
||||
val driver =
|
||||
SQLCipherDriver(
|
||||
password,
|
||||
null,
|
||||
null,
|
||||
)
|
||||
|
||||
instance ?: Room
|
||||
.databaseBuilder(
|
||||
context.applicationContext,
|
||||
HermesDatabase::class.java,
|
||||
"hermes_control.db",
|
||||
).setDriver(driver)
|
||||
.addMigrations(
|
||||
MIGRATION_2_3,
|
||||
MIGRATION_3_4,
|
||||
MIGRATION_4_5,
|
||||
MIGRATION_5_6,
|
||||
MIGRATION_6_7,
|
||||
).fallbackToDestructiveMigration(false)
|
||||
.build()
|
||||
.also { instance = it }
|
||||
}
|
||||
|
||||
// Load SQLCipher native library before creating the driver
|
||||
System.loadLibrary("sqlcipher")
|
||||
val driver =
|
||||
SQLCipherDriver(
|
||||
AuthManager.getDatabasePassword(),
|
||||
null,
|
||||
null,
|
||||
)
|
||||
|
||||
instance ?: Room
|
||||
.databaseBuilder(
|
||||
context.applicationContext,
|
||||
HermesDatabase::class.java,
|
||||
"hermes_control.db",
|
||||
).setDriver(driver)
|
||||
.addMigrations(
|
||||
MIGRATION_2_3,
|
||||
MIGRATION_3_4,
|
||||
MIGRATION_4_5,
|
||||
MIGRATION_5_6,
|
||||
MIGRATION_6_7,
|
||||
).fallbackToDestructiveMigration(false)
|
||||
.build()
|
||||
.also { instance = it }
|
||||
}
|
||||
|
||||
/** Returns true if the database file starts with the SQLCipher magic header. */
|
||||
|
||||
Reference in new issue
Block a user