-
-
Notifications
You must be signed in to change notification settings - Fork 628
Feat/snapshot resources #1104
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Feat/snapshot resources #1104
Changes from all commits
93c6ba1
97b0d3a
cd49862
2820850
2b71f5f
fb264c3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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( | ||
| 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) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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" } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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。