@@ -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 ,
0 commit comments