Merge pull request #138 from schrodingerspet

fix(message-logger): make backup import persistent and validated
This commit is contained in:
ᴋᴀʟᴀᴅɪɴ
2026-04-19 14:07:13 +05:30
committed by GitHub
3 changed files with 253 additions and 8 deletions

View File

@@ -497,6 +497,16 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
)
}
fun showImportError(throwable: Throwable) {
context.log.error("Failed to import message logger", throwable)
context.shortToast(
translation.format(
"import_failed_toast",
"message" to (throwable.message ?: "Unknown error")
)
)
}
fun parseConversationMessage(message: LoggedMessage): ParsedConversationMessage {
val messageObject = runCatching {
JsonParser.parseString(String(message.messageData, Charsets.UTF_8)).asJsonObject
@@ -758,7 +768,35 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
}
OutlinedButton(modifier = Modifier.fillMaxWidth().padding(5.dp), onClick = { routes.loggerHistory.navigate() }, colors = sharedOutlinedColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f))) { Text(translation["view_logger_history_button"]) }
if (showImportDialog) {
AestheticDialog(onDismissRequest = { showImportDialog = false }, title = translation["message_logger_import_title"], text = translation["message_logger_import_text"], icon = Icons.Filled.Info, confirmButtonText = context.translation["button.import"], dismissButtonText = context.translation["button.cancel"], onConfirm = { showImportDialog = false; runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { context.messageLogger.databaseFile.outputStream().use { out -> it.copyTo(out) } }; storedMessagesCount = context.messageLogger.getStoredMessageCount(); storedStoriesCount = context.messageLogger.getStoredStoriesCount(); context.shortToast(translation["success_toast"]) } } }, onDismiss = { showImportDialog = false }, showCloseButton = false)
AestheticDialog(
onDismissRequest = { showImportDialog = false },
title = translation["message_logger_import_title"],
text = translation["message_logger_import_text"],
icon = Icons.Filled.Info,
confirmButtonText = context.translation["button.import"],
dismissButtonText = context.translation["button.cancel"],
onConfirm = {
showImportDialog = false
runCatching {
activityLauncherHelper.openFile("application/octet-stream") { uri ->
scope.launch {
runCatching {
val importResult = withContext(Dispatchers.IO) {
context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { input ->
context.messageLogger.importDatabase(input)
} ?: throw IllegalStateException("Failed to open selected backup file")
}
storedMessagesCount = importResult.messageCount
storedStoriesCount = importResult.storyCount
context.shortToast(translation["success_toast"])
}.onFailure { showImportError(it) }
}
}
}.onFailure { showImportError(it) }
},
onDismiss = { showImportDialog = false },
showCloseButton = false
)
}
if (showExportOptionsDialog) {
AestheticDialog(

View File

@@ -1104,6 +1104,16 @@ object LegacyTheme : ThemeContract {
)
}
fun showImportError(throwable: Throwable) {
context.log.error("Failed to import message logger", throwable)
context.shortToast(
translation.format(
"import_failed_toast",
"message" to (throwable.message ?: "Unknown error")
)
)
}
fun parseConversationMessage(message: LoggedMessage): ParsedConversationMessage {
val messageObject = runCatching {
JsonParser.parseString(String(message.messageData, Charsets.UTF_8)).asJsonObject
@@ -1365,7 +1375,35 @@ object LegacyTheme : ThemeContract {
}
OutlinedButton(modifier = Modifier.fillMaxWidth().padding(5.dp), onClick = { routes.loggerHistory.navigate() }, colors = sharedOutlinedColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f))) { Text(translation["view_logger_history_button"]) }
if (showImportDialog) {
AestheticDialog(onDismissRequest = { showImportDialog = false }, title = translation["message_logger_import_title"], text = translation["message_logger_import_text"], icon = Icons.Filled.Info, confirmButtonText = importLabel, dismissButtonText = context.translation["button.cancel"], onConfirm = { showImportDialog = false; runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { context.messageLogger.databaseFile.outputStream().use { out -> it.copyTo(out) } }; storedMessagesCount = context.messageLogger.getStoredMessageCount(); storedStoriesCount = context.messageLogger.getStoredStoriesCount(); context.shortToast(translation["success_toast"]) } } }, onDismiss = { showImportDialog = false }, showCloseButton = false)
AestheticDialog(
onDismissRequest = { showImportDialog = false },
title = translation["message_logger_import_title"],
text = translation["message_logger_import_text"],
icon = Icons.Filled.Info,
confirmButtonText = importLabel,
dismissButtonText = context.translation["button.cancel"],
onConfirm = {
showImportDialog = false
runCatching {
activityLauncherHelper.openFile("application/octet-stream") { uri ->
scope.launch {
runCatching {
val importResult = withContext(Dispatchers.IO) {
context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { input ->
context.messageLogger.importDatabase(input)
} ?: throw IllegalStateException("Failed to open selected backup file")
}
storedMessagesCount = importResult.messageCount
storedStoriesCount = importResult.storyCount
context.shortToast(translation["success_toast"])
}.onFailure { showImportError(it) }
}
}
}.onFailure { showImportError(it) }
},
onDismiss = { showImportDialog = false },
showCloseButton = false
)
}
if (showExportOptionsDialog) {
AestheticDialog(

View File

@@ -21,6 +21,7 @@ import me.eternal.purrfectsnap.common.util.ktx.getLongOrNull
import me.eternal.purrfectsnap.common.util.ktx.getStringOrNull
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
import java.io.File
import java.io.InputStream
import java.util.UUID
class LoggedMessage(
@@ -85,6 +86,11 @@ data class ConversationExportResult(
val trackerEventCount: Int
)
data class DatabaseImportResult(
val messageCount: Int,
val storyCount: Int
)
class LoggerWrapper(
val databaseFile: File,
private val readOnly: Boolean = false
@@ -144,27 +150,190 @@ class LoggerWrapper(
private val coroutineScope = CoroutineScope(Dispatchers.IO.limitedParallelism(1))
private val gson by lazy { GsonBuilder().create() }
private val database get() = synchronized(this) {
_database?.takeIf { it.isOpen } ?: run {
_database?.close()
val dbFlags = if (readOnly) SQLiteDatabase.OPEN_READONLY else SQLiteDatabase.CREATE_IF_NECESSARY or SQLiteDatabase.OPEN_READWRITE
val openedDatabase = SQLiteDatabase.openDatabase(databaseFile.absolutePath, null, dbFlags)
private fun openDatabase(file: File, readOnly: Boolean): SQLiteDatabase {
val dbFlags = if (readOnly) {
SQLiteDatabase.OPEN_READONLY
} else {
SQLiteDatabase.CREATE_IF_NECESSARY or SQLiteDatabase.OPEN_READWRITE
}
return SQLiteDatabase.openDatabase(file.absolutePath, null, dbFlags).also { openedDatabase ->
if (!readOnly) {
SQLiteDatabaseHelper.createTablesFromSchema(openedDatabase, MESSAGE_LOGGER_SCHEMA)
}
}
}
private fun closeDatabaseLocked() {
_database?.takeIf { it.isOpen }?.close()
_database = null
}
private val database get() = synchronized(this) {
_database?.takeIf { it.isOpen } ?: run {
closeDatabaseLocked()
val openedDatabase = openDatabase(databaseFile, readOnly)
_database = openedDatabase
openedDatabase
}
}
protected fun finalize() {
_database?.close()
synchronized(this) {
closeDatabaseLocked()
}
}
fun init() {
}
private fun resolveDatabaseSidecars(file: File): List<File> {
return listOf(
file,
File("${file.absolutePath}-wal"),
File("${file.absolutePath}-shm"),
File("${file.absolutePath}-journal")
)
}
private fun deleteIfExists(file: File) {
if (file.exists() && !file.delete()) {
throw IllegalStateException("Failed to delete ${file.name}")
}
}
private fun replaceFile(source: File, target: File) {
if (!source.exists()) {
throw IllegalStateException("Missing source file ${source.name}")
}
if (source.renameTo(target)) return
source.inputStream().use { input ->
target.outputStream().use { output ->
input.copyTo(output)
}
}
if (!source.delete()) {
throw IllegalStateException("Failed to delete temporary file ${source.name}")
}
}
private fun requireCompatibleSchema(db: SQLiteDatabase) {
MESSAGE_LOGGER_SCHEMA.forEach { (tableName, columns) ->
val existingColumns = mutableListOf<String>()
db.rawQuery("PRAGMA table_info($tableName)", null).use { cursor ->
while (cursor.moveToNext()) {
val columnName = cursor.getStringOrNull("name") ?: continue
val columnType = cursor.getStringOrNull("type") ?: ""
existingColumns.add("$columnName $columnType".trim())
}
}
if (existingColumns.isEmpty()) {
throw IllegalStateException("Selected database is missing required table $tableName")
}
val missingColumns = columns.filter { expectedColumn ->
!expectedColumn.uppercase().startsWith("PRIMARY KEY") &&
existingColumns.none { existingColumn -> expectedColumn.startsWith(existingColumn) }
}
if (missingColumns.isNotEmpty()) {
throw IllegalStateException(
"Selected database has incompatible schema for $tableName: ${missingColumns.joinToString()}"
)
}
}
}
private fun requireIntegrity(db: SQLiteDatabase) {
val integrityResult = db.rawQuery("PRAGMA integrity_check(1)", null).use { cursor ->
if (!cursor.moveToFirst()) null else cursor.getString(0)
}
if (integrityResult == null || !integrityResult.equals("ok", ignoreCase = true)) {
throw IllegalStateException("Selected database failed integrity check: ${integrityResult ?: "unknown"}")
}
}
private fun getTableCount(db: SQLiteDatabase, tableName: String): Int {
return db.rawQuery("SELECT COUNT(*) FROM $tableName", null).use { cursor ->
if (!cursor.moveToFirst()) 0 else cursor.getInt(0)
}
}
fun importDatabase(inputStream: InputStream): DatabaseImportResult {
if (readOnly) {
throw IllegalStateException("Cannot import into read-only logger")
}
val databaseDir = databaseFile.parentFile
?: throw IllegalStateException("Cannot resolve message logger database directory")
if (!databaseDir.exists() && !databaseDir.mkdirs()) {
throw IllegalStateException("Failed to create message logger directory")
}
val tempFile = File(
databaseDir,
"${databaseFile.name}.import-${System.currentTimeMillis()}-${UUID.randomUUID()}"
)
val backupFile = File(
databaseDir,
"${databaseFile.name}.backup-${System.currentTimeMillis()}-${UUID.randomUUID()}"
)
try {
inputStream.use { input ->
tempFile.outputStream().use { output ->
val copiedBytes = input.copyTo(output)
if (copiedBytes <= 0L) {
throw IllegalStateException("Selected backup is empty")
}
}
}
openDatabase(tempFile, readOnly = true).use { importedDatabase ->
requireIntegrity(importedDatabase)
requireCompatibleSchema(importedDatabase)
}
synchronized(this) {
closeDatabaseLocked()
val hadExistingDatabase = databaseFile.exists()
if (hadExistingDatabase) {
databaseFile.inputStream().use { input ->
backupFile.outputStream().use { output ->
input.copyTo(output)
}
}
}
try {
resolveDatabaseSidecars(databaseFile).forEach(::deleteIfExists)
replaceFile(tempFile, databaseFile)
resolveDatabaseSidecars(databaseFile).filter { it != databaseFile }.forEach(::deleteIfExists)
val reopenedDatabase = openDatabase(databaseFile, readOnly = false)
val importResult = DatabaseImportResult(
messageCount = getTableCount(reopenedDatabase, "messages"),
storyCount = getTableCount(reopenedDatabase, "stories")
)
_database = reopenedDatabase
deleteIfExists(backupFile)
return importResult
} catch (throwable: Throwable) {
runCatching {
resolveDatabaseSidecars(databaseFile).forEach(::deleteIfExists)
if (backupFile.exists()) {
replaceFile(backupFile, databaseFile)
}
}
closeDatabaseLocked()
throw throwable
}
}
} finally {
runCatching { deleteIfExists(tempFile) }
runCatching { deleteIfExists(backupFile) }
}
}
override fun getLoggedIds(conversationId: Array<String>, limit: Int): LongArray {
if (conversationId.any {
runCatching { UUID.fromString(it) }.isFailure