From 93c6ba16520e945a5c7784d7becb8e78d998c541 Mon Sep 17 00:00:00 2001 From: kong <135376906+3316891527@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:18:53 +0000 Subject: [PATCH 1/6] feat(backup): snapshot referenced image & font resources with restore rebinding - Collect currently referenced avatar/background/bubble/font resources for all character cards, character groups and the global user avatar. - Copy only referenced files into payload/resources/ (owner dirs + readable names), record originalUri/snapshotPath mapping in manifest.resources. - Skip re-copying referenced source files in raw files/external_files scan. - On restore, rebind each snapshot resource back to its original app-private path. - Keep includeTerminalData/databases/shared_prefs/datastore behaviour unchanged. --- .../data/backup/RawSnapshotBackupManager.kt | 169 +++++++++++- .../backup/RawSnapshotResourceReferences.kt | 241 ++++++++++++++++++ 2 files changed, 406 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotResourceReferences.kt diff --git a/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt b/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt index 48d7e14450..5ba2015d94 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt @@ -53,13 +53,31 @@ object RawSnapshotBackupManager { private val mainHandler = Handler(Looper.getMainLooper()) + @Serializable + data class ResourceMapping( + val ownerType: String, + val kind: String, + val ownerId: String? = null, + val ownerName: String? = null, + val originalUri: String, + val snapshotPath: String, + val restoreRoot: String? = null, + val restoreRelativePath: String? = null, + ) + @Serializable data class Manifest( val formatVersion: Int, val packageName: String, val createdAt: Long, val includes: List, - val includeTerminalData: Boolean = true + val includeTerminalData: Boolean = true, + val resources: List = emptyList(), + ) + + data class PreparedImageResource( + val source: File, + val mapping: ResourceMapping, ) data class SnapshotOptions( @@ -126,6 +144,13 @@ object RawSnapshotBackupManager { val sharedPrefsDir = File(dataDir, "shared_prefs") val datastoreDir = File(dataDir, "datastore") val databasesDir = File(dataDir, "databases") + val resourceReferences = DefaultRawSnapshotResourceReferenceProvider(context).collectReferences() + val preparedResources = prepareImageResources( + references = resourceReferences, + ) + val referencedImagePaths = preparedResources + .mapNotNull { it.source.canonicalFile.path } + .toSet() try { val sqliteDb = AppDatabase.getDatabase(context).openHelper.writableDatabase @@ -139,14 +164,16 @@ object RawSnapshotBackupManager { ENTRY_EXTERNAL_FILES, ENTRY_SHARED_PREFS, ENTRY_DATASTORE, - ENTRY_DATABASES + ENTRY_DATABASES, + RawSnapshotResourceLayout.ROOT, ) val manifest = Manifest( formatVersion = FORMAT_VERSION, packageName = context.packageName, createdAt = System.currentTimeMillis(), includes = includes, - includeTerminalData = options.includeTerminalData + includeTerminalData = options.includeTerminalData, + resources = preparedResources.map { it.mapping }, ) ZipOutputStream(BufferedOutputStream(FileOutputStream(tmpFile))).use { zos -> @@ -154,6 +181,29 @@ object RawSnapshotBackupManager { zos.write(json.encodeToString(manifest).toByteArray(Charsets.UTF_8)) zos.closeEntry() + if (preparedResources.isNotEmpty()) { + val writtenResources = HashSet() + val resourcesMs = measureTimeMillis { + preparedResources.forEach { prepared -> + val source = prepared.source + if (!source.isFile) return@forEach + val snapshotPath = prepared.mapping.snapshotPath + if (!writtenResources.add(snapshotPath)) return@forEach + zos.putNextEntry(ZipEntry(snapshotPath)) + BufferedInputStream(FileInputStream(source)).use { input -> + val buffer = ByteArray(64 * 1024) + while (true) { + val read = input.read(buffer) + if (read <= 0) break + zos.write(buffer, 0, read) + } + } + zos.closeEntry() + } + } + AppLogger.i(TAG, "export add resources done in ${resourcesMs}ms (count=${writtenResources.size})") + } + val alwaysExcluded = OperitPaths.rawSnapshotExcludedFilesTopLevelDirNames() val excludedNames = if (options.includeTerminalData) { alwaysExcluded @@ -167,6 +217,7 @@ object RawSnapshotBackupManager { dir = context.filesDir, entryPrefix = ENTRY_FILES, excludedTopLevelDirNames = excludedNames, + referencedResourcePaths = referencedImagePaths, onScannedCountChanged = { scanned -> if (onProgress != null) { mainHandler.post { @@ -189,6 +240,7 @@ object RawSnapshotBackupManager { dir = context.filesDir, entryPrefix = ENTRY_FILES, excludedTopLevelDirNames = excludedNames, + referencedResourcePaths = referencedImagePaths, totalFiles = filesTotalCount, onPercentChanged = { percent -> if (onProgress != null) { @@ -205,7 +257,8 @@ object RawSnapshotBackupManager { val externalFilesTotalCount = totalFilesForZip( dir = externalFilesDir, entryPrefix = ENTRY_EXTERNAL_FILES, - excludedTopLevelDirNames = emptySet() + excludedTopLevelDirNames = emptySet(), + referencedResourcePaths = referencedImagePaths ) withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_EXTERNAL_FILES, 0)) @@ -215,6 +268,7 @@ object RawSnapshotBackupManager { zos = zos, dir = externalFilesDir, entryPrefix = ENTRY_EXTERNAL_FILES, + referencedResourcePaths = referencedImagePaths, totalFiles = externalFilesTotalCount, onPercentChanged = { percent -> if (onProgress != null) { @@ -327,6 +381,8 @@ object RawSnapshotBackupManager { withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_DATABASES) } replaceDirContents(File(payloadDir, "databases"), File(context.dataDir, "databases")) + restoreSnapshotResources(context = context, manifest = manifest, workDir = workDir) + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.FINALIZING) } AppLogger.i(TAG, "restore done: ${manifest.packageName}") } catch (e: Exception) { @@ -421,6 +477,7 @@ object RawSnapshotBackupManager { dir: File, entryPrefix: String, excludedTopLevelDirNames: Set = emptySet(), + referencedResourcePaths: Set = emptySet(), totalFiles: Int = 0, onPercentChanged: ((Int) -> Unit)? = null ) { @@ -439,6 +496,12 @@ object RawSnapshotBackupManager { if (!f.isFile) return@forEach val canonical = f.canonicalFile + val referencedSkip = + referencedResourcePaths.isNotEmpty() && referencedResourcePaths.contains(canonical.path) + if (referencedSkip) { + AppLogger.i(TAG, "export skip referenced resource in raw copy: ${canonical.absolutePath}") + return@forEach + } if (shouldSkipForZip(canonical, baseCanonical, entryPrefix, excludedTopLevelDirNames)) { if (canonical.name == "lock.mdb" && canonical.parentFile?.name?.startsWith("objectbox") == true) { AppLogger.w(TAG, "export skip objectbox lock file: ${canonical.absolutePath}") @@ -475,6 +538,100 @@ object RawSnapshotBackupManager { } } + private fun prepareImageResources( + references: Set, + ): List { + val seenSourcePaths = HashSet() + val result = ArrayList() + references.forEach { reference -> + val localPath = reference.localPath ?: return@forEach + val source = File(localPath) + if (!source.isFile) return@forEach + val canonical = source.canonicalFile + if (!seenSourcePaths.add(canonical.path)) return@forEach + val extension = canonical.extension + val snapshotPath = RawSnapshotResourceLayout.directoryFor(reference) + + RawSnapshotResourceLayout.fileName(reference, extension) + result += PreparedImageResource( + source = canonical, + mapping = ResourceMapping( + ownerType = reference.ownerType.name, + kind = reference.kind.name, + ownerId = reference.ownerId, + ownerName = reference.ownerName, + originalUri = reference.uri, + snapshotPath = snapshotPath, + ), + ) + } + AppLogger.i(TAG, "prepare resources done (references=${references.size} files=${result.size})") + return result + } + + private fun restoreSnapshotResources( + context: Context, + manifest: Manifest, + workDir: File, + ) { + if (manifest.resources.isEmpty()) return + val payloadRoot = File(workDir, "payload").canonicalFile + val allowedRoots = listOfNotNull( + context.dataDir.canonicalFile, + context.getExternalFilesDir(null)?.canonicalFile, + ) + var restored = 0 + var skipped = 0 + manifest.resources.forEach { mapping -> + val snapshotFile = File(payloadRoot, mapping.snapshotPath).canonicalFile + if (!snapshotFile.path.startsWith(payloadRoot.path + File.separator)) { + skipped++ + return@forEach + } + if (!snapshotFile.isFile) { + skipped++ + return@forEach + } + val destination = restoreDestinationFor(mapping.originalUri, allowedRoots) + if (destination == null) { + AppLogger.w(TAG, "restore resource skip (unsupported originalUri): ${mapping.originalUri}") + skipped++ + return@forEach + } + try { + destination.parentFile?.mkdirs() + snapshotFile.copyTo(destination, overwrite = true) + restored++ + } catch (e: Exception) { + AppLogger.w(TAG, "restore resource failed: ${mapping.snapshotPath}", e) + skipped++ + } + } + AppLogger.i( + TAG, + "restore resources done (restored=$restored skipped=$skipped total=${manifest.resources.size})" + ) + } + + private fun restoreDestinationFor( + originalUri: String, + allowedRoots: List, + ): File? { + val uri = Uri.parse(originalUri) + val path = when (uri.scheme?.lowercase()) { + null, "" -> originalUri + "file" -> uri.path + else -> return null + } ?: return null + val file = runCatching { File(path).canonicalFile }.getOrNull() ?: return null + if (allowedRoots.none { root -> + file.path == root.path || file.path.startsWith(root.path + File.separator) + } + ) { + return null + } + return file + } + private fun shouldPruneDirForZip( currentDir: File, baseDir: File, @@ -544,6 +701,7 @@ object RawSnapshotBackupManager { dir: File, entryPrefix: String, excludedTopLevelDirNames: Set, + referencedResourcePaths: Set = emptySet(), onScannedCountChanged: ((Int) -> Unit)? = null ): Int { if (!dir.exists() || !dir.isDirectory) return 0 @@ -557,6 +715,9 @@ object RawSnapshotBackupManager { }.forEach { f -> if (!f.isFile) return@forEach val canonical = f.canonicalFile + if (referencedResourcePaths.isNotEmpty() && referencedResourcePaths.contains(canonical.path)) { + return@forEach + } if (shouldSkipForZip(canonical, baseCanonical, entryPrefix, excludedTopLevelDirNames)) return@forEach total++ diff --git a/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotResourceReferences.kt b/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotResourceReferences.kt new file mode 100644 index 0000000000..5833e821a9 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotResourceReferences.kt @@ -0,0 +1,241 @@ +package com.ai.assistance.operit.data.backup + +import android.content.Context +import android.net.Uri +import com.ai.assistance.operit.data.preferences.CharacterCardManager +import com.ai.assistance.operit.data.preferences.CharacterGroupCardManager +import com.ai.assistance.operit.data.preferences.DisplayPreferencesManager +import com.ai.assistance.operit.data.preferences.ThemePreferenceSnapshot +import com.ai.assistance.operit.data.preferences.UserPreferencesManager +import kotlinx.coroutines.flow.first +import java.io.File + +enum class RawSnapshotResourceOwnerType { + GLOBAL, + CHARACTER_CARD, + CHARACTER_GROUP, +} + +enum class RawSnapshotResourceKind { + USER_AVATAR, + AI_AVATAR, + BACKGROUND, + BUBBLE_USER, + BUBBLE_AI, + FONT_MAIN, + FONT_USER, + FONT_AI, +} + +data class RawSnapshotResourceReference( + val ownerType: RawSnapshotResourceOwnerType, + val kind: RawSnapshotResourceKind, + val uri: String, + val localPath: String? = null, + val ownerId: String? = null, + val ownerName: String? = null, +) + +interface RawSnapshotResourceReferenceProvider { + suspend fun collectReferences(): Set +} + +object EmptyRawSnapshotResourceReferenceProvider : RawSnapshotResourceReferenceProvider { + override suspend fun collectReferences(): Set = emptySet() +} + +/** Collects resources referenced by all recoverable character cards and groups. */ +class DefaultRawSnapshotResourceReferenceProvider( + context: Context, +) : RawSnapshotResourceReferenceProvider { + private val appContext = context.applicationContext ?: context + private val userPreferences = UserPreferencesManager.getInstance(appContext) + private val displayPreferences = DisplayPreferencesManager.getInstance(appContext) + private val characterCards = CharacterCardManager.getInstance(appContext) + private val characterGroups = CharacterGroupCardManager.getInstance(appContext) + + override suspend fun collectReferences(): Set { + val references = linkedSetOf() + addReference( + references, + ownerType = RawSnapshotResourceOwnerType.GLOBAL, + kind = RawSnapshotResourceKind.USER_AVATAR, + uri = displayPreferences.globalUserAvatarUri.first(), + ) + + characterCards.getAllCharacterCards().forEach { card -> + val owner = RawSnapshotResourceOwner( + type = RawSnapshotResourceOwnerType.CHARACTER_CARD, + id = card.id, + name = card.name, + ) + addReference( + references, + owner, + RawSnapshotResourceKind.USER_AVATAR, + userPreferences.resolveThemePreferenceSnapshot(characterCardId = card.id).customUserAvatarUri, + ) + addReference( + references, + owner, + RawSnapshotResourceKind.AI_AVATAR, + userPreferences.getAiAvatarForCharacterCardFlow(card.id).first(), + ) + addThemeReferences( + references, + userPreferences.resolveThemePreferenceSnapshot(characterCardId = card.id), + owner, + ) + } + + characterGroups.getAllCharacterGroupCards().forEach { group -> + val owner = RawSnapshotResourceOwner( + type = RawSnapshotResourceOwnerType.CHARACTER_GROUP, + id = group.id, + name = group.name, + ) + addReference( + references, + owner, + RawSnapshotResourceKind.AI_AVATAR, + userPreferences.getAiAvatarForCharacterGroupFlow(group.id).first(), + ) + addThemeReferences( + references, + userPreferences.resolveThemePreferenceSnapshot(characterGroupId = group.id), + owner, + ) + } + + return references + } + + private fun addThemeReferences( + references: MutableSet, + snapshot: ThemePreferenceSnapshot, + owner: RawSnapshotResourceOwner, + ) { + if (snapshot.useBackgroundImage) { + addReference(references, owner, RawSnapshotResourceKind.BACKGROUND, snapshot.backgroundImageUri) + } + if (snapshot.bubbleUserUseImage) { + addReference(references, owner, RawSnapshotResourceKind.BUBBLE_USER, snapshot.bubbleUserImageUri) + } + if (snapshot.bubbleAiUseImage) { + addReference(references, owner, RawSnapshotResourceKind.BUBBLE_AI, snapshot.bubbleAiImageUri) + } + if (snapshot.useCustomFont) { + addReference(references, owner, RawSnapshotResourceKind.FONT_MAIN, snapshot.customFontPath) + } + if (snapshot.bubbleUserUseCustomFont) { + addReference(references, owner, RawSnapshotResourceKind.FONT_USER, snapshot.bubbleUserCustomFontPath) + } + if (snapshot.bubbleAiUseCustomFont) { + addReference(references, owner, RawSnapshotResourceKind.FONT_AI, snapshot.bubbleAiCustomFontPath) + } + } + + private fun addReference( + references: MutableSet, + ownerType: RawSnapshotResourceOwnerType, + kind: RawSnapshotResourceKind, + uri: String?, + ownerId: String? = null, + ownerName: String? = null, + ) { + addReference( + references, + RawSnapshotResourceOwner(ownerType, ownerId, ownerName), + kind, + uri, + ) + } + + private fun addReference( + references: MutableSet, + owner: RawSnapshotResourceOwner, + kind: RawSnapshotResourceKind, + uri: String?, + ) { + val normalizedUri = uri?.trim().orEmpty() + if (normalizedUri.isBlank() || normalizedUri.startsWith("file:///android_asset/")) return + references += RawSnapshotResourceReference( + ownerType = owner.type, + kind = kind, + uri = normalizedUri, + localPath = resolveLocalPath(normalizedUri), + ownerId = owner.id, + ownerName = owner.name, + ) + } + + private fun resolveLocalPath(uriString: String): String? { + val uri = Uri.parse(uriString) + val path = when (uri.scheme?.lowercase()) { + null, "" -> uriString + "file" -> uri.path + else -> null + } ?: return null + return runCatching { File(path).canonicalPath }.getOrNull() + } + + private data class RawSnapshotResourceOwner( + val type: RawSnapshotResourceOwnerType, + val id: String? = null, + val name: String? = null, + ) +} + +object RawSnapshotResourceLayout { + const val ROOT = "payload/resources/" + + fun directoryFor(reference: RawSnapshotResourceReference): String { + val ownerDirectory = when (reference.ownerType) { + RawSnapshotResourceOwnerType.GLOBAL -> "global" + RawSnapshotResourceOwnerType.CHARACTER_CARD -> + "character_cards/${ownerDirectoryName(reference)}" + RawSnapshotResourceOwnerType.CHARACTER_GROUP -> + "character_groups/${ownerDirectoryName(reference)}" + } + return "$ROOT$ownerDirectory/" + } + + fun fileName(reference: RawSnapshotResourceReference, extension: String): String { + val kindName = when (reference.kind) { + RawSnapshotResourceKind.USER_AVATAR -> "user_avatar" + RawSnapshotResourceKind.AI_AVATAR -> "ai_avatar" + RawSnapshotResourceKind.BACKGROUND -> "background" + RawSnapshotResourceKind.BUBBLE_USER -> "bubble_user" + RawSnapshotResourceKind.BUBBLE_AI -> "bubble_ai" + RawSnapshotResourceKind.FONT_MAIN -> "font_main" + RawSnapshotResourceKind.FONT_USER -> "font_user" + RawSnapshotResourceKind.FONT_AI -> "font_ai" + } + val ownerSegment = when (reference.ownerType) { + RawSnapshotResourceOwnerType.GLOBAL -> null + else -> { + val name = sanitizeSegment(reference.ownerName ?: "unnamed") + val id = sanitizeSegment(reference.ownerId ?: "unknown").takeLast(8) + "${name}_$id" + } + } + val stem = if (ownerSegment.isNullOrBlank()) kindName else "${kindName}_$ownerSegment" + val suffix = extension.trim().trimStart('.').lowercase().ifBlank { "bin" } + return "$stem.$suffix" + } + + private fun ownerDirectoryName(reference: RawSnapshotResourceReference): String { + val name = sanitizeSegment(reference.ownerName ?: "unnamed") + val id = sanitizeSegment(reference.ownerId ?: "unknown").takeLast(12) + return "${name}_$id" + } + + private fun sanitizeSegment(value: String): String { + return value + .trim() + .replace(Regex("[^\\p{L}\\p{N}._-]+"), "_") + .trim('_', '.', ' ') + .take(48) + .ifBlank { "unnamed" } + } +} From 97b0d3a52756147b7dbcae12d5aa588773b2d3ef Mon Sep 17 00:00:00 2001 From: kong <135376906+3316891527@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:51:52 +0000 Subject: [PATCH 2/6] perf(backup): exclude unreferenced historical theme media from snapshot Theme media (user/ai/group avatars, background, bubble images and custom fonts) are persisted as flat files in filesDir root with known prefixes. Only files currently referenced by a character card/group or the global user avatar are kept (they live once under payload/resources); any other file matching those prefixes is now excluded from the raw files copy so historical media no longer bloats the snapshot. Logs and non-theme data are intentionally left untouched. --- .../data/backup/RawSnapshotBackupManager.kt | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt b/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt index 5ba2015d94..3e9e8417bf 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt @@ -51,6 +51,22 @@ object RawSnapshotBackupManager { private val terminalTopLevelDirNames = setOf("usr", "tmp", "bin") +// Theme assets (avatars / background / bubble image / fonts) picked by the user are persisted +// by FileUtils.copyFileToInternalStorage() as flat files in filesDir root, named +// "_." (or "avatar__", "group_avatar__"). Only these +// prefixes are candidates when pruning unreferenced historical media from a raw snapshot. +private val themeMediaFlatNamePrefixes = listOf( + "background", + "bubble_ai", + "bubble_user", + "custom_font", + "user_avatar", + "ai_avatar", + "global_user_avatar", + "avatar_", + "group_avatar_", +) + private val mainHandler = Handler(Looper.getMainLooper()) @Serializable @@ -502,6 +518,10 @@ object RawSnapshotBackupManager { AppLogger.i(TAG, "export skip referenced resource in raw copy: ${canonical.absolutePath}") return@forEach } + if (entryPrefix == ENTRY_FILES && isUnreferencedThemeMediaFlatFile(canonical, baseCanonical)) { + AppLogger.i(TAG, "export skip unreferenced theme media: ${canonical.name}") + return@forEach + } if (shouldSkipForZip(canonical, baseCanonical, entryPrefix, excludedTopLevelDirNames)) { if (canonical.name == "lock.mdb" && canonical.parentFile?.name?.startsWith("objectbox") == true) { AppLogger.w(TAG, "export skip objectbox lock file: ${canonical.absolutePath}") @@ -632,6 +652,16 @@ object RawSnapshotBackupManager { return file } + private fun isUnreferencedThemeMediaFlatFile(canonical: File, baseCanonical: File): Boolean { + if (canonical == baseCanonical || !canonical.path.startsWith(baseCanonical.path + File.separator)) { + return false + } + val rel = canonical.path.substring(baseCanonical.path.length + 1) + if (rel.contains('/')) return false + val name = canonical.name + return themeMediaFlatNamePrefixes.any { name.startsWith(it) } + } + private fun shouldPruneDirForZip( currentDir: File, baseDir: File, @@ -718,6 +748,9 @@ object RawSnapshotBackupManager { if (referencedResourcePaths.isNotEmpty() && referencedResourcePaths.contains(canonical.path)) { return@forEach } + if (entryPrefix == ENTRY_FILES && isUnreferencedThemeMediaFlatFile(canonical, baseCanonical)) { + return@forEach + } if (shouldSkipForZip(canonical, baseCanonical, entryPrefix, excludedTopLevelDirNames)) return@forEach total++ From cd4986222e93e5f85b9ddb1bdf6a0323c2dab433 Mon Sep 17 00:00:00 2001 From: kong <135376906+3316891527@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:37:03 +0000 Subject: [PATCH 3/6] fix(backup): collect default_character theme media as referenced The built-in default_character persona is not returned by getAllCharacterCards(), so its background / user avatar / ai avatar (stored under character_card_theme_default_character_* keys) were never collected. They were therefore treated as unreferenced history and pruned, leaving the exported snapshot without the current main-chat resources. Collect the default scope explicitly so those files are kept under payload/resources and restored. --- .../backup/RawSnapshotResourceReferences.kt | 63 +++++++++++++------ 1 file changed, 44 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotResourceReferences.kt b/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotResourceReferences.kt index 5833e821a9..821c75264a 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotResourceReferences.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotResourceReferences.kt @@ -63,28 +63,24 @@ class DefaultRawSnapshotResourceReferenceProvider( uri = displayPreferences.globalUserAvatarUri.first(), ) - characterCards.getAllCharacterCards().forEach { card -> - val owner = RawSnapshotResourceOwner( - type = RawSnapshotResourceOwnerType.CHARACTER_CARD, - id = card.id, - name = card.name, - ) - addReference( + val storedCards = characterCards.getAllCharacterCards() + val storedCardIds = storedCards.map { it.id }.toSet() + storedCards.forEach { card -> + collectCharacterCardReferences( references, - owner, - RawSnapshotResourceKind.USER_AVATAR, - userPreferences.resolveThemePreferenceSnapshot(characterCardId = card.id).customUserAvatarUri, + card.id, + card.name, ) - addReference( - references, - owner, - RawSnapshotResourceKind.AI_AVATAR, - userPreferences.getAiAvatarForCharacterCardFlow(card.id).first(), - ) - addThemeReferences( + } + // The built-in "default_character" is the app's always-present chat persona and is NOT + // returned by getAllCharacterCards(). Its theme/background/avatar lives under the + // character_card_theme_default_character_* keys, so it must be collected explicitly or + // those current resources would be treated as unreferenced history and pruned. + if (!storedCardIds.contains(CharacterCardManager.DEFAULT_CHARACTER_CARD_ID)) { + collectCharacterCardReferences( references, - userPreferences.resolveThemePreferenceSnapshot(characterCardId = card.id), - owner, + CharacterCardManager.DEFAULT_CHARACTER_CARD_ID, + CharacterCardManager.DEFAULT_CHARACTER_NAME, ) } @@ -110,6 +106,35 @@ class DefaultRawSnapshotResourceReferenceProvider( return references } + private suspend fun collectCharacterCardReferences( + references: MutableSet, + cardId: String, + cardName: String, + ) { + val owner = RawSnapshotResourceOwner( + type = RawSnapshotResourceOwnerType.CHARACTER_CARD, + id = cardId, + name = cardName, + ) + addReference( + references, + owner, + RawSnapshotResourceKind.USER_AVATAR, + userPreferences.resolveThemePreferenceSnapshot(characterCardId = cardId).customUserAvatarUri, + ) + addReference( + references, + owner, + RawSnapshotResourceKind.AI_AVATAR, + userPreferences.getAiAvatarForCharacterCardFlow(cardId).first(), + ) + addThemeReferences( + references, + userPreferences.resolveThemePreferenceSnapshot(characterCardId = cardId), + owner, + ) + } + private fun addThemeReferences( references: MutableSet, snapshot: ThemePreferenceSnapshot, From 2820850c78168d1e0e8d021d493fd34d80865309 Mon Sep 17 00:00:00 2001 From: kong <135376906+3316891527@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:37:38 +0000 Subject: [PATCH 4/6] fix(backup): restore snapshot resources and make logs optional --- .../data/backup/RawSnapshotBackupManager.kt | 404 +++++++++++++++--- .../screens/ChatBackupSettingsScreen.kt | 28 ++ .../ui/recovery/DataRecoveryActivity.kt | 23 + .../ui/recovery/DataRecoveryViewModel.kt | 14 +- app/src/main/res/values-en/strings.xml | 4 + app/src/main/res/values/strings.xml | 4 + 6 files changed, 411 insertions(+), 66 deletions(-) diff --git a/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt b/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt index 3e9e8417bf..0763f68041 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt @@ -50,22 +50,27 @@ object RawSnapshotBackupManager { private const val ENTRY_DATABASES = "payload/databases/" private val terminalTopLevelDirNames = setOf("usr", "tmp", "bin") - -// Theme assets (avatars / background / bubble image / fonts) picked by the user are persisted -// by FileUtils.copyFileToInternalStorage() as flat files in filesDir root, named -// "_." (or "avatar__", "group_avatar__"). Only these -// prefixes are candidates when pruning unreferenced historical media from a raw snapshot. -private val themeMediaFlatNamePrefixes = listOf( - "background", - "bubble_ai", - "bubble_user", - "custom_font", - "user_avatar", - "ai_avatar", - "global_user_avatar", - "avatar_", - "group_avatar_", -) + private val logFilesTopLevelDirNames = setOf("logs") + private val logExternalTopLevelDirNames = setOf("anr_reports") + + private const val RESTORE_ROOT_APP_DATA = "app_data" + private const val RESTORE_ROOT_EXTERNAL_FILES = "external_files" + + // Theme assets (avatars / background / bubble image / fonts) picked by the user are persisted + // by FileUtils.copyFileToInternalStorage() as flat files in filesDir root, named + // "_." (or "avatar__", "group_avatar__"). Only these + // prefixes are candidates when pruning unreferenced historical media from a raw snapshot. + private val themeMediaFlatNamePrefixes = listOf( + "background", + "bubble_ai", + "bubble_user", + "custom_font", + "user_avatar", + "ai_avatar", + "global_user_avatar", + "avatar_", + "group_avatar_", + ) private val mainHandler = Handler(Looper.getMainLooper()) @@ -88,6 +93,7 @@ private val themeMediaFlatNamePrefixes = listOf( val createdAt: Long, val includes: List, val includeTerminalData: Boolean = true, + val includeLogs: Boolean = true, val resources: List = emptyList(), ) @@ -96,8 +102,21 @@ private val themeMediaFlatNamePrefixes = listOf( val mapping: ResourceMapping, ) + private data class ResourceRestorePlan( + val mapping: ResourceMapping, + val snapshotFile: File, + val destination: File, + val destinationUri: String, + ) + + private data class RestoreLocation( + val root: String, + val relativePath: String, + ) + data class SnapshotOptions( - val includeTerminalData: Boolean = false + val includeTerminalData: Boolean = false, + val includeLogs: Boolean = true, ) enum class ExportProgress { @@ -162,6 +181,7 @@ private val themeMediaFlatNamePrefixes = listOf( val databasesDir = File(dataDir, "databases") val resourceReferences = DefaultRawSnapshotResourceReferenceProvider(context).collectReferences() val preparedResources = prepareImageResources( + context = context, references = resourceReferences, ) val referencedImagePaths = preparedResources @@ -189,6 +209,7 @@ private val themeMediaFlatNamePrefixes = listOf( createdAt = System.currentTimeMillis(), includes = includes, includeTerminalData = options.includeTerminalData, + includeLogs = options.includeLogs, resources = preparedResources.map { it.mapping }, ) @@ -221,10 +242,10 @@ private val themeMediaFlatNamePrefixes = listOf( } val alwaysExcluded = OperitPaths.rawSnapshotExcludedFilesTopLevelDirNames() - val excludedNames = if (options.includeTerminalData) { - alwaysExcluded - } else { - alwaysExcluded + terminalTopLevelDirNames + val excludedNames = buildSet { + addAll(alwaysExcluded) + if (!options.includeTerminalData) addAll(terminalTopLevelDirNames) + if (!options.includeLogs) addAll(logTopLevelDirNames) } withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(stage = ExportProgress.SCANNING_FILES, scannedFiles = 0)) @@ -273,7 +294,7 @@ private val themeMediaFlatNamePrefixes = listOf( val externalFilesTotalCount = totalFilesForZip( dir = externalFilesDir, entryPrefix = ENTRY_EXTERNAL_FILES, - excludedTopLevelDirNames = emptySet(), + excludedTopLevelDirNames = if (options.includeLogs) emptySet() else logExternalTopLevelDirNames, referencedResourcePaths = referencedImagePaths ) withContext(Dispatchers.Main) { @@ -284,6 +305,7 @@ private val themeMediaFlatNamePrefixes = listOf( zos = zos, dir = externalFilesDir, entryPrefix = ENTRY_EXTERNAL_FILES, + excludedTopLevelDirNames = if (options.includeLogs) emptySet() else logExternalTopLevelDirNames, referencedResourcePaths = referencedImagePaths, totalFiles = externalFilesTotalCount, onPercentChanged = { percent -> @@ -365,21 +387,33 @@ private val themeMediaFlatNamePrefixes = listOf( val payloadDir = File(workDir, "payload") val externalFilesPayloadDir = File(payloadDir, "external_files") - val alwaysExcluded = OperitPaths.rawSnapshotExcludedFilesTopLevelDirNames() + val resourceRestorePlans = buildResourceRestorePlans( + context = context, + manifest = manifest, + workDir = workDir, + ) + rewriteResourceUrisInExtractedPreferences( + workDir = workDir, + resourceRestorePlans = resourceRestorePlans, + ) + val alwaysExcluded = OperitPaths.rawSnapshotExcludedFilesTopLevelDirNames() val preserveTerminal = !manifest.includeTerminalData val preservedTerminalNames = if (preserveTerminal) terminalTopLevelDirNames else emptySet() val preservedAlwaysExcludedNames = alwaysExcluded.filterNot { dirName -> File(payloadDir, "files/$dirName").exists() }.toSet() - val preservedNames = preservedTerminalNames + preservedAlwaysExcludedNames + val preservedLogNames = if (!manifest.includeLogs) logTopLevelDirNames else emptySet() + val preservedNames = preservedTerminalNames + preservedAlwaysExcludedNames + preservedLogNames AppLogger.i( TAG, - "restore manifest ok (formatVersion=${manifest.formatVersion}, includeTerminalData=${manifest.includeTerminalData})" + "restore manifest ok (formatVersion=${manifest.formatVersion}, " + + "includeTerminalData=${manifest.includeTerminalData}, " + + "includeLogs=${manifest.includeLogs}, resources=${manifest.resources.size})" ) - AppLogger.i(TAG, "restore replace dirs (preserveTerminalTopLevel=${preservedNames.isNotEmpty()})") + AppLogger.i(TAG, "restore replace dirs (preserveTopLevel=${preservedNames.joinToString()})") withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_FILES) } replaceDirContents(File(payloadDir, "files"), context.filesDir, preservedTopLevelDirNames = preservedNames) @@ -388,7 +422,12 @@ private val themeMediaFlatNamePrefixes = listOf( "External files dir is unavailable" } withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_EXTERNAL_FILES) } - replaceDirContents(externalFilesPayloadDir, externalFilesDir) + val preservedExternalLogNames = if (!manifest.includeLogs) logExternalTopLevelDirNames else emptySet() + replaceDirContents( + externalFilesPayloadDir, + externalFilesDir, + preservedTopLevelDirNames = preservedExternalLogNames, + ) } withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_SHARED_PREFS) } replaceDirContents(File(payloadDir, "shared_prefs"), File(context.dataDir, "shared_prefs")) @@ -397,7 +436,7 @@ private val themeMediaFlatNamePrefixes = listOf( withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_DATABASES) } replaceDirContents(File(payloadDir, "databases"), File(context.dataDir, "databases")) - restoreSnapshotResources(context = context, manifest = manifest, workDir = workDir) + restoreSnapshotResources(resourceRestorePlans) withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.FINALIZING) } AppLogger.i(TAG, "restore done: ${manifest.packageName}") @@ -559,6 +598,7 @@ private val themeMediaFlatNamePrefixes = listOf( } private fun prepareImageResources( + context: Context, references: Set, ): List { val seenSourcePaths = HashSet() @@ -572,6 +612,7 @@ private val themeMediaFlatNamePrefixes = listOf( val extension = canonical.extension val snapshotPath = RawSnapshotResourceLayout.directoryFor(reference) + RawSnapshotResourceLayout.fileName(reference, extension) + val restoreLocation = restoreLocationFor(context, canonical) result += PreparedImageResource( source = canonical, mapping = ResourceMapping( @@ -581,6 +622,8 @@ private val themeMediaFlatNamePrefixes = listOf( ownerName = reference.ownerName, originalUri = reference.uri, snapshotPath = snapshotPath, + restoreRoot = restoreLocation?.root, + restoreRelativePath = restoreLocation?.relativePath, ), ) } @@ -588,68 +631,301 @@ private val themeMediaFlatNamePrefixes = listOf( return result } - private fun restoreSnapshotResources( + private fun buildResourceRestorePlans( context: Context, manifest: Manifest, workDir: File, - ) { - if (manifest.resources.isEmpty()) return - val payloadRoot = File(workDir, "payload").canonicalFile - val allowedRoots = listOfNotNull( - context.dataDir.canonicalFile, - context.getExternalFilesDir(null)?.canonicalFile, - ) - var restored = 0 - var skipped = 0 - manifest.resources.forEach { mapping -> - val snapshotFile = File(payloadRoot, mapping.snapshotPath).canonicalFile - if (!snapshotFile.path.startsWith(payloadRoot.path + File.separator)) { - skipped++ - return@forEach - } - if (!snapshotFile.isFile) { - skipped++ - return@forEach + ): List { + if (manifest.resources.isEmpty()) return emptyList() + + val seenOriginalUris = HashSet() + return manifest.resources.mapNotNull { mapping -> + if (!seenOriginalUris.add(mapping.originalUri)) return@mapNotNull null + + val snapshotFile = snapshotFileFor(workDir, mapping) + if (snapshotFile == null || !snapshotFile.isFile) { + AppLogger.w(TAG, "restore resource skip (snapshot file missing): ${mapping.snapshotPath}") + return@mapNotNull null } - val destination = restoreDestinationFor(mapping.originalUri, allowedRoots) + + val destination = restoreDestinationFor(mapping, context) if (destination == null) { AppLogger.w(TAG, "restore resource skip (unsupported originalUri): ${mapping.originalUri}") - skipped++ - return@forEach + return@mapNotNull null } + + ResourceRestorePlan( + mapping = mapping, + snapshotFile = snapshotFile, + destination = destination, + destinationUri = uriForDestination(mapping.originalUri, destination), + ) + } + } + + private fun snapshotFileFor(workDir: File, mapping: ResourceMapping): File? { + val payloadRoot = File(workDir, "payload").canonicalFile + val rawPath = mapping.snapshotPath.trimStart('/') + val relativePath = when { + rawPath.startsWith("payload/") -> rawPath.removePrefix("payload/") + rawPath.startsWith("resources/") -> rawPath + else -> return null + } + val candidate = File(payloadRoot, relativePath).canonicalFile + return candidate.takeIf { isWithin(payloadRoot, it) } + } + + private fun restoreLocationFor(context: Context, source: File): RestoreLocation? { + val canonicalSource = source.canonicalFile + val dataRoot = context.dataDir.canonicalFile + if (isWithin(dataRoot, canonicalSource)) { + return RestoreLocation( + root = RESTORE_ROOT_APP_DATA, + relativePath = relativePathFrom(dataRoot, canonicalSource), + ) + } + + val externalRoot = context.getExternalFilesDir(null)?.canonicalFile + if (externalRoot != null && isWithin(externalRoot, canonicalSource)) { + return RestoreLocation( + root = RESTORE_ROOT_EXTERNAL_FILES, + relativePath = relativePathFrom(externalRoot, canonicalSource), + ) + } + return null + } + + private fun restoreSnapshotResources(resourceRestorePlans: List) { + if (resourceRestorePlans.isEmpty()) { + AppLogger.i(TAG, "restore resources done (restored=0 skipped=0 total=0)") + return + } + + var restored = 0 + var skipped = 0 + resourceRestorePlans.forEach { plan -> try { - destination.parentFile?.mkdirs() - snapshotFile.copyTo(destination, overwrite = true) + plan.destination.parentFile?.mkdirs() + plan.snapshotFile.copyTo(plan.destination, overwrite = true) restored++ } catch (e: Exception) { - AppLogger.w(TAG, "restore resource failed: ${mapping.snapshotPath}", e) + AppLogger.w(TAG, "restore resource failed: ${plan.mapping.snapshotPath}", e) skipped++ } } AppLogger.i( TAG, - "restore resources done (restored=$restored skipped=$skipped total=${manifest.resources.size})" + "restore resources done (restored=$restored skipped=$skipped total=${resourceRestorePlans.size})" ) } private fun restoreDestinationFor( - originalUri: String, - allowedRoots: List, + mapping: ResourceMapping, + context: Context, ): File? { - val uri = Uri.parse(originalUri) + val configuredRoot = when (mapping.restoreRoot) { + RESTORE_ROOT_APP_DATA -> context.dataDir + RESTORE_ROOT_EXTERNAL_FILES -> context.getExternalFilesDir(null) + else -> null + } + val configuredRelativePath = mapping.restoreRelativePath + if (configuredRoot != null && !configuredRelativePath.isNullOrBlank()) { + return resolveWithin(configuredRoot, configuredRelativePath) + } + + // Compatibility for manifests created before restoreRoot/restoreRelativePath was added. + return restoreLegacyDestination(mapping.originalUri, context) + } + + private fun restoreLegacyDestination(originalUri: String, context: Context): File? { + val uri = runCatching { Uri.parse(originalUri) }.getOrNull() ?: return null val path = when (uri.scheme?.lowercase()) { null, "" -> originalUri "file" -> uri.path - else -> return null + else -> null } ?: return null - val file = runCatching { File(path).canonicalFile }.getOrNull() ?: return null - if (allowedRoots.none { root -> - file.path == root.path || file.path.startsWith(root.path + File.separator) - } - ) { + val normalizedPath = path.replace('\\', '/') + val filesMarker = "/files/" + val markerIndex = normalizedPath.lastIndexOf(filesMarker) + if (markerIndex < 0) return null + + val relativePath = normalizedPath.substring(markerIndex + filesMarker.length) + val isExternalFilesPath = normalizedPath.contains("/Android/data/") || + normalizedPath.contains("/Android/media/") + val root = if (isExternalFilesPath) { + context.getExternalFilesDir(null) + } else { + context.filesDir + } ?: return null + return resolveWithin(root, relativePath) + } + + private fun uriForDestination(originalUri: String, destination: File): String { + val scheme = runCatching { Uri.parse(originalUri).scheme?.lowercase() }.getOrNull() + return if (scheme.isNullOrBlank()) { + destination.path + } else { + Uri.fromFile(destination).toString() + } + } + + private fun resolveWithin(root: File, relativePath: String): File? { + if (relativePath.isBlank() || relativePath.startsWith("/") || relativePath.contains('\u0000')) { return null } - return file + val canonicalRoot = runCatching { root.canonicalFile }.getOrNull() ?: return null + val candidate = runCatching { + File(canonicalRoot, relativePath.replace('/', File.separatorChar)).canonicalFile + }.getOrNull() ?: return null + return candidate.takeIf { isWithin(canonicalRoot, it) && it != canonicalRoot } + } + + private fun relativePathFrom(root: File, file: File): String { + return file.path.substring(root.path.length + 1).replace(File.separatorChar, '/') + } + + private fun isWithin(root: File, file: File): Boolean { + return file.path == root.path || file.path.startsWith(root.path + File.separator) + } + + private fun rewriteResourceUrisInExtractedPreferences( + workDir: File, + resourceRestorePlans: List, + ) { + if (resourceRestorePlans.isEmpty()) return + val replacements = linkedMapOf() + resourceRestorePlans.forEach { plan -> + replacements.putIfAbsent(plan.mapping.originalUri, plan.destinationUri) + } + if (replacements.isEmpty()) return + + val payloadRoot = File(workDir, "payload") + val preferenceDirs = listOf( + File(payloadRoot, "files/datastore"), + File(payloadRoot, "datastore"), + File(payloadRoot, "shared_prefs"), + ) + var changedFiles = 0 + preferenceDirs + .filter { it.isDirectory } + .flatMap { dir -> + dir.walkTopDown() + .filter { it.isFile && (it.name.endsWith(".preferences_pb") || it.name.endsWith(".xml")) } + .toList() + } + .distinctBy { it.canonicalPath } + .forEach { file -> + if (rewritePreferenceFile(file, replacements)) changedFiles++ + } + + AppLogger.i( + TAG, + "rewrite resource URI references done (mappings=${replacements.size}, files=$changedFiles)" + ) + } + + private fun rewritePreferenceFile( + file: File, + replacements: Map, + ): Boolean { + val original = runCatching { file.readBytes() }.getOrNull() ?: return false + val rewritten = if (file.name.endsWith(".preferences_pb")) { + rewritePreferenceProto(original, replacements, depth = 0) ?: original + } else { + val text = original.toString(Charsets.UTF_8) + replacements.entries.fold(text) { current, (source, destination) -> + current.replace(source, destination) + }.toByteArray(Charsets.UTF_8) + } + if (original.contentEquals(rewritten)) return false + return runCatching { + file.writeBytes(rewritten) + true + }.getOrDefault(false) + } + + private data class ProtoVarint( + val value: Long, + val nextOffset: Int, + ) + + private fun rewritePreferenceProto( + bytes: ByteArray, + replacements: Map, + depth: Int, + ): ByteArray? { + if (depth > 12) return null + val output = ByteArrayOutputStream(bytes.size) + var offset = 0 + while (offset < bytes.size) { + val tag = readProtoVarint(bytes, offset) ?: return null + if (tag.value == 0L) return null + output.write(bytes, offset, tag.nextOffset - offset) + offset = tag.nextOffset + + when ((tag.value and 7L).toInt()) { + 0 -> { + val value = readProtoVarint(bytes, offset) ?: return null + output.write(bytes, offset, value.nextOffset - offset) + offset = value.nextOffset + } + 1 -> { + if (bytes.size - offset < 8) return null + output.write(bytes, offset, 8) + offset += 8 + } + 2 -> { + val length = readProtoVarint(bytes, offset) ?: return null + if (length.value < 0L || length.value > Int.MAX_VALUE) return null + val payloadStart = length.nextOffset + val payloadLength = length.value.toInt() + if (payloadLength > bytes.size - payloadStart) return null + val payload = bytes.copyOfRange(payloadStart, payloadStart + payloadLength) + val payloadText = payload.toString(Charsets.UTF_8) + val replacementText = replacements[payloadText] + val rewrittenPayload = if (replacementText != null) { + replacementText.toByteArray(Charsets.UTF_8) + } else { + rewritePreferenceProto(payload, replacements, depth + 1) ?: payload + } + writeProtoVarint(output, rewrittenPayload.size.toLong()) + output.write(rewrittenPayload) + offset = payloadStart + payloadLength + } + 5 -> { + if (bytes.size - offset < 4) return null + output.write(bytes, offset, 4) + offset += 4 + } + else -> return null + } + } + return output.toByteArray() + } + + private fun readProtoVarint(bytes: ByteArray, startOffset: Int): ProtoVarint? { + if (startOffset < 0 || startOffset >= bytes.size) return null + var offset = startOffset + var value = 0L + var shift = 0 + while (offset < bytes.size && shift <= 63) { + val byte = bytes[offset].toInt() and 0xff + if (shift == 63 && byte > 1) return null + value = value or ((byte and 0x7f).toLong() shl shift) + offset++ + if ((byte and 0x80) == 0) return ProtoVarint(value, offset) + shift += 7 + } + return null + } + + private fun writeProtoVarint(output: ByteArrayOutputStream, value: Long) { + var remaining = value + while ((remaining and -128L) != 0L) { + output.write(((remaining and 0x7fL) or 0x80L).toInt()) + remaining = remaining ushr 7 + } + output.write(remaining.toInt()) } private fun isUnreferencedThemeMediaFlatFile(canonical: File, baseCanonical: File): Boolean { diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatBackupSettingsScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatBackupSettingsScreen.kt index 24ea91a66e..9c61fc15f2 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatBackupSettingsScreen.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatBackupSettingsScreen.kt @@ -176,6 +176,7 @@ fun ChatBackupSettingsScreen() { var roomDbRestoreOperationMessage by remember { mutableStateOf("") } var rawSnapshotOperationState by remember { mutableStateOf(RawSnapshotOperation.IDLE) } var rawSnapshotOperationMessage by remember { mutableStateOf("") } + var includeRawSnapshotLogs by remember { mutableStateOf(true) } var pendingRawSnapshotRestoreUri by remember { mutableStateOf(null) } var showRawSnapshotRestoreConfirmDialog by remember { mutableStateOf(false) } var showDeleteConfirmDialog by remember { mutableStateOf(false) } @@ -973,6 +974,30 @@ fun ChatBackupSettingsScreen() { color = MaterialTheme.colorScheme.onSurfaceVariant ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.backup_raw_snapshot_include_logs), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Text( + text = stringResource(R.string.backup_raw_snapshot_include_logs_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = includeRawSnapshotLogs, + onCheckedChange = { includeRawSnapshotLogs = it }, + enabled = rawSnapshotOperationState != RawSnapshotOperation.BACKING_UP, + ) + } + FlowRow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), @@ -988,6 +1013,9 @@ fun ChatBackupSettingsScreen() { try { val outFile = RawSnapshotBackupManager.exportToBackupDir( context = context, + options = RawSnapshotBackupManager.SnapshotOptions( + includeLogs = includeRawSnapshotLogs, + ), onProgress = { progress -> val suffix = progress.percent?.let { " ${it}%" } ?: "" rawSnapshotOperationMessage = when (progress.stage) { diff --git a/app/src/main/java/com/ai/assistance/operit/ui/recovery/DataRecoveryActivity.kt b/app/src/main/java/com/ai/assistance/operit/ui/recovery/DataRecoveryActivity.kt index 9b40595782..6fa0ef395a 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/recovery/DataRecoveryActivity.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/recovery/DataRecoveryActivity.kt @@ -51,6 +51,7 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -142,6 +143,28 @@ private fun DataRecoveryScreen() { style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.data_recovery_include_logs), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = stringResource(R.string.data_recovery_include_logs_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = state.includeLogsInSnapshot, + onCheckedChange = viewModel::setIncludeLogsInSnapshot, + enabled = !state.isRunning, + ) + } Spacer(modifier = Modifier.height(10.dp)) Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { Button( diff --git a/app/src/main/java/com/ai/assistance/operit/ui/recovery/DataRecoveryViewModel.kt b/app/src/main/java/com/ai/assistance/operit/ui/recovery/DataRecoveryViewModel.kt index 1f5522123d..394dcbe27e 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/recovery/DataRecoveryViewModel.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/recovery/DataRecoveryViewModel.kt @@ -33,7 +33,8 @@ class DataRecoveryViewModel(private val context: Context) : ViewModel() { val queryResult: QueryResult? = null, val affectedRows: Int? = null, val lastSnapshotPath: String? = null, - val restoreCompleted: Boolean = false + val restoreCompleted: Boolean = false, + val includeLogsInSnapshot: Boolean = true, ) private val _state = MutableStateFlow(State()) @@ -43,6 +44,10 @@ class DataRecoveryViewModel(private val context: Context) : ViewModel() { _state.value = _state.value.copy(sqlText = sql) } + fun setIncludeLogsInSnapshot(enabled: Boolean) { + _state.value = _state.value.copy(includeLogsInSnapshot = enabled) + } + fun runSql() { val sql = sanitizeSql(_state.value.sqlText) if (sql.isBlank()) { @@ -99,7 +104,12 @@ class DataRecoveryViewModel(private val context: Context) : ViewModel() { viewModelScope.launch { try { val outFile = - RawSnapshotBackupManager.exportToBackupDir(context) { progress -> + RawSnapshotBackupManager.exportToBackupDir( + context = context, + options = RawSnapshotBackupManager.SnapshotOptions( + includeLogs = _state.value.includeLogsInSnapshot, + ), + ) { progress -> _state.value = _state.value.copy( status = exportProgressText(progress) diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index da3ae8664a..3fdcf3889c 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -5312,6 +5312,8 @@ File Snapshot Backup (Experimental) Archive/restore app data by file & directory This archives app data that is removed by Clear data: internal files/shared_prefs/datastore/databases and Android/data/package/files (the terminal Linux environment can be very large, so it is excluded by default). Restoring overwrites current data and requires an app restart. Some encrypted data may not work after device migration or reinstall. + Include logs in snapshot + Logs help diagnose failures but may contain sensitive information. This only changes the export and does not delete device logs. Backup now Restore from file Creating file snapshot backup... @@ -8325,4 +8327,6 @@ Current client version %1$s is lower than the minimum required version %2$s for this resource. Please update the client before downloading. Current client version %1$s is higher than the maximum supported version %2$s for this resource. Please use a supported client version. Invalid Thinking Configuration + Include logs in snapshot + Logs help diagnose failures but may contain sensitive information. This only changes the export and does not delete device logs. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 54019940f0..ba21ea5482 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1463,6 +1463,8 @@ 数据救援 原始快照 导出会打包内部 files、shared_prefs、datastore、databases 和 Android/data 包目录;导入会覆盖当前数据。 + 在快照中包含日志 + 日志有助于故障排查,但可能包含敏感信息;关闭后只影响导出,不删除设备上的日志。 导出快照 导入快照 启动主应用 @@ -5818,6 +5820,8 @@ 文件快照备份(实验) 以文件/目录为单位的整体压缩备份与恢复 将打包会随“清除数据”一起删除的应用数据:内部 files/shared_prefs/datastore/databases,以及 Android/data/包名/files(终端 Linux 环境体积很大,默认不备份)。恢复会覆盖当前数据,并且需要重启应用生效。部分加密数据在换机/重装后可能无法使用。 + 在快照中包含日志 + 日志有助于故障排查,但可能包含敏感信息;关闭后只影响导出,不删除设备上的日志。 立即备份 从文件恢复 正在生成文件快照备份... From 2b71f5fb7d72d17e8bdae1fe6b373e487eceef3c Mon Sep 17 00:00:00 2001 From: kong <135376906+3316891527@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:04:01 +0000 Subject: [PATCH 5/6] fix(backup): rename stale log dir constant references --- .../assistance/operit/data/backup/RawSnapshotBackupManager.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt b/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt index 0763f68041..51a7abd55c 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt @@ -245,7 +245,7 @@ object RawSnapshotBackupManager { val excludedNames = buildSet { addAll(alwaysExcluded) if (!options.includeTerminalData) addAll(terminalTopLevelDirNames) - if (!options.includeLogs) addAll(logTopLevelDirNames) + if (!options.includeLogs) addAll(logFilesTopLevelDirNames) } withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(stage = ExportProgress.SCANNING_FILES, scannedFiles = 0)) @@ -403,7 +403,7 @@ object RawSnapshotBackupManager { val preservedAlwaysExcludedNames = alwaysExcluded.filterNot { dirName -> File(payloadDir, "files/$dirName").exists() }.toSet() - val preservedLogNames = if (!manifest.includeLogs) logTopLevelDirNames else emptySet() + val preservedLogNames = if (!manifest.includeLogs) logFilesTopLevelDirNames else emptySet() val preservedNames = preservedTerminalNames + preservedAlwaysExcludedNames + preservedLogNames AppLogger.i( From fb264c39d6c4cb96791d30820c20e18b2d3071b4 Mon Sep 17 00:00:00 2001 From: kong <135376906+3316891527@users.noreply.github.com> Date: Sat, 5 Sep 2026 04:20:43 +0000 Subject: [PATCH 6/6] fix(backup): tolerate stale entry removal failure during restore --- .../assistance/operit/data/backup/RawSnapshotBackupManager.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt b/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt index 51a7abd55c..4e8b1e6994 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt @@ -1055,8 +1055,8 @@ object RawSnapshotBackupManager { // snapshot leaves newer migration markers behind and changes how restored data is read. toDir.listFiles()?.forEach { existing -> if (!preservedTopLevelDirNames.contains(existing.name)) { - check(existing.deleteRecursively()) { - "Failed to remove stale snapshot entry: ${existing.absolutePath}" + if (!existing.deleteRecursively()) { + AppLogger.w(TAG, "restore could not remove stale entry, will overwrite: ${existing.absolutePath}") } } }