Skip to content

Commit a4e7444

Browse files
author
Fastace
committed
Fix:重构RESTIC 文件恢复逻辑
1 parent 71407ea commit a4e7444

8 files changed

Lines changed: 324 additions & 5 deletions

File tree

source/app/src/main/kotlin/com/xayah/databackup/MainActivity.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import com.xayah.feature.main.settings.restic.ResticRepoPathScreen
5252
import com.xayah.feature.main.settings.restic.ResticPasswordScreen
5353
import com.xayah.feature.main.settings.restic.ResticInitializationScreen
5454
import com.xayah.feature.main.restore.ResticRestorePage
55+
import com.xayah.feature.main.restore.ResticFilesRestorePage
5556
import com.xayah.feature.main.restore.ResticBackupDetailPage
5657
import dagger.hilt.android.AndroidEntryPoint
5758
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -228,6 +229,10 @@ class MainActivity : AppCompatActivity() {
228229
PageDirectory()
229230
}
230231

232+
composable(MainRoutes.ResticFilesRestore.route) {
233+
ResticFilesRestorePage(navController = navController)
234+
}
235+
231236
composable(MainRoutes.ResticRepoPath.route) {
232237
ResticRepoPathScreen()
233238
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.xayah.core.model.restic
2+
3+
import com.xayah.core.model.DataType
4+
import kotlinx.serialization.Serializable
5+
6+
@Serializable
7+
data class ResticBackupFiles(
8+
val mediaName: String,
9+
val fullPath: String,
10+
val timestamp: Long,
11+
val dataType: DataType,
12+
val snapshotId: String,
13+
val snapshotTime: String,
14+
val tags: List<String>
15+
)

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

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ package com.xayah.core.restic
22

33
import android.content.Context
44
import android.util.Log
5-
import com.topjohnwu.superuser.Shell // 确保使用正确的 libsu 包名
5+
import com.topjohnwu.superuser.Shell
66
import com.xayah.core.model.DataType
77
import com.xayah.core.model.restic.ResticBackupApp
8+
import com.xayah.core.model.restic.ResticBackupFiles
89
import dagger.hilt.android.qualifiers.ApplicationContext
910
import kotlinx.coroutines.Dispatchers
1011
import kotlinx.coroutines.withContext
@@ -215,6 +216,38 @@ class ResticRepository @Inject constructor(
215216
} else emptyList()
216217
}
217218

219+
suspend fun listBackedUpFiles(repoPath: String, password: String): List<ResticBackupFiles> {
220+
val snapshots = listSnapshots(repoPath, password)
221+
val files = mutableListOf<ResticBackupFiles>()
222+
snapshots.forEach { snapshot ->
223+
snapshot.tags.forEach { tag ->
224+
val parts = tag.split("-")
225+
if (parts.size >= 3) {
226+
val mediaName = parts.dropLast(2).joinToString("-")
227+
val timestamp = parts.last().toLongOrNull() ?: 0L
228+
val dataType = when (parts[parts.size - 2]) {
229+
"media" -> DataType.PACKAGE_MEDIA
230+
"config" -> DataType.PACKAGE_CONFIG
231+
else -> null
232+
}
233+
if (dataType != null) {
234+
val fullPath = snapshot.paths.firstOrNull() ?: ""
235+
files.add(ResticBackupFiles(
236+
mediaName = mediaName,
237+
fullPath = fullPath,
238+
timestamp = timestamp,
239+
dataType = dataType,
240+
snapshotId = snapshot.id,
241+
snapshotTime = snapshot.time,
242+
tags = snapshot.tags
243+
))
244+
}
245+
}
246+
}
247+
}
248+
return files
249+
}
250+
218251
suspend fun validateRepository(repoPath: String, password: String): Boolean =
219252
executeRestic("snapshots", "--repo", "\"$repoPath\"", "--json", env = mapOf("RESTIC_PASSWORD" to password)).isSuccess
220253

source/core/ui/src/main/kotlin/com/xayah/core/ui/route/Routes.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ sealed class MainRoutes(val route: String) {
5959
data object About : MainRoutes(route = "main_about")
6060
data object Translators : MainRoutes(route = "main_translators")
6161
data object ResticRestore : MainRoutes(route = "main_restic_restore")
62+
data object ResticFilesRestore : MainRoutes(route = "main_restic_files_restore")
6263
data object ResticBackupDetail : MainRoutes(route = "main_restic_backup_detail?${ARG_GROUP}={${ARG_GROUP}}") {
6364
fun getRoute(groupJsonEncoded: String) = "main_restic_backup_detail?${ARG_GROUP}=${groupJsonEncoded}"
6465
}

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -166,13 +166,11 @@ fun PageRestore() {
166166
val filesInteractionSource = remember { MutableInteractionSource() }
167167
Clickable(
168168
title = stringResource(id = R.string.files),
169-
value = if (uiState.medium.isEmpty()) null else
170-
"${context.getString(R.string.args_files_backed_up, uiState.medium.size)}${if (uiState.mediumSize.isNotEmpty()) " (${uiState.mediumSize})" else ""}"
171-
,
169+
value = "从 Restic 块存储中查询已备份的文件",
172170
leadingIcon = ImageVector.vectorResource(id = R.drawable.ic_rounded_folder_open),
173171
interactionSource = filesInteractionSource,
174172
) {
175-
viewModel.emitIntentOnIO(IndexUiIntent.ToFileList(navController))
173+
navController.navigateSingle(MainRoutes.ResticFilesRestore.route)
176174
}
177175

178176
Title(title = stringResource(id = R.string.advanced)) {
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.xayah.feature.main.restore
2+
3+
import com.xayah.core.model.restic.ResticBackupFiles
4+
import kotlinx.serialization.Serializable
5+
6+
@Serializable
7+
data class ResticFileBackupGroup(
8+
val mediaName: String,
9+
val fullPath: String, // 完整路径确保唯一性
10+
val timestamp: Long,
11+
val backups: List<ResticBackupFiles>, // 使用 ResticBackupFiles
12+
val mediaLabel: String = mediaName
13+
) {
14+
val snapshotCount: Int get() = backups.size // 计算属性
15+
}
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
package com.xayah.feature.main.restore
2+
3+
import androidx.compose.foundation.layout.Arrangement
4+
import androidx.compose.foundation.layout.Box
5+
import androidx.compose.foundation.layout.Column
6+
import androidx.compose.foundation.layout.fillMaxSize
7+
import androidx.compose.foundation.layout.padding
8+
import androidx.compose.foundation.lazy.LazyColumn
9+
import androidx.compose.foundation.lazy.items
10+
import androidx.compose.material3.Button
11+
import androidx.compose.material3.CircularProgressIndicator
12+
import androidx.compose.material3.ExperimentalMaterial3Api
13+
import androidx.compose.material3.Text
14+
import androidx.compose.material3.TopAppBarDefaults
15+
import androidx.compose.material3.rememberTopAppBarState
16+
import androidx.compose.runtime.Composable
17+
import androidx.compose.runtime.LaunchedEffect
18+
import androidx.compose.runtime.getValue
19+
import androidx.compose.ui.Alignment
20+
import androidx.compose.ui.Modifier
21+
import androidx.compose.animation.ExperimentalAnimationApi
22+
import androidx.compose.foundation.ExperimentalFoundationApi
23+
import androidx.hilt.navigation.compose.hiltViewModel
24+
import androidx.lifecycle.compose.collectAsStateWithLifecycle
25+
import androidx.navigation.NavController
26+
import androidx.compose.ui.platform.LocalContext
27+
import androidx.compose.foundation.layout.Row
28+
import androidx.compose.foundation.layout.height
29+
import androidx.compose.foundation.layout.IntrinsicSize
30+
import androidx.compose.foundation.layout.size
31+
import androidx.compose.material3.Icon
32+
import androidx.compose.material3.MaterialTheme
33+
import androidx.compose.material3.Surface
34+
import androidx.compose.material3.Text
35+
import androidx.compose.material.icons.Icons
36+
import androidx.compose.material.icons.Icons.Default
37+
import android.content.Context
38+
import androidx.compose.material.icons.filled.Folder
39+
import com.xayah.feature.main.restore.ResticFilesRestoreViewModel
40+
import com.xayah.core.ui.theme.ThemedColorSchemeKeyTokens
41+
import com.xayah.core.ui.theme.value
42+
import com.xayah.core.util.DateUtil
43+
import com.xayah.feature.main.restore.RestoreScaffold
44+
import com.xayah.core.ui.component.BodyMediumText
45+
import com.xayah.core.ui.component.TitleLargeText
46+
import com.xayah.core.ui.token.SizeTokens
47+
import com.xayah.feature.main.restore.ResticFilesRestoreUiState
48+
import com.xayah.feature.main.restore.ResticFileBackupGroup
49+
50+
@OptIn(ExperimentalFoundationApi::class)
51+
@Composable
52+
fun ResticFileBackupGroupItem(
53+
group: ResticFileBackupGroup,
54+
onClick: () -> Unit,
55+
context: Context
56+
) {
57+
Surface(onClick = onClick) {
58+
Row(
59+
modifier = Modifier
60+
.height(IntrinsicSize.Min)
61+
.padding(SizeTokens.Level16),
62+
verticalAlignment = Alignment.CenterVertically,
63+
horizontalArrangement = Arrangement.spacedBy(SizeTokens.Level16)
64+
) {
65+
Icon(
66+
imageVector = Icons.Default.Folder,
67+
contentDescription = null,
68+
modifier = Modifier.size(SizeTokens.Level32),
69+
tint = MaterialTheme.colorScheme.primary
70+
)
71+
72+
Column(modifier = Modifier.weight(1f)) {
73+
TitleLargeText(
74+
text = group.mediaName,
75+
maxLines = 1
76+
)
77+
78+
BodyMediumText(
79+
text = group.fullPath, // 显示完整路径
80+
color = ThemedColorSchemeKeyTokens.Outline.value,
81+
maxLines = 2
82+
)
83+
84+
BodyMediumText(
85+
text = DateUtil.formatTimestamp(
86+
group.timestamp,
87+
DateUtil.PATTERN_YMD_HMS
88+
),
89+
color = ThemedColorSchemeKeyTokens.Outline.value,
90+
maxLines = 1
91+
)
92+
93+
Text(
94+
text = "共计 ${group.snapshotCount} 个快照",
95+
style = MaterialTheme.typography.bodySmall,
96+
color = MaterialTheme.colorScheme.onSurfaceVariant
97+
)
98+
}
99+
}
100+
}
101+
}
102+
103+
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalAnimationApi::class)
104+
@Composable
105+
fun ResticFilesRestorePage(
106+
navController: NavController,
107+
viewModel: ResticFilesRestoreViewModel = hiltViewModel()
108+
) {
109+
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
110+
111+
LaunchedEffect(Unit) {
112+
viewModel.loadBackedUpFiles()
113+
}
114+
115+
RestoreScaffold(
116+
scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()),
117+
title = "从 Restic 备份恢复文件"
118+
) {
119+
Column(
120+
modifier = Modifier
121+
.fillMaxSize()
122+
.padding(SizeTokens.Level16)
123+
) {
124+
when (val currentState = uiState) {
125+
is ResticFilesRestoreUiState.Loading -> {
126+
Box(
127+
modifier = Modifier.fillMaxSize(),
128+
contentAlignment = Alignment.Center
129+
) {
130+
CircularProgressIndicator()
131+
}
132+
}
133+
134+
is ResticFilesRestoreUiState.Success -> {
135+
if (currentState.groups.isEmpty()) {
136+
Box(
137+
modifier = Modifier.fillMaxSize(),
138+
contentAlignment = Alignment.Center
139+
) {
140+
TitleLargeText(text = "没有找到文件备份")
141+
}
142+
} else {
143+
LazyColumn(
144+
verticalArrangement = Arrangement.spacedBy(SizeTokens.Level8)
145+
) {
146+
items(
147+
currentState.groups,
148+
key = { item: ResticFileBackupGroup -> "${item.fullPath}-${item.timestamp}" }
149+
) { group: ResticFileBackupGroup ->
150+
ResticFileBackupGroupItem(
151+
group = group,
152+
onClick = {
153+
// 导航到文件恢复详情页
154+
},
155+
context = LocalContext.current
156+
)
157+
}
158+
}
159+
}
160+
}
161+
162+
is ResticFilesRestoreUiState.Error -> {
163+
Box(
164+
modifier = Modifier.fillMaxSize(),
165+
contentAlignment = Alignment.Center
166+
) {
167+
Column(
168+
horizontalAlignment = Alignment.CenterHorizontally,
169+
verticalArrangement = Arrangement.spacedBy(SizeTokens.Level16)
170+
) {
171+
TitleLargeText(text = "加载失败")
172+
BodyMediumText(text = currentState.message)
173+
Button(onClick = { viewModel.loadBackedUpFiles() }) {
174+
Text("重试")
175+
}
176+
}
177+
}
178+
}
179+
}
180+
}
181+
}
182+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package com.xayah.feature.main.restore
2+
3+
import android.content.Context
4+
import androidx.lifecycle.ViewModel
5+
import androidx.lifecycle.viewModelScope
6+
import com.xayah.core.datastore.readResticPassword
7+
import com.xayah.core.datastore.readResticRepoPath
8+
import com.xayah.core.model.restic.ResticBackupFiles
9+
import com.xayah.core.restic.ResticRepository
10+
import com.xayah.feature.main.restore.ResticFileBackupGroup
11+
import dagger.hilt.android.lifecycle.HiltViewModel
12+
import dagger.hilt.android.qualifiers.ApplicationContext
13+
import kotlinx.coroutines.flow.MutableStateFlow
14+
import kotlinx.coroutines.flow.StateFlow
15+
import kotlinx.coroutines.flow.asStateFlow
16+
import kotlinx.coroutines.launch
17+
import kotlinx.serialization.Serializable
18+
import javax.inject.Inject
19+
20+
sealed class ResticFilesRestoreUiState {
21+
object Loading : ResticFilesRestoreUiState()
22+
data class Success(val groups: List<ResticFileBackupGroup>) : ResticFilesRestoreUiState()
23+
data class Error(val message: String) : ResticFilesRestoreUiState()
24+
}
25+
26+
@HiltViewModel
27+
class ResticFilesRestoreViewModel @Inject constructor(
28+
@ApplicationContext private val context: Context,
29+
private val resticRepo: ResticRepository
30+
) : ViewModel() {
31+
32+
private val _uiState = MutableStateFlow<ResticFilesRestoreUiState>(ResticFilesRestoreUiState.Loading)
33+
val uiState: StateFlow<ResticFilesRestoreUiState> = _uiState.asStateFlow()
34+
35+
fun loadBackedUpFiles() {
36+
viewModelScope.launch {
37+
_uiState.value = ResticFilesRestoreUiState.Loading
38+
39+
val repoPath = context.readResticRepoPath()
40+
val password = context.readResticPassword()
41+
42+
if (repoPath.isNullOrEmpty() || password.isNullOrEmpty()) {
43+
_uiState.value = ResticFilesRestoreUiState.Error("Restic not configured")
44+
return@launch
45+
}
46+
47+
try {
48+
val files = resticRepo.listBackedUpFiles(repoPath, password)
49+
50+
// 按路径分组,确保唯一性
51+
val groupedByPath = files
52+
.groupBy { "${it.fullPath}-${it.timestamp}" }
53+
.map { (key, backups) ->
54+
val first = backups.first()
55+
ResticFileBackupGroup(
56+
mediaName = first.mediaName,
57+
fullPath = first.fullPath,
58+
timestamp = first.timestamp,
59+
backups = backups.sortedBy { it.dataType.type }
60+
)
61+
}
62+
.sortedByDescending { it.timestamp }
63+
64+
_uiState.value = ResticFilesRestoreUiState.Success(groupedByPath)
65+
} catch (e: Exception) {
66+
_uiState.value = ResticFilesRestoreUiState.Error(e.message ?: "Unknown error")
67+
}
68+
}
69+
}
70+
}

0 commit comments

Comments
 (0)