Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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<RawSnapshotResourceReference>
}

object EmptyRawSnapshotResourceReferenceProvider : RawSnapshotResourceReferenceProvider {
override suspend fun collectReferences(): Set<RawSnapshotResourceReference> = 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<RawSnapshotResourceReference> {
val references = linkedSetOf<RawSnapshotResourceReference>()
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(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 群组也需要收集自定义用户头像

群组主题使用同一编辑器,可以保存 custom_user_avatar_uri,聊天气泡也实际优先显示该头像。这里群组只收 AI 头像和 addThemeReferences,后者没有用户头像;customUserAvatarUri 仅在角色卡路径收集。群组独有的 user_avatar_* 文件因此会被后续未引用媒体筛选排除,备份恢复后丢失。请从群组的 ThemePreferenceSnapshot 同样收集 customUserAvatarUri。

references,
userPreferences.resolveThemePreferenceSnapshot(characterGroupId = group.id),
owner,
)
}

return references
}

private suspend fun collectCharacterCardReferences(
references: MutableSet<RawSnapshotResourceReference>,
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<RawSnapshotResourceReference>,
snapshot: ThemePreferenceSnapshot,
owner: RawSnapshotResourceOwner,
) {
if (snapshot.useBackgroundImage) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 保留已保存但暂时关闭的主题媒体引用

选择背景或字体后关闭显示开关,设置页只改布尔值,仍保存对应 URI,用户随后可以直接重新启用。但这里因开关关闭不收集文件,RawSnapshotBackupManager 又将剩余匹配前缀的媒体从原始 files 备份中排除。恢复快照时 replaceDirContents 会移除原文件,重新启用背景/字体便找不到资源。请按保存的 URI 收集引用;显示开关关闭不等于资源已不再被配置引用。气泡图片和字体的同类判断也需一致处理。

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<RawSnapshotResourceReference>,
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<RawSnapshotResourceReference>,
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" }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uri?>(null) }
var showRawSnapshotRestoreConfirmDialog by remember { mutableStateOf(false) }
var showDeleteConfirmDialog by remember { mutableStateOf(false) }
Expand Down Expand Up @@ -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),
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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()) {
Expand Down Expand Up @@ -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)
Expand Down
Loading