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..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 @@ -50,20 +50,73 @@ object RawSnapshotBackupManager { private const val ENTRY_DATABASES = "payload/databases/" private val terminalTopLevelDirNames = setOf("usr", "tmp", "bin") + 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()) + @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 includeLogs: Boolean = true, + val resources: List = emptyList(), + ) + + data class PreparedImageResource( + val source: File, + 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 { @@ -126,6 +179,14 @@ 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( + context = context, + references = resourceReferences, + ) + val referencedImagePaths = preparedResources + .mapNotNull { it.source.canonicalFile.path } + .toSet() try { val sqliteDb = AppDatabase.getDatabase(context).openHelper.writableDatabase @@ -139,14 +200,17 @@ 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, + includeLogs = options.includeLogs, + resources = preparedResources.map { it.mapping }, ) ZipOutputStream(BufferedOutputStream(FileOutputStream(tmpFile))).use { zos -> @@ -154,11 +218,34 @@ 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 - } else { - alwaysExcluded + terminalTopLevelDirNames + val excludedNames = buildSet { + addAll(alwaysExcluded) + if (!options.includeTerminalData) addAll(terminalTopLevelDirNames) + if (!options.includeLogs) addAll(logFilesTopLevelDirNames) } withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(stage = ExportProgress.SCANNING_FILES, scannedFiles = 0)) @@ -167,6 +254,7 @@ object RawSnapshotBackupManager { dir = context.filesDir, entryPrefix = ENTRY_FILES, excludedTopLevelDirNames = excludedNames, + referencedResourcePaths = referencedImagePaths, onScannedCountChanged = { scanned -> if (onProgress != null) { mainHandler.post { @@ -189,6 +277,7 @@ object RawSnapshotBackupManager { dir = context.filesDir, entryPrefix = ENTRY_FILES, excludedTopLevelDirNames = excludedNames, + referencedResourcePaths = referencedImagePaths, totalFiles = filesTotalCount, onPercentChanged = { percent -> if (onProgress != null) { @@ -205,7 +294,8 @@ object RawSnapshotBackupManager { val externalFilesTotalCount = totalFilesForZip( dir = externalFilesDir, entryPrefix = ENTRY_EXTERNAL_FILES, - excludedTopLevelDirNames = emptySet() + excludedTopLevelDirNames = if (options.includeLogs) emptySet() else logExternalTopLevelDirNames, + referencedResourcePaths = referencedImagePaths ) withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_EXTERNAL_FILES, 0)) @@ -215,6 +305,8 @@ object RawSnapshotBackupManager { zos = zos, dir = externalFilesDir, entryPrefix = ENTRY_EXTERNAL_FILES, + excludedTopLevelDirNames = if (options.includeLogs) emptySet() else logExternalTopLevelDirNames, + referencedResourcePaths = referencedImagePaths, totalFiles = externalFilesTotalCount, onPercentChanged = { percent -> if (onProgress != null) { @@ -295,21 +387,33 @@ object RawSnapshotBackupManager { 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) logFilesTopLevelDirNames 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) @@ -318,7 +422,12 @@ object RawSnapshotBackupManager { "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")) @@ -327,6 +436,8 @@ object RawSnapshotBackupManager { withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_DATABASES) } replaceDirContents(File(payloadDir, "databases"), File(context.dataDir, "databases")) + restoreSnapshotResources(resourceRestorePlans) + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.FINALIZING) } AppLogger.i(TAG, "restore done: ${manifest.packageName}") } catch (e: Exception) { @@ -421,6 +532,7 @@ object RawSnapshotBackupManager { dir: File, entryPrefix: String, excludedTopLevelDirNames: Set = emptySet(), + referencedResourcePaths: Set = emptySet(), totalFiles: Int = 0, onPercentChanged: ((Int) -> Unit)? = null ) { @@ -439,6 +551,16 @@ 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 (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}") @@ -475,6 +597,347 @@ object RawSnapshotBackupManager { } } + private fun prepareImageResources( + context: Context, + 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) + val restoreLocation = restoreLocationFor(context, canonical) + 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, + restoreRoot = restoreLocation?.root, + restoreRelativePath = restoreLocation?.relativePath, + ), + ) + } + AppLogger.i(TAG, "prepare resources done (references=${references.size} files=${result.size})") + return result + } + + private fun buildResourceRestorePlans( + context: Context, + manifest: Manifest, + workDir: File, + ): 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, context) + if (destination == null) { + AppLogger.w(TAG, "restore resource skip (unsupported originalUri): ${mapping.originalUri}") + 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 { + plan.destination.parentFile?.mkdirs() + plan.snapshotFile.copyTo(plan.destination, overwrite = true) + restored++ + } catch (e: Exception) { + AppLogger.w(TAG, "restore resource failed: ${plan.mapping.snapshotPath}", e) + skipped++ + } + } + AppLogger.i( + TAG, + "restore resources done (restored=$restored skipped=$skipped total=${resourceRestorePlans.size})" + ) + } + + private fun restoreDestinationFor( + mapping: ResourceMapping, + context: Context, + ): File? { + 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 -> null + } ?: return null + 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 + } + 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 { + 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, @@ -544,6 +1007,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 +1021,12 @@ object RawSnapshotBackupManager { }.forEach { f -> if (!f.isFile) return@forEach val canonical = f.canonicalFile + 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++ @@ -585,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}") } } } 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..821c75264a --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotResourceReferences.kt @@ -0,0 +1,266 @@ +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(), + ) + + val storedCards = characterCards.getAllCharacterCards() + val storedCardIds = storedCards.map { it.id }.toSet() + storedCards.forEach { card -> + collectCharacterCardReferences( + references, + card.id, + card.name, + ) + } + // 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, + CharacterCardManager.DEFAULT_CHARACTER_CARD_ID, + CharacterCardManager.DEFAULT_CHARACTER_NAME, + ) + } + + 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 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, + 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" } + } +} 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 环境体积很大,默认不备份)。恢复会覆盖当前数据,并且需要重启应用生效。部分加密数据在换机/重装后可能无法使用。 + 在快照中包含日志 + 日志有助于故障排查,但可能包含敏感信息;关闭后只影响导出,不删除设备上的日志。 立即备份 从文件恢复 正在生成文件快照备份...