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
@@ -14,24 +14,31 @@ import com.m57.hermescontrol.data.remote.NetworkMonitor
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.update.UpdateNoticeManager
|
||||
import com.m57.hermescontrol.ui.analytics.AnalyticsPreloader
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class HermesControlApp :
|
||||
Application(),
|
||||
SingletonImageLoader.Factory {
|
||||
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
AuthManager.init(this)
|
||||
// Issue #478: guarantee a "Default" profile is always selected so there is no
|
||||
// separate standalone/default code path anywhere in the app.
|
||||
AuthManager.ensureDefaultProfile()
|
||||
NetworkMonitor.init(this)
|
||||
// Issue #537 follow-up (A): preload analytics in the background after launch
|
||||
// so the tab renders instantly when opened (the usage endpoint is slow on a
|
||||
// cold backend). Fire-and-forget; never blocks UI startup.
|
||||
AnalyticsPreloader.preload(this)
|
||||
// Issue #890: silent once-per-version update check right after launch,
|
||||
// so the chat screen can show an update banner without user interaction.
|
||||
UpdateNoticeManager.checkOnLaunch()
|
||||
appScope.launch {
|
||||
AuthManager.initializationState.first { it == AuthManager.InitializationState.Ready }
|
||||
// Issue #537 follow-up (A): preload analytics in the background after launch
|
||||
// so the tab renders instantly when opened (the usage endpoint is slow on a
|
||||
// cold backend). Fire-and-forget; never blocks UI startup.
|
||||
AnalyticsPreloader.preload(this@HermesControlApp)
|
||||
// Issue #890: silent once-per-version update check right after launch,
|
||||
// so the chat screen can show an update banner without user interaction.
|
||||
UpdateNoticeManager.checkOnLaunch()
|
||||
}
|
||||
}
|
||||
|
||||
override fun newImageLoader(context: PlatformContext): ImageLoader =
|
||||
|
||||
@@ -9,15 +9,24 @@ import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.notification.NotificationHelper
|
||||
import com.m57.hermescontrol.notification.NotificationReplyReceiver
|
||||
import com.m57.hermescontrol.theme.HermesControlTheme
|
||||
import com.m57.hermescontrol.ui.common.ErrorState
|
||||
import com.m57.hermescontrol.ui.common.LoadingState
|
||||
import com.m57.hermescontrol.util.LocaleContextWrapper
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
companion object {
|
||||
@@ -32,6 +41,8 @@ class MainActivity : ComponentActivity() {
|
||||
"com.m57.hermescontrol.ACTION_OPEN_CHAT_FROM_NOTIFICATION"
|
||||
}
|
||||
|
||||
private var baseContextLanguageCode: String = LocaleContextWrapper.SYSTEM_LANGUAGE
|
||||
|
||||
/**
|
||||
* Apply the user-selected display language before any view is inflated.
|
||||
* Reads the persisted code from [AuthManager]; an uninitialized store
|
||||
@@ -41,6 +52,7 @@ class MainActivity : ComponentActivity() {
|
||||
val code =
|
||||
runCatching { AuthManager.getAppLanguage() }
|
||||
.getOrDefault(LocaleContextWrapper.SYSTEM_LANGUAGE)
|
||||
baseContextLanguageCode = code
|
||||
super.attachBaseContext(LocaleContextWrapper.wrapWithCode(base, code))
|
||||
}
|
||||
|
||||
@@ -49,6 +61,18 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
consumeNotificationIntent(intent)
|
||||
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
AuthManager.initializationState.collect { state ->
|
||||
if (state == AuthManager.InitializationState.Ready) {
|
||||
if (AuthManager.isGatedMode() || !AuthManager.getToken().isNullOrBlank()) {
|
||||
HermesWsClient.connect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
val themePreference by AuthManager.themePreferenceFlow.collectAsState()
|
||||
@@ -65,7 +89,35 @@ class MainActivity : ComponentActivity() {
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
MainNavigation()
|
||||
val initState by AuthManager.initializationState.collectAsStateWithLifecycle()
|
||||
when (initState) {
|
||||
AuthManager.InitializationState.Loading -> {
|
||||
LoadingState()
|
||||
}
|
||||
|
||||
AuthManager.InitializationState.Ready -> {
|
||||
val savedLanguage = AuthManager.getAppLanguage()
|
||||
if (savedLanguage != baseContextLanguageCode) {
|
||||
// The next activity attaches with loaded preferences. Keep navigation
|
||||
// gated across recompositions until that recreation completes (#1171).
|
||||
LaunchedEffect(savedLanguage) {
|
||||
recreate()
|
||||
}
|
||||
LoadingState()
|
||||
} else {
|
||||
MainNavigation()
|
||||
}
|
||||
}
|
||||
|
||||
AuthManager.InitializationState.Failed -> {
|
||||
ErrorState(
|
||||
message = stringResource(R.string.auth_init_failed),
|
||||
onRetry = {
|
||||
AuthManager.init(applicationContext)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,9 +134,6 @@ class MainActivity : ComponentActivity() {
|
||||
NotificationHelper.setAppForeground(this, true)
|
||||
ExternalActivityLifecycleGuard.onHostResumed()
|
||||
NotificationHelper.stop(this)
|
||||
if (AuthManager.isGatedMode() || !AuthManager.getToken().isNullOrBlank()) {
|
||||
HermesWsClient.connect()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -296,6 +296,7 @@ class ChatNotificationService : Service() {
|
||||
*/
|
||||
object NotificationHelper {
|
||||
fun start(context: Context) {
|
||||
if (AuthManager.initializationState.value != AuthManager.InitializationState.Ready) return
|
||||
if (AuthManager.getToken().isNullOrBlank()) return
|
||||
// Only run the foreground service while a reply is actually pending
|
||||
// (issue #794) — otherwise the mandatory persistent "Waiting for
|
||||
|
||||
@@ -11,14 +11,16 @@ import com.m57.hermescontrol.data.local.toUiModel
|
||||
* UI state management and WebSocket event handling.
|
||||
*/
|
||||
open class ChatPersistenceRepository(
|
||||
private val dao: ChatMessageDao,
|
||||
private val daoProvider: suspend () -> ChatMessageDao,
|
||||
) {
|
||||
constructor(dao: ChatMessageDao) : this({ dao })
|
||||
|
||||
/** Persist a single message for the given session. */
|
||||
suspend fun persistMessage(
|
||||
message: ChatMessage,
|
||||
sessionId: String,
|
||||
) {
|
||||
dao.upsert(message.toEntity(sessionId))
|
||||
daoProvider().upsert(message.toEntity(sessionId))
|
||||
}
|
||||
|
||||
/** Persist multiple messages in one transaction. */
|
||||
@@ -27,15 +29,15 @@ open class ChatPersistenceRepository(
|
||||
sessionId: String,
|
||||
) {
|
||||
val entities = messages.map { it.toEntity(sessionId) }
|
||||
dao.upsertAll(entities)
|
||||
daoProvider().upsertAll(entities)
|
||||
}
|
||||
|
||||
/** Load cached messages for a session from Room. */
|
||||
suspend fun loadMessages(sessionId: String): List<ChatMessage> =
|
||||
dao.getMessagesForSession(sessionId).map { it.toUiModel() }
|
||||
daoProvider().getMessagesForSession(sessionId).map { it.toUiModel() }
|
||||
|
||||
/** Clear all cached messages for a session (e.g. after /undo rewind). */
|
||||
suspend fun clearMessagesForSession(sessionId: String) {
|
||||
dao.deleteMessagesForSession(sessionId)
|
||||
daoProvider().deleteMessagesForSession(sessionId)
|
||||
}
|
||||
}
|
||||
@@ -322,9 +322,9 @@ class ChatViewModel(
|
||||
application: Application,
|
||||
private val startCleanup: Boolean,
|
||||
repo: ChatPersistenceRepository =
|
||||
ChatPersistenceRepository(
|
||||
HermesDatabase.get(application).chatMessageDao(),
|
||||
),
|
||||
ChatPersistenceRepository {
|
||||
HermesDatabase.get(application).chatMessageDao()
|
||||
},
|
||||
slashUsageStore: SlashUsageStore = SlashUsageStore(application.applicationContext),
|
||||
searchDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default,
|
||||
private val ioDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO,
|
||||
|
||||
Reference in new issue
Block a user