Skip to content

Commit 1717d00

Browse files
author
Fastace
committed
修正Rustic TTY问题,通过Bussyxbox
1 parent 9de093e commit 1717d00

2 files changed

Lines changed: 212 additions & 16 deletions

File tree

source/core/restic/src/main/kotlin/com/xayah/core/restic/ResticRepository.kt

Lines changed: 199 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,31 +41,45 @@ class ResticRepository @Inject constructor(
4141
}
4242

4343
/**
44-
* 核心执行方法:使用 libsu 执行 Root 命令
44+
* 核心执行方法:使用 libsu 执行 Root 命令
45+
* @param usePty 是否使用 PTY 模拟(用于需要终端的命令,如 --sql)
4546
*/
4647
private suspend fun executeRestic(
4748
vararg args: String,
48-
env: Map<String, String> = emptyMap()
49+
env: Map<String, String> = emptyMap(),
50+
usePty: Boolean = false
4951
): Shell.Result = withContext(Dispatchers.IO) {
5052
val defaultEnv = mutableMapOf(
5153
"HOME" to context.filesDir.absolutePath,
5254
"XDG_CACHE_HOME" to File(context.cacheDir, "restic").absolutePath
5355
)
56+
57+
if (usePty) {
58+
defaultEnv["TERM"] = "xterm-256color"
59+
}
60+
5461
defaultEnv.putAll(env)
5562

56-
// 详细日志记录
63+
val envExports = defaultEnv.map { "export ${it.key}=\"${it.value}\"" }
64+
val resticCommand = "$resticPath ${args.joinToString(" ")}"
65+
66+
val finalCommand = if (usePty) {
67+
val busyboxPath = "${context.filesDir.absolutePath}/bin/busybox"
68+
// 重定向 stdin, stdout, stderr 避免 script 挂起
69+
envExports.joinToString(" && ") +
70+
" && $busyboxPath script -qc \"$resticCommand 2>&1\" /dev/null < /dev/null 2>&1"
71+
} else {
72+
envExports.joinToString(" && ") + " && $resticCommand"
73+
}
74+
5775
Log.d(TAG, "=== Restic Command Debug ===")
5876
Log.d(TAG, "Command: restic ${args.joinToString(" ")}")
77+
Log.d(TAG, "Use PTY: $usePty")
5978
Log.d(TAG, "Environment: ${defaultEnv.entries.joinToString(", ") { "${it.key}=${it.value}" }}")
79+
Log.d(TAG, "Full command: $finalCommand")
6080

61-
val envExports = defaultEnv.map { "export ${it.key}=\"${it.value}\"" }
62-
val command = envExports.joinToString(" && ") + " && $resticPath ${args.joinToString(" ")}"
81+
val result = Shell.cmd(finalCommand).exec()
6382

64-
Log.d(TAG, "Full command: $command")
65-
66-
val result = Shell.cmd(command).exec()
67-
68-
// 详细输出日志
6983
Log.d(TAG, "Exit code: ${result.code}")
7084
if (result.out.isNotEmpty()) {
7185
Log.d(TAG, "STDOUT:\n${result.out.joinToString("\n")}")
@@ -78,6 +92,181 @@ class ResticRepository @Inject constructor(
7892
result
7993
}
8094

95+
/**
96+
* 使用 Rustic OpenDAL SQL 模式从 S3 获取应用备份列表
97+
*/
98+
suspend fun listBackedUpAppsFromS3WithSql(
99+
cloudEntity: CloudEntity,
100+
password: String
101+
): List<ResticBackupApp> = withContext(Dispatchers.IO) {
102+
try {
103+
val extra = json.decodeFromString<S3Extra>(cloudEntity.extra) ?: return@withContext emptyList()
104+
105+
// 创建 cache/sql/ 目录
106+
val sqlDir = File(context.cacheDir, "sql")
107+
if (!sqlDir.exists()) {
108+
sqlDir.mkdirs()
109+
}
110+
111+
// SQL 文件保存到 cache/sql/ 目录
112+
val sqlFile = File(sqlDir, "snapshots_${System.currentTimeMillis()}.sql")
113+
114+
val env = mutableMapOf(
115+
"OPENDAL_BUCKET" to extra.bucket,
116+
"OPENDAL_ROOT" to formatOpenDALRoot(cloudEntity.remote),
117+
"OPENDAL_ENDPOINT" to buildOpenDALEndpoint(extra),
118+
"OPENDAL_SECRET_ID" to extra.accessKeyId,
119+
"OPENDAL_SECRET_KEY" to extra.secretAccessKey,
120+
"RUSTIC_PASSWORD" to password
121+
)
122+
123+
val args = mutableListOf(
124+
"--no-progress",
125+
"snapshots",
126+
"-r", "opendal:cos",
127+
"--sql",
128+
"--sql-output", sqlFile.absolutePath
129+
)
130+
131+
Log.d(TAG, "执行 Rustic SQL 查询,输出路径: ${sqlFile.absolutePath}")
132+
val result = executeRestic(*args.toTypedArray(), env = env, usePty = true)
133+
134+
if (!result.isSuccess) {
135+
Log.e(TAG, "SQL 生成失败 (Exit Code: ${result.code})")
136+
Log.e(TAG, "标准输出: ${result.out.joinToString("\n")}")
137+
Log.e(TAG, "错误输出: ${result.err.joinToString("\n")}")
138+
return@withContext emptyList()
139+
}
140+
141+
if (!sqlFile.exists()) {
142+
Log.e(TAG, "SQL 文件未生成: ${sqlFile.absolutePath}")
143+
return@withContext emptyList()
144+
}
145+
146+
if (sqlFile.length() == 0L) {
147+
Log.e(TAG, "SQL 文件为空")
148+
return@withContext emptyList()
149+
}
150+
151+
val apps = parseSqlFileForApps(sqlFile)
152+
sqlFile.delete() // 清理临时文件
153+
154+
Log.d(TAG, "SQL 模式成功提取 ${apps.size} 个应用备份项")
155+
apps
156+
} catch (e: Exception) {
157+
Log.e(TAG, "listBackedUpAppsFromS3WithSql 异常", e)
158+
emptyList()
159+
}
160+
}
161+
162+
/**
163+
* 格式化 OpenDAL Root 路径
164+
* 确保以 / 开头,以 / 结尾(与您的命令示例一致)
165+
*/
166+
private fun formatOpenDALRoot(remotePath: String): String {
167+
val trimmed = remotePath.trim()
168+
if (trimmed.isEmpty() || trimmed == "/") {
169+
return "/"
170+
}
171+
val withLeading = if (trimmed.startsWith("/")) trimmed else "/$trimmed"
172+
return if (withLeading.endsWith("/")) withLeading else "$withLeading/"
173+
}
174+
175+
/**
176+
* 构建 OpenDAL Endpoint
177+
* 格式: protocol://endpoint (不包含 bucket)
178+
*/
179+
private fun buildOpenDALEndpoint(extra: S3Extra): String {
180+
val protocol = when (extra.protocol) {
181+
S3Protocol.HTTP -> "http"
182+
S3Protocol.HTTPS -> "https"
183+
}
184+
return "$protocol://${extra.endpoint.trim().removeSuffix("/")}"
185+
}
186+
187+
/**
188+
* 从 SQL 文件解析应用备份信息(使用 v_snapshots_full 视图)
189+
*/
190+
private fun parseSqlFileForApps(sqlFile: File): List<ResticBackupApp> {
191+
val db = android.database.sqlite.SQLiteDatabase.openOrCreateDatabase(":memory:", null)
192+
try {
193+
// 执行 SQL 文件中的所有语句
194+
sqlFile.readText().split(";").forEach { stmt ->
195+
val trimmed = stmt.trim()
196+
if (trimmed.isNotEmpty()) {
197+
try {
198+
db.execSQL(trimmed + ";")
199+
} catch (e: Exception) {
200+
// 忽略 COMMIT 等可能的语法差异
201+
Log.w(TAG, "SQL 语句执行警告: ${e.message}")
202+
}
203+
}
204+
}
205+
206+
// 直接从视图查询,tags_flat 包含所有标签(用 char(31) 分隔)
207+
val query = """
208+
SELECT
209+
id,
210+
time,
211+
tags_flat
212+
FROM v_snapshots_full
213+
WHERE tags_flat IS NOT NULL
214+
"""
215+
216+
val cursor = db.rawQuery(query, null)
217+
val apps = mutableListOf<ResticBackupApp>()
218+
219+
while (cursor.moveToNext()) {
220+
val snapshotId = cursor.getString(0)
221+
val snapshotTime = cursor.getString(1)
222+
val tagsFlat = cursor.getString(2) ?: continue
223+
224+
// tags_flat 使用 char(31) 作为分隔符
225+
val tags = tagsFlat.split(31.toChar()).filter { it.isNotEmpty() }
226+
227+
tags.forEach { tag ->
228+
val parts = tag.split("-")
229+
// 标签格式: user_0-com.package.name-1768755852316-apk
230+
if (parts.size >= 4 && parts[0].startsWith("user_")) {
231+
try {
232+
val userId = parts[0].split("_").lastOrNull()?.toIntOrNull() ?: 0
233+
val packageName = parts[1]
234+
val timestamp = parts[2].toLongOrNull() ?: 0L
235+
val dataType = when (parts[3]) {
236+
"apk" -> DataType.PACKAGE_APK
237+
"user" -> DataType.PACKAGE_USER
238+
"user_de" -> DataType.PACKAGE_USER_DE
239+
"data" -> DataType.PACKAGE_DATA
240+
"obb" -> DataType.PACKAGE_OBB
241+
"media" -> DataType.PACKAGE_MEDIA
242+
"config" -> DataType.PACKAGE_CONFIG
243+
else -> null
244+
}
245+
246+
if (dataType != null) {
247+
apps.add(ResticBackupApp(
248+
packageName = packageName,
249+
userId = userId,
250+
timestamp = timestamp,
251+
dataType = dataType,
252+
snapshotId = snapshotId,
253+
snapshotTime = snapshotTime,
254+
tags = tags
255+
))
256+
}
257+
} catch (e: Exception) {
258+
Log.e(TAG, "解析标签出错: $tag", e)
259+
}
260+
}
261+
}
262+
}
263+
cursor.close()
264+
return apps
265+
} finally {
266+
db.close()
267+
}
268+
}
269+
81270
// --- 恢复快照(包含详细诊断逻辑) ---
82271
suspend fun restoreSnapshot(
83272
repoPath: String,

source/feature/main/restore/src/main/kotlin/com/xayah/feature/main/restore/CloudRestoreViewModel.kt

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,13 @@ class CloudRestoreViewModel @Inject constructor(
5656
fun setCloudEntity(accountName: String) {
5757
val cleanAccountName = accountName.replace("accountName=", "")
5858
this.accountName = cleanAccountName // 存储账户名
59-
Log.e("CloudRestore", "setCloudEntity 被调用,账户名: $accountName")
59+
Log.d("CloudRestore", "setCloudEntity 被调用,账户名: $accountName")
6060
viewModelScope.launch {
61-
Log.e("CloudRestore", "开始查询云端账户: $cleanAccountName")
61+
Log.d("CloudRestore", "开始查询云端账户: $cleanAccountName")
6262
val cloudEntity = cloudRepo.queryByName(cleanAccountName)
6363
if (cloudEntity != null) {
64-
Log.e("CloudRestore", "找到云端账户: ${cloudEntity.name}, 类型: ${cloudEntity.type}")
65-
Log.e("CloudRestore", "准备调用 loadCloudBackedUpApps")
64+
Log.d("CloudRestore", "找到云端账户: ${cloudEntity.name}, 类型: ${cloudEntity.type}")
65+
Log.d("CloudRestore", "准备调用 loadCloudBackedUpApps")
6666
loadCloudBackedUpApps(cloudEntity)
6767
} else {
6868
Log.e("CloudRestore", "云端账户查询失败: $cleanAccountName")
@@ -72,7 +72,7 @@ class CloudRestoreViewModel @Inject constructor(
7272
}
7373

7474
fun loadCloudBackedUpApps(cloudEntity: CloudEntity) {
75-
Log.e("CloudRestore", "=== loadCloudBackedUpApps 开始 ===")
75+
Log.d("CloudRestore", "=== loadCloudBackedUpApps 开始 ===")
7676
viewModelScope.launch {
7777
_uiState.value = CloudRestoreUiState.Loading
7878
val password = context.readS3ResticPassword()
@@ -82,7 +82,14 @@ class CloudRestoreViewModel @Inject constructor(
8282
}
8383
try {
8484
// 明确指定类型,消除歧义
85-
val apps: List<ResticBackupApp> = resticRepo.listBackedUpAppsFromS3(cloudEntity, password)
85+
val apps: List<ResticBackupApp> = try {
86+
// 优先使用 SQL 模式(性能更好)
87+
resticRepo.listBackedUpAppsFromS3WithSql(cloudEntity, password)
88+
} catch (e: Exception) {
89+
Log.w("CloudRestore", "SQL 模式失败,回退到 JSON 模式", e)
90+
// Fallback 到现有 JSON 模式
91+
resticRepo.listBackedUpAppsFromS3(cloudEntity, password)
92+
}
8693

8794
val groupedBackups = apps
8895
.groupBy { "${it.userId}-${it.packageName}-${it.timestamp}" }

0 commit comments

Comments
 (0)