Skip to content

Commit 29fac2f

Browse files
author
Fastace
committed
云端应用增加快照删除操作
1 parent 2caace3 commit 29fac2f

4 files changed

Lines changed: 261 additions & 90 deletions

File tree

source/core/model/src/main/kotlin/com/xayah/core/model/ResticProgress.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ data class ResticProgressState(
1515
val timeElapsed: String = "00:00",
1616
val currentDataTypeIndex: Int = 0,
1717
val totalDataTypes: Int = 0,
18-
val isCompleted: Boolean = false
18+
val isCompleted: Boolean = false,
19+
val isDeleting: Boolean = false
1920
) {
2021
val progressText: String
2122
get() = "$filesFinished/$filesTotal files"

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

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,73 @@ class ResticRepository @Inject constructor(
672672
}
673673
}
674674

675+
/**
676+
* 从 S3 删除单个快照
677+
*/
678+
suspend fun forgetSnapshotFromS3(
679+
cloudEntity: CloudEntity,
680+
password: String,
681+
snapshotId: String
682+
): Boolean = withContext(Dispatchers.IO) {
683+
try {
684+
val extra = json.decodeFromString<S3Extra>(cloudEntity.extra) ?: return@withContext false
685+
686+
val env = mutableMapOf(
687+
"OPENDAL_BUCKET" to extra.bucket,
688+
"OPENDAL_ROOT" to formatOpenDALRoot(cloudEntity.remote),
689+
"OPENDAL_ENDPOINT" to buildOpenDALEndpoint(extra),
690+
"OPENDAL_SECRET_ID" to extra.accessKeyId,
691+
"OPENDAL_SECRET_KEY" to extra.secretAccessKey,
692+
"RUSTIC_PASSWORD" to password
693+
)
694+
695+
val args = arrayOf("forget", snapshotId, "-r", "opendal:cos")
696+
val result = executeRestic(*args, env = env, usePty = false)
697+
698+
Log.d(TAG, "Forget snapshot 结果: exitCode=${result.code}")
699+
result.code == 0
700+
} catch (e: Exception) {
701+
Log.e(TAG, "删除快照失败: ${e.message}", e)
702+
false
703+
}
704+
}
705+
706+
/**
707+
* 清理 S3 仓库中未引用的数据
708+
*/
709+
suspend fun pruneS3Repository(
710+
cloudEntity: CloudEntity,
711+
password: String
712+
): Boolean = withContext(Dispatchers.IO) {
713+
try {
714+
val extra = json.decodeFromString<S3Extra>(cloudEntity.extra) ?: return@withContext false
715+
716+
val env = mutableMapOf(
717+
"OPENDAL_BUCKET" to extra.bucket,
718+
"OPENDAL_ROOT" to formatOpenDALRoot(cloudEntity.remote),
719+
"OPENDAL_ENDPOINT" to buildOpenDALEndpoint(extra),
720+
"OPENDAL_SECRET_ID" to extra.accessKeyId,
721+
"OPENDAL_SECRET_KEY" to extra.secretAccessKey,
722+
"RUSTIC_PASSWORD" to password
723+
)
724+
725+
val args = mutableListOf(
726+
"prune",
727+
"-r", "opendal:cos",
728+
"--max-unused", "unlimited" // 关键修改:避免重组
729+
)
730+
731+
Log.d(TAG, "执行 S3 仓库 prune (unlimited 模式)")
732+
val result = executeRestic(*args.toTypedArray(), env = env, usePty = false)
733+
734+
Log.d(TAG, "Prune 结果: exitCode=${result.code}")
735+
result.code == 0
736+
} catch (e: Exception) {
737+
Log.e(TAG, "Prune 失败: ${e.message}", e)
738+
false
739+
}
740+
}
741+
675742
suspend fun validateRepository(repoPath: String, password: String): Boolean =
676743
executeRestic("check", "-r", repoPath,
677744
env = mapOf("RUSTIC_PASSWORD" to password),

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

Lines changed: 125 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.xayah.feature.main.restore
22

33
import android.util.Log
44
import androidx.compose.animation.ExperimentalAnimationApi
5+
import androidx.compose.foundation.ExperimentalFoundationApi // 新增
56
import androidx.compose.foundation.layout.Arrangement
67
import androidx.compose.foundation.layout.Column
78
import androidx.compose.foundation.layout.Row
@@ -10,51 +11,46 @@ import androidx.compose.foundation.layout.fillMaxSize
1011
import androidx.compose.foundation.layout.fillMaxWidth
1112
import androidx.compose.foundation.layout.height
1213
import androidx.compose.foundation.layout.padding
13-
import androidx.compose.foundation.layout.size
14+
import androidx.compose.foundation.rememberScrollState
15+
import androidx.compose.foundation.verticalScroll // 新增
16+
import androidx.compose.material3.Button
17+
import androidx.compose.material3.ButtonDefaults
1418
import androidx.compose.material3.ExperimentalMaterial3Api
1519
import androidx.compose.material3.HorizontalDivider
16-
import androidx.compose.material3.Icon
1720
import androidx.compose.material3.MaterialTheme
1821
import androidx.compose.material3.Text
1922
import androidx.compose.material3.TopAppBarDefaults
2023
import androidx.compose.material3.rememberTopAppBarState
2124
import androidx.compose.runtime.Composable
25+
import androidx.compose.runtime.LaunchedEffect
2226
import androidx.compose.runtime.getValue
27+
import androidx.compose.runtime.rememberCoroutineScope
2328
import androidx.compose.ui.Alignment
2429
import androidx.compose.ui.Modifier
2530
import androidx.compose.ui.platform.LocalContext
26-
import androidx.compose.ui.unit.dp
2731
import androidx.hilt.navigation.compose.hiltViewModel
28-
import androidx.navigation.NavController
29-
import androidx.compose.foundation.ExperimentalFoundationApi
30-
import androidx.compose.foundation.rememberScrollState
31-
import androidx.compose.foundation.verticalScroll
3232
import androidx.lifecycle.compose.collectAsStateWithLifecycle
33-
import androidx.compose.runtime.rememberCoroutineScope
34-
import androidx.compose.material.icons.Icons
35-
import androidx.compose.material.icons.filled.Folder
36-
import androidx.compose.runtime.LaunchedEffect
37-
import com.xayah.core.ui.route.MainRoutes
38-
import com.xayah.core.model.OpType
39-
import com.xayah.core.util.navigateSingle
40-
import com.xayah.core.util.localBackupSaveDir
41-
import com.xayah.core.model.Target
33+
import androidx.navigation.NavController
34+
import com.xayah.core.datastore.readBackupDirectory
35+
import com.xayah.core.model.DataType
36+
import com.xayah.core.ui.component.confirm
4237
import com.xayah.core.ui.component.BodyMediumText
38+
import com.xayah.core.ui.component.LocalSlotScope
4339
import com.xayah.core.ui.component.PackageIconImage
44-
import com.xayah.core.datastore.readBackupDirectory
4540
import com.xayah.core.ui.component.ProgressButton
4641
import com.xayah.core.ui.component.Title
4742
import com.xayah.core.ui.component.TitleLargeText
43+
import com.xayah.core.ui.route.MainRoutes
4844
import com.xayah.core.ui.theme.ThemedColorSchemeKeyTokens
4945
import com.xayah.core.ui.theme.value
5046
import com.xayah.core.ui.token.SizeTokens
5147
import com.xayah.core.util.DateUtil
52-
import com.xayah.core.model.DataType
53-
import com.xayah.core.model.ResticProgressState
48+
import com.xayah.core.util.localBackupSaveDir
49+
import com.xayah.core.util.navigateSingle
5450
import kotlinx.coroutines.launch
5551
import java.net.URLEncoder
5652

57-
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalAnimationApi::class)
53+
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class, ExperimentalFoundationApi::class) // 修改: 添加 ExperimentalFoundationApi
5854
@Composable
5955
fun CloudBackupDetailPage(
6056
navController: NavController,
@@ -63,17 +59,23 @@ fun CloudBackupDetailPage(
6359
viewModel: CloudRestoreViewModel = hiltViewModel()
6460
) {
6561
val resticProgress by viewModel.resticProgress.collectAsStateWithLifecycle()
62+
val dialogState = LocalSlotScope.current!!.dialogSlot // 修改: 从 LocalSlotScope 获取
63+
val coroutineScope = rememberCoroutineScope()
64+
val context = LocalContext.current
65+
6666
LaunchedEffect(accountName) {
6767
viewModel.setCloudEntity(accountName)
6868
}
69+
70+
val isDeleting = resticProgress.isDeleting
6971
val isRestoring = resticProgress.totalDataTypes > 0 &&
70-
resticProgress.currentDataTypeIndex < resticProgress.totalDataTypes
71-
val isCompleted = resticProgress.isCompleted
72-
val buttonEnabled = !isRestoring && !isCompleted
73-
val coroutineScope = rememberCoroutineScope()
74-
val context = LocalContext.current
72+
resticProgress.currentDataTypeIndex < resticProgress.totalDataTypes &&
73+
!isDeleting // 排除删除状态
74+
75+
val isCompleted = resticProgress.isCompleted && !isDeleting
76+
val deleteButtonEnabled = !isRestoring && !isCompleted && !isDeleting
77+
val restoreButtonEnabled = !isRestoring && !isCompleted && !isDeleting
7578

76-
// 计算进度信息
7779
val currentProgress = if (resticProgress.bytesTotal > 0) {
7880
resticProgress.bytesWritten.toFloat() / resticProgress.bytesTotal
7981
} else 0f
@@ -98,76 +100,119 @@ fun CloudBackupDetailPage(
98100
}
99101
return if (index < sortedBackups.size) {
100102
sortedBackups[index].dataType.type.uppercase()
101-
} else {
102-
""
103-
}
103+
} else ""
104104
}
105105

106+
val totalSnapshots = group.backups.size
107+
val totalSteps = totalSnapshots + 1 // 快照数量 + 1 (prune)
108+
val currentStep = if (isDeleting) {
109+
resticProgress.currentDataTypeIndex + 1
110+
} else {
111+
0
112+
}
106113
RestoreScaffold(
107114
scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()),
108115
title = "云端备份详情",
109116
actions = {
110-
ProgressButton(
117+
Column(
111118
modifier = Modifier.fillMaxWidth(),
112-
progress = currentProgress,
113-
currentIndex = currentIndex,
114-
totalCount = totalCount,
115-
speed = speed,
116-
progressSize = progressSize,
117-
enabled = buttonEnabled,
118-
text = when {
119-
isRestoring -> {
120-
val currentDataType = getCurrentDataTypeName(group, currentIndex)
121-
"正在恢复${currentDataType}快照"
119+
verticalArrangement = Arrangement.spacedBy(SizeTokens.Level8)
120+
) {
121+
// 删除按钮
122+
ProgressButton(
123+
modifier = Modifier.fillMaxWidth(),
124+
progress = 0f,
125+
currentIndex = if (isDeleting) currentStep else 0,
126+
totalCount = if (isDeleting) totalSteps else 0,
127+
speed = "",
128+
progressSize = "",
129+
enabled = deleteButtonEnabled,
130+
text = if (isDeleting) {
131+
if (currentStep <= totalSnapshots) {
132+
val currentDataType = getCurrentDataTypeName(group, currentIndex)
133+
"正在删除${currentDataType}快照 ($currentStep/$totalSteps)"
134+
} else {
135+
"正在清理存储空间 ($totalSteps/$totalSteps)"
136+
}
137+
} else {
138+
"删除云端快照"
139+
},
140+
onClick = {
141+
if (!isDeleting) {
142+
coroutineScope.launch {
143+
if (dialogState.confirm(
144+
title = "提示",
145+
text = "确认删除该应用的所有云端快照?\n共计 ${group.backups.size} 个快照"
146+
)) {
147+
val success = viewModel.deleteCloudSnapshots(group)
148+
if (success) {
149+
navController.popBackStack()
150+
}
151+
}
152+
}
153+
}
122154
}
123-
isCompleted -> "云端恢复已完成"
124-
else -> "恢复云端快照"
125-
},
126-
onClick = {
127-
if (!isRestoring && !isCompleted) {
128-
coroutineScope.launch {
129-
try {
130-
Log.d("CloudRestore", "用户点击恢复按钮,开始云端恢复流程")
131-
val success = viewModel.restoreFromCloudSnapshots(group)
132-
Log.d("CloudRestore", "云端恢复结果: $success")
133-
134-
if (success) {
135-
Log.d("CloudRestore", "云端恢复成功,准备读取备份目录")
136-
val backupDir = "${context.localBackupSaveDir()}/restore/"
137-
Log.d("CloudRestore", "导航到恢复页面,备份目录: $backupDir")
138-
viewModel.refreshLocalDatabase(backupDir)
139-
140-
// 在数据库刷新完成后计算应用大小
141-
viewModel.calculateSizesForActivatedApps()
142-
val route = MainRoutes.PackagesRestoreProcessingGraph.getRoute(
143-
cloudName = URLEncoder.encode("", "UTF-8"), // 改为空字符串,与本地恢复一致
144-
backupDir = URLEncoder.encode(backupDir, "UTF-8"),
145-
packageName = group.packageName
146-
)
147-
Log.d("Navigation", "构建路由: $route")
148-
navController.navigateSingle(route)
149-
Log.d("Navigation", "导航完成: CloudBackupDetailPage → PackagesRestoreProcessingGraph")
150-
} else {
151-
Log.e("CloudRestore", "云端恢复失败")
155+
)
156+
157+
// 恢复按钮
158+
ProgressButton(
159+
modifier = Modifier.fillMaxWidth(),
160+
progress = currentProgress,
161+
currentIndex = currentIndex,
162+
totalCount = totalCount,
163+
speed = speed,
164+
progressSize = progressSize,
165+
enabled = restoreButtonEnabled,
166+
text = when {
167+
isRestoring -> {
168+
val currentDataType = getCurrentDataTypeName(group, currentIndex)
169+
"正在恢复${currentDataType}快照"
170+
}
171+
isCompleted -> "云端恢复已完成"
172+
else -> "恢复云端快照"
173+
},
174+
onClick = {
175+
if (!isRestoring && !isCompleted && !isDeleting) {
176+
coroutineScope.launch {
177+
try {
178+
Log.d("CloudRestore", "用户点击恢复按钮,开始云端恢复流程")
179+
val success = viewModel.restoreFromCloudSnapshots(group)
180+
Log.d("CloudRestore", "云端恢复结果: $success")
181+
182+
if (success) {
183+
Log.d("CloudRestore", "云端恢复成功,准备读取备份目录")
184+
val backupDir = "${context.localBackupSaveDir()}/restore/"
185+
Log.d("CloudRestore", "导航到恢复页面,备份目录: $backupDir")
186+
viewModel.refreshLocalDatabase(backupDir)
187+
viewModel.calculateSizesForActivatedApps()
188+
189+
val route = MainRoutes.PackagesRestoreProcessingGraph.getRoute(
190+
cloudName = URLEncoder.encode("", "UTF-8"),
191+
backupDir = URLEncoder.encode(backupDir, "UTF-8"),
192+
packageName = group.packageName
193+
)
194+
Log.d("Navigation", "构建路由: $route")
195+
navController.navigateSingle(route)
196+
Log.d("Navigation", "导航完成: CloudBackupDetailPage → PackagesRestoreProcessingGraph")
197+
} else {
198+
Log.e("CloudRestore", "云端恢复失败")
199+
}
200+
} catch (e: Exception) {
201+
Log.e("CloudRestore", "云端恢复流程异常: ${e.message}", e)
152202
}
153-
} catch (e: Exception) {
154-
Log.e("CloudRestore", "云端恢复流程异常: ${e.message}", e)
155203
}
156204
}
157-
} else {
158-
Log.d("CloudRestore", "云端恢复正在进行中,忽略点击")
159205
}
160-
}
161-
)
206+
)
207+
}
162208
}
163209
) {
164-
Column(
210+
Column(
165211
modifier = Modifier
166212
.fillMaxSize()
167213
.padding(SizeTokens.Level16)
168214
.verticalScroll(rememberScrollState())
169215
) {
170-
// APP图标和基本信息
171216
Row(
172217
modifier = Modifier.fillMaxWidth(),
173218
verticalAlignment = Alignment.CenterVertically,
@@ -186,23 +231,17 @@ fun CloudBackupDetailPage(
186231
color = ThemedColorSchemeKeyTokens.Outline.value
187232
)
188233
BodyMediumText(
189-
text = DateUtil.formatTimestamp(
190-
group.timestamp,
191-
DateUtil.PATTERN_YMD_HMS
192-
),
234+
text = DateUtil.formatTimestamp(group.timestamp, DateUtil.PATTERN_YMD_HMS),
193235
color = ThemedColorSchemeKeyTokens.Outline.value
194236
)
195237
}
196238
}
197239

198240
Spacer(modifier = Modifier.height(SizeTokens.Level24))
199241

200-
// 备份类型详情
201242
Title(title = "备份类型详情") {
202243
group.backups.forEach { backup ->
203-
Column(
204-
modifier = Modifier.padding(vertical = SizeTokens.Level8)
205-
) {
244+
Column(modifier = Modifier.padding(vertical = SizeTokens.Level8)) {
206245
Row(
207246
modifier = Modifier.fillMaxWidth(),
208247
horizontalArrangement = Arrangement.SpaceBetween,
@@ -225,10 +264,7 @@ fun CloudBackupDetailPage(
225264
)
226265
}
227266
}
228-
229-
HorizontalDivider(
230-
modifier = Modifier.padding(vertical = SizeTokens.Level4)
231-
)
267+
HorizontalDivider(modifier = Modifier.padding(vertical = SizeTokens.Level4))
232268
}
233269
}
234270
}

0 commit comments

Comments
 (0)