Skip to content

Commit e4417cb

Browse files
author
Developer
committed
Merge branch 'feat/backup-targz' into main
2 parents acc1ac7 + 2dc6476 commit e4417cb

7 files changed

Lines changed: 192 additions & 128 deletions

File tree

app/src/main/java/com/aicode/feature/backup/data/BackupManagerImpl.kt

Lines changed: 76 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ import com.aicode.feature.agent.domain.mcp.McpManager
1212
import com.aicode.feature.agent.domain.permission.PermissionRulesRepository
1313
import com.aicode.feature.backup.domain.AgentMessageDto
1414
import com.aicode.feature.backup.domain.BackupCrypto
15-
import com.aicode.feature.backup.domain.BackupFormat
1615
import com.aicode.feature.backup.domain.BackupManager
16+
import com.aicode.feature.backup.domain.BackupOptions
1717
import com.aicode.feature.backup.domain.BackupSnapshot
1818
import com.aicode.feature.backup.domain.ChatSessionDto
1919
import com.aicode.feature.backup.domain.GitCredentialDto
@@ -34,11 +34,18 @@ import com.aicode.feature.settings.data.repository.VisionModelSettingsRepository
3434
import com.aicode.feature.workspace.data.local.dao.RemoteConnectionDao
3535
import com.aicode.feature.workspace.data.local.entity.RemoteConnectionEntity
3636
import com.aicode.feature.workspace.data.local.entity.RemoteMountEntity
37+
import com.aicode.feature.workspace.data.repository.WorkspaceRepository
3738
import com.aicode.feature.workspace.domain.model.RemoteProtocol
3839
import dagger.hilt.android.qualifiers.ApplicationContext
3940
import kotlinx.coroutines.Dispatchers
4041
import kotlinx.coroutines.withContext
4142
import kotlinx.serialization.json.Json
43+
import org.apache.commons.compress.archivers.tar.TarArchiveEntry
44+
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream
45+
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream
46+
import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream
47+
import java.io.ByteArrayInputStream
48+
import java.io.ByteArrayOutputStream
4249
import javax.inject.Inject
4350
import javax.inject.Singleton
4451

@@ -58,7 +65,8 @@ class BackupManagerImpl @Inject constructor(
5865
private val keepaliveSettingsRepository: KeepaliveSettingsRepository,
5966
private val logSettingsRepository: LogSettingsRepository,
6067
private val visionModelSettingsRepository: VisionModelSettingsRepository,
61-
private val syncSettingsRepository: SyncSettingsRepository
68+
private val syncSettingsRepository: SyncSettingsRepository,
69+
private val workspaceRepository: WorkspaceRepository
6270
) : BackupManager {
6371

6472
private val json = Json {
@@ -73,22 +81,22 @@ class BackupManagerImpl @Inject constructor(
7381
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: ""
7482
}.getOrDefault("")
7583

76-
override suspend fun export(password: CharArray): ByteArray = withContext(Dispatchers.IO) {
77-
val providers = aiProviderDao.getAllProvidersOnce().map { it.toDto() }
78-
val credentials = gitCredentialDao.getAllOnce().map { it.toDto() }
79-
val connections = remoteConnectionDao.getAllConnectionsOnce().map { it.toDto() }
80-
val mounts = remoteConnectionDao.getAllMountsOnce().map { it.toDto() }
81-
val sessions = chatSessionDao.getAllOnce().map { it.toDto() }
82-
val messages = agentMessageDao.getAllOnce().map { it.toDto() }
83-
val todos = todoItemDao.getAllOnce().map { it.toDto() }
84-
val mcpServers = mcpConfigRepository.getServers()
85-
val globalRules = permissionRulesRepository.getGlobalRulesOnce()
86-
val themeMode = themeSettingsRepository.snapshot()
87-
val keepalive = keepaliveSettingsRepository.snapshot()
88-
val logLevel = logSettingsRepository.snapshot()
89-
val visionProviderId = visionModelSettingsRepository.getVisionProviderId()
90-
val visionModel = visionModelSettingsRepository.getVisionModel()
91-
val syncSettings = syncSettingsRepository.snapshot()
84+
override suspend fun export(password: CharArray?, options: BackupOptions): ByteArray = withContext(Dispatchers.IO) {
85+
val providers = if (options.providers) aiProviderDao.getAllProvidersOnce().map { it.toDto() } else emptyList()
86+
val credentials = if (options.gitCredentials) gitCredentialDao.getAllOnce().map { it.toDto() } else emptyList()
87+
val connections = if (options.remoteConnections) remoteConnectionDao.getAllConnectionsOnce().map { it.toDto() } else emptyList()
88+
val mounts = if (options.remoteConnections) remoteConnectionDao.getAllMountsOnce().map { it.toDto() } else emptyList()
89+
val sessions = if (options.chatHistory) chatSessionDao.getAllOnce().map { it.toDto() } else emptyList()
90+
val messages = if (options.chatHistory) agentMessageDao.getAllOnce().map { it.toDto() } else emptyList()
91+
val todos = if (options.chatHistory) todoItemDao.getAllOnce().map { it.toDto() } else emptyList()
92+
val mcpServers = if (options.mcpServers) mcpConfigRepository.getServers() else emptyList()
93+
val globalRules = if (options.permissionRules) permissionRulesRepository.getGlobalRulesOnce() else emptyList()
94+
val themeMode = if (options.appSettings) themeSettingsRepository.snapshot() else null
95+
val keepalive = if (options.appSettings) keepaliveSettingsRepository.snapshot() else false
96+
val logLevel = if (options.appSettings) logSettingsRepository.snapshot() else null
97+
val visionProviderId = if (options.appSettings) visionModelSettingsRepository.getVisionProviderId() else ""
98+
val visionModel = if (options.appSettings) visionModelSettingsRepository.getVisionModel() else ""
99+
val syncSettings = if (options.appSettings) syncSettingsRepository.snapshot() else null
92100

93101
val snapshot = BackupSnapshot(
94102
schemaVersion = currentSchemaVersion(),
@@ -111,21 +119,31 @@ class BackupManagerImpl @Inject constructor(
111119
syncSettings = syncSettings
112120
)
113121
val plain = json.encodeToString(BackupSnapshot.serializer(), snapshot).toByteArray(Charsets.UTF_8)
114-
val salt = BackupCrypto.newSalt()
115-
val iv = BackupCrypto.newIv()
116-
val ciphertext = BackupCrypto.encrypt(plain, password, salt, iv)
117-
BackupFormat.pack(BackupSnapshot.FORMAT_VERSION, salt, iv, ciphertext)
122+
val tarGz = tarGz(plain)
123+
if (password != null && password.isNotEmpty()) {
124+
val salt = BackupCrypto.newSalt()
125+
val iv = BackupCrypto.newIv()
126+
BackupCrypto.encrypt(tarGz, password, salt, iv)
127+
} else {
128+
tarGz
129+
}
118130
}
119131

120-
override suspend fun import(data: ByteArray, password: CharArray): Result<RestoreStats> = withContext(Dispatchers.IO) {
132+
override suspend fun import(data: ByteArray, password: CharArray?): Result<RestoreStats> = withContext(Dispatchers.IO) {
121133
runCatching {
122-
val header = BackupFormat.unpack(data)
123-
?: error("不是有效的 AiCode 备份文件")
124-
if (header.formatVersion > BackupSnapshot.FORMAT_VERSION) {
125-
error("备份格式版本 ${header.formatVersion} 高于本应用支持版本,请升级应用")
134+
val tarGz = if (password != null && password.isNotEmpty()) {
135+
val salt = ByteArray(BackupCrypto.SALT_LEN)
136+
val iv = ByteArray(BackupCrypto.IV_LEN)
137+
require(data.size >= salt.size + iv.size) { "不是有效的 AiCode 备份文件" }
138+
System.arraycopy(data, 0, salt, 0, salt.size)
139+
System.arraycopy(data, salt.size, iv, 0, iv.size)
140+
val ciphertext = data.copyOfRange(salt.size + iv.size, data.size)
141+
BackupCrypto.decrypt(ciphertext, password, salt, iv)
142+
} else {
143+
data
126144
}
127-
val ciphertext = data.copyOfRange(header.ciphertextOffset, data.size)
128-
val plain = BackupCrypto.decrypt(ciphertext, password, header.salt, header.iv)
145+
val plain = unTarGz(tarGz)
146+
?: error("不是有效的 AiCode 备份文件")
129147
val snapshot = json.decodeFromString(BackupSnapshot.serializer(), String(plain, Charsets.UTF_8))
130148
if (snapshot.schemaVersion > currentSchemaVersion()) {
131149
error("备份的数据库版本 v${snapshot.schemaVersion} 高于本应用 v${currentSchemaVersion()},请升级应用")
@@ -134,6 +152,33 @@ class BackupManagerImpl @Inject constructor(
134152
}
135153
}
136154

155+
private fun tarGz(content: ByteArray): ByteArray {
156+
val baos = ByteArrayOutputStream()
157+
GzipCompressorOutputStream(baos).use { gz ->
158+
TarArchiveOutputStream(gz).use { tar ->
159+
tar.putArchiveEntry(TarArchiveEntry("snapshot.json").apply { size = content.size.toLong() })
160+
tar.write(content)
161+
tar.closeArchiveEntry()
162+
}
163+
}
164+
return baos.toByteArray()
165+
}
166+
167+
private fun unTarGz(data: ByteArray): ByteArray? = runCatching {
168+
GzipCompressorInputStream(ByteArrayInputStream(data)).use { gz ->
169+
org.apache.commons.compress.archivers.tar.TarArchiveInputStream(gz).use { tar ->
170+
var entry = tar.nextEntry
171+
while (entry != null) {
172+
if (entry.name == "snapshot.json") {
173+
return@use tar.readBytes()
174+
}
175+
entry = tar.nextEntry
176+
}
177+
null
178+
}
179+
}
180+
}.getOrNull()
181+
137182
private suspend fun restore(snapshot: BackupSnapshot): RestoreStats {
138183
if (snapshot.providers.isNotEmpty()) {
139184
aiProviderDao.insertAllProviders(snapshot.providers.map { it.toEntity() })
@@ -148,7 +193,8 @@ class BackupManagerImpl @Inject constructor(
148193
remoteConnectionDao.insertAllMounts(snapshot.remoteMounts.map { it.toEntity() })
149194
}
150195
if (snapshot.chatSessions.isNotEmpty()) {
151-
chatSessionDao.upsertAll(snapshot.chatSessions.map { it.toEntity() })
196+
val currentWorkspacePath = workspaceRepository.currentPath()
197+
chatSessionDao.upsertAll(snapshot.chatSessions.map { it.copy(workspacePath = currentWorkspacePath).toEntity() })
152198
}
153199
if (snapshot.agentMessages.isNotEmpty()) {
154200
agentMessageDao.insertAll(snapshot.agentMessages.map { it.toEntity() })

app/src/main/java/com/aicode/feature/backup/domain/BackupCrypto.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,13 @@ object BackupCrypto {
1919
private const val KEY_LEN_BITS = 256
2020
private const val GCM_TAG_BITS = 128
2121

22+
const val SALT_LEN = 16
23+
const val IV_LEN = 12
24+
2225
private val random = SecureRandom()
2326

24-
fun newSalt(): ByteArray = ByteArray(BackupFormat.SALT_LEN).also { random.nextBytes(it) }
25-
fun newIv(): ByteArray = ByteArray(BackupFormat.IV_LEN).also { random.nextBytes(it) }
27+
fun newSalt(): ByteArray = ByteArray(SALT_LEN).also { random.nextBytes(it) }
28+
fun newIv(): ByteArray = ByteArray(IV_LEN).also { random.nextBytes(it) }
2629

2730
private fun deriveKey(password: CharArray, salt: ByteArray): SecretKey {
2831
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")

app/src/main/java/com/aicode/feature/backup/domain/BackupFormat.kt

Lines changed: 0 additions & 46 deletions
This file was deleted.

app/src/main/java/com/aicode/feature/backup/domain/BackupManager.kt

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,34 @@
11
package com.aicode.feature.backup.domain
22

33
/**
4-
* 备份编排器:从各数据源采集快照并加密打包,或反向解密还原
4+
* 备份编排器:从各数据源采集快照并打包,或反向解包还原
55
*
6-
* 导出:采集 → 序列化为 JSON → AES-GCM 加密 → 打包成备份文件字节
7-
* 导入:解包文件头解密 → 反序列化 → 校验 schemaVersion → 合并写入各数据源。
6+
* 导出:按 [BackupOptions] 采集 → 序列化为 JSON → tar.gz 压缩 → 口令非空则 AES-GCM 加密。
7+
* 导入:口令非空则先解密解 tar.gz → 反序列化 → 校验 schemaVersion → 合并写入各数据源。
88
*/
99
interface BackupManager {
10-
/** 生成加密备份文件字节*/
11-
suspend fun export(password: CharArray): ByteArray
10+
/** 生成备份文件字节。password 为 null 或空时不加密,输出明文 tar.gz*/
11+
suspend fun export(password: CharArray?, options: BackupOptions): ByteArray
1212

1313
/**
14-
* 解密并还原备份文件。
14+
* 解包并还原备份文件。
15+
* @param password 备份未加密时传 null 或空;加密文件必须提供正确口令。
1516
* @return 还原统计(各数据段条目数);口令错误/格式不符/版本过高时返回失败。
1617
*/
17-
suspend fun import(data: ByteArray, password: CharArray): Result<RestoreStats>
18+
suspend fun import(data: ByteArray, password: CharArray?): Result<RestoreStats>
1819
}
1920

21+
/** 导出数据范围选项;未勾选的段在快照中保持空值,导入时跳过。 */
22+
data class BackupOptions(
23+
val providers: Boolean = true,
24+
val gitCredentials: Boolean = true,
25+
val remoteConnections: Boolean = true,
26+
val chatHistory: Boolean = true,
27+
val mcpServers: Boolean = true,
28+
val permissionRules: Boolean = true,
29+
val appSettings: Boolean = true
30+
)
31+
2032
data class RestoreStats(
2133
val providers: Int = 0,
2234
val gitCredentials: Int = 0,

app/src/main/java/com/aicode/feature/backup/domain/BackupSnapshot.kt

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import kotlinx.serialization.Serializable
1313
*/
1414
@Serializable
1515
data class BackupSnapshot(
16-
val formatVersion: Int = FORMAT_VERSION,
1716
val schemaVersion: Int,
1817
val appVersion: String = "",
1918
val createdAt: Long,
@@ -32,12 +31,7 @@ data class BackupSnapshot(
3231
val visionProviderId: String = "",
3332
val visionModel: String = "",
3433
val syncSettings: SyncSettingsSnapshot? = null
35-
) {
36-
companion object {
37-
/** 备份快照自身格式版本(区别于 Room schemaVersion)。格式不兼容时递增。 */
38-
const val FORMAT_VERSION = 1
39-
}
40-
}
34+
)
4135

4236
@Serializable
4337
data class ProviderDto(

0 commit comments

Comments
 (0)