Performance improvements. Backup status screen
This commit is contained in:
parent
171bf78be8
commit
0c8290f16e
8 changed files with 455 additions and 54 deletions
|
|
@ -15,6 +15,8 @@ import androidx.compose.ui.unit.dp
|
|||
import com.rukira.wowbackup.ui.Screen
|
||||
import com.rukira.wowbackup.ui.config.ConfigScreen
|
||||
import com.rukira.wowbackup.ui.config.ConfigViewModel
|
||||
import com.rukira.wowbackup.ui.status.StatusScreen
|
||||
import com.rukira.wowbackup.ui.status.StatusViewModel
|
||||
|
||||
@Composable
|
||||
fun App(
|
||||
|
|
@ -24,7 +26,14 @@ fun App(
|
|||
MaterialTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
when (currentScreen) {
|
||||
Screen.STATUS -> PlaceholderScreen("Status", "Backup status will appear here.")
|
||||
Screen.STATUS -> {
|
||||
val viewModel = remember { StatusViewModel() }
|
||||
StatusScreen(
|
||||
viewModel = viewModel,
|
||||
onNavigateToConfig = { onNavigate(Screen.CONFIG) },
|
||||
onNavigateToRestore = { onNavigate(Screen.RESTORE) },
|
||||
)
|
||||
}
|
||||
Screen.CONFIG -> {
|
||||
val viewModel = remember { ConfigViewModel() }
|
||||
ConfigScreen(
|
||||
|
|
|
|||
|
|
@ -2,20 +2,33 @@ package com.rukira.wowbackup.backup
|
|||
|
||||
import com.rukira.wowbackup.config.AppConfig
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
private const val THROTTLE_INTERVAL_NS = 100_000_000L // 100ms
|
||||
|
||||
data class BackupProgress(
|
||||
val totalFiles: Int,
|
||||
val completedFiles: Int,
|
||||
|
|
@ -34,14 +47,14 @@ object BackupEngine {
|
|||
private val _progress = MutableStateFlow<BackupProgress?>(null)
|
||||
val progress: StateFlow<BackupProgress?> = _progress.asStateFlow()
|
||||
|
||||
suspend fun runBackup(config: AppConfig): BackupResult {
|
||||
val wowPath = config.wowInstallPath ?: return BackupResult.Failure("WoW install path not set.")
|
||||
val backupPath = config.backupPath ?: return BackupResult.Failure("Backup path not set.")
|
||||
suspend fun runBackup(config: AppConfig): BackupResult = withContext(Dispatchers.IO) {
|
||||
val wowPath = config.wowInstallPath ?: return@withContext BackupResult.Failure("WoW install path not set.")
|
||||
val backupPath = config.backupPath ?: return@withContext BackupResult.Failure("Backup path not set.")
|
||||
val wowDir = File(wowPath)
|
||||
val backupDir = File(backupPath)
|
||||
|
||||
if (!wowDir.exists()) return BackupResult.Failure("WoW directory does not exist: $wowPath")
|
||||
if (!backupDir.exists()) return BackupResult.Failure("Backup directory does not exist: $backupPath")
|
||||
if (!wowDir.exists()) return@withContext BackupResult.Failure("WoW directory does not exist: $wowPath")
|
||||
if (!backupDir.exists()) return@withContext BackupResult.Failure("Backup directory does not exist: $backupPath")
|
||||
|
||||
// Collect source folders
|
||||
val sourceFolders = buildList {
|
||||
|
|
@ -56,24 +69,25 @@ object BackupEngine {
|
|||
}
|
||||
|
||||
if (sourceFolders.isEmpty()) {
|
||||
return BackupResult.Failure("No folders found to backup.")
|
||||
return@withContext BackupResult.Failure("No folders found to backup.")
|
||||
}
|
||||
|
||||
// Count total files
|
||||
val allFiles = sourceFolders.flatMap { folder ->
|
||||
folder.walkTopDown().filter { it.isFile }.toList()
|
||||
// NIO count-only pass — fast, no List<File> allocation
|
||||
val totalFiles = sourceFolders.sumOf { folder ->
|
||||
Files.walk(folder.toPath()).use { stream ->
|
||||
stream.filter { Files.isRegularFile(it) }.count().toInt()
|
||||
}
|
||||
}
|
||||
val totalFiles = allFiles.size
|
||||
logger.info { "Starting backup of $totalFiles files from ${sourceFolders.map { it.name }}" }
|
||||
|
||||
val startTime = System.currentTimeMillis()
|
||||
val timestamp = LocalDateTime.now().format(TIMESTAMP_FORMAT)
|
||||
|
||||
return try {
|
||||
try {
|
||||
if (config.compressionEnabled) {
|
||||
backupZip(wowDir, sourceFolders, allFiles, backupDir, timestamp, totalFiles)
|
||||
backupZip(wowDir, sourceFolders, backupDir, timestamp, totalFiles)
|
||||
} else {
|
||||
backupCopy(wowDir, sourceFolders, allFiles, backupDir, timestamp, totalFiles)
|
||||
backupCopy(wowDir, sourceFolders, backupDir, timestamp, totalFiles)
|
||||
}
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
logger.info { "Backup cancelled" }
|
||||
|
|
@ -91,70 +105,105 @@ object BackupEngine {
|
|||
private suspend fun backupCopy(
|
||||
wowDir: File,
|
||||
sourceFolders: List<File>,
|
||||
allFiles: List<File>,
|
||||
backupDir: File,
|
||||
timestamp: String,
|
||||
totalFiles: Int,
|
||||
): BackupResult {
|
||||
val destDir = File(backupDir, timestamp)
|
||||
destDir.mkdirs()
|
||||
): BackupResult = coroutineScope {
|
||||
val destDir = File(backupDir, timestamp).toPath()
|
||||
Files.createDirectories(destDir)
|
||||
|
||||
var completed = 0
|
||||
var totalSize = 0L
|
||||
val wowPath = wowDir.toPath()
|
||||
val completed = AtomicInteger(0)
|
||||
val totalSize = AtomicLong(0)
|
||||
|
||||
for (folder in sourceFolders) {
|
||||
for (file in folder.walkTopDown().filter { it.isFile }) {
|
||||
currentCoroutineContext().ensureActive()
|
||||
val channel = Channel<Pair<Path, Path>>(capacity = 256)
|
||||
|
||||
val relativePath = file.relativeTo(wowDir)
|
||||
val destFile = File(destDir, relativePath.path)
|
||||
destFile.parentFile?.mkdirs()
|
||||
// Producer: walk filesystem lazily and send (source, relative) pairs
|
||||
val producer = launch {
|
||||
for (folder in sourceFolders) {
|
||||
for (file in folder.walkTopDown().filter { it.isFile }) {
|
||||
ensureActive()
|
||||
val source = file.toPath()
|
||||
val relative = wowPath.relativize(source)
|
||||
channel.send(source to relative)
|
||||
}
|
||||
}
|
||||
channel.close()
|
||||
}
|
||||
|
||||
_progress.value = BackupProgress(totalFiles, completed, relativePath.path)
|
||||
// Workers: consume from channel and copy files in parallel
|
||||
val workerCount = Runtime.getRuntime().availableProcessors().coerceIn(2, 8)
|
||||
val workers = (1..workerCount).map {
|
||||
launch {
|
||||
var lastEmitNs = 0L
|
||||
for ((source, relative) in channel) {
|
||||
ensureActive()
|
||||
|
||||
file.copyTo(destFile, overwrite = true)
|
||||
totalSize += file.length()
|
||||
completed++
|
||||
val dest = destDir.resolve(relative)
|
||||
Files.createDirectories(dest.parent)
|
||||
Files.copy(source, dest, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES)
|
||||
|
||||
totalSize.addAndGet(Files.size(source))
|
||||
val done = completed.incrementAndGet()
|
||||
|
||||
// Throttle progress emissions to ~10/sec per worker
|
||||
val now = System.nanoTime()
|
||||
if (now - lastEmitNs >= THROTTLE_INTERVAL_NS || done == totalFiles) {
|
||||
lastEmitNs = now
|
||||
_progress.value = BackupProgress(totalFiles, done, relative.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val elapsed = System.currentTimeMillis()
|
||||
logger.info { "Backup complete: $completed files copied to $destDir" }
|
||||
workers.joinAll()
|
||||
producer.join()
|
||||
|
||||
return BackupResult.Success(
|
||||
backupPath = destDir,
|
||||
fileCount = completed,
|
||||
sizeBytes = totalSize,
|
||||
durationMs = 0, // caller can measure
|
||||
val finalCompleted = completed.get()
|
||||
logger.info { "Backup complete: $finalCompleted files copied to $destDir" }
|
||||
|
||||
BackupResult.Success(
|
||||
backupPath = destDir.toFile(),
|
||||
fileCount = finalCompleted,
|
||||
sizeBytes = totalSize.get(),
|
||||
durationMs = 0, // caller measures
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun backupZip(
|
||||
wowDir: File,
|
||||
sourceFolders: List<File>,
|
||||
allFiles: List<File>,
|
||||
backupDir: File,
|
||||
timestamp: String,
|
||||
totalFiles: Int,
|
||||
): BackupResult {
|
||||
val zipFile = File(backupDir, "$timestamp.zip")
|
||||
val wowPath = wowDir.toPath()
|
||||
|
||||
var completed = 0
|
||||
var lastEmitNs = 0L
|
||||
|
||||
ZipOutputStream(FileOutputStream(zipFile).buffered()).use { zos ->
|
||||
for (folder in sourceFolders) {
|
||||
for (file in folder.walkTopDown().filter { it.isFile }) {
|
||||
currentCoroutineContext().ensureActive()
|
||||
|
||||
val relativePath = file.relativeTo(wowDir).path
|
||||
_progress.value = BackupProgress(totalFiles, completed, relativePath)
|
||||
val source = file.toPath()
|
||||
val relativePath = wowPath.relativize(source).toString()
|
||||
|
||||
val entry = ZipEntry(relativePath)
|
||||
entry.time = file.lastModified()
|
||||
entry.time = Files.getLastModifiedTime(source).toMillis()
|
||||
zos.putNextEntry(entry)
|
||||
file.inputStream().buffered().use { it.copyTo(zos) }
|
||||
Files.newInputStream(source).buffered().use { it.copyTo(zos) }
|
||||
zos.closeEntry()
|
||||
completed++
|
||||
|
||||
// Throttle progress emissions to ~10/sec
|
||||
val now = System.nanoTime()
|
||||
if (now - lastEmitNs >= THROTTLE_INTERVAL_NS || completed == totalFiles) {
|
||||
lastEmitNs = now
|
||||
_progress.value = BackupProgress(totalFiles, completed, relativePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,12 +8,14 @@ import java.time.format.DateTimeParseException
|
|||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
data class BackupEntry(
|
||||
class BackupEntry(
|
||||
val path: File,
|
||||
val timestamp: LocalDateTime,
|
||||
val isCompressed: Boolean,
|
||||
val sizeBytes: Long,
|
||||
)
|
||||
private val sizeComputer: () -> Long,
|
||||
) {
|
||||
val sizeBytes: Long by lazy { sizeComputer() }
|
||||
}
|
||||
|
||||
object BackupHistory {
|
||||
|
||||
|
|
@ -75,17 +77,15 @@ object BackupHistory {
|
|||
javaTimestamp.hour, javaTimestamp.minute, javaTimestamp.second,
|
||||
)
|
||||
|
||||
val size = if (isZip) {
|
||||
file.length()
|
||||
} else {
|
||||
file.walkTopDown().filter { it.isFile }.sumOf { it.length() }
|
||||
}
|
||||
|
||||
return BackupEntry(
|
||||
path = file,
|
||||
timestamp = timestamp,
|
||||
isCompressed = isZip,
|
||||
sizeBytes = size,
|
||||
sizeComputer = if (isZip) {
|
||||
{ file.length() }
|
||||
} else {
|
||||
{ file.walkTopDown().filter { it.isFile }.sumOf { it.length() } }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ data class AppConfig(
|
|||
val backupHistoryCount: Int = 5,
|
||||
val forceBackupWhileRunning: Boolean = false,
|
||||
val backupWtf: Boolean = true,
|
||||
val backupInterface: Boolean = true,
|
||||
val backupInterface: Boolean = false,
|
||||
val compressionEnabled: Boolean = false,
|
||||
val notificationsEnabled: Boolean = true,
|
||||
val runAtStartup: Boolean = false,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
package com.rukira.wowbackup.platform
|
||||
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import java.awt.Desktop
|
||||
import java.io.File
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
object DesktopActions {
|
||||
|
||||
fun openFolder(path: File) {
|
||||
try {
|
||||
Desktop.getDesktop().open(path)
|
||||
} catch (e: Exception) {
|
||||
logger.error(e) { "Failed to open folder: $path" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -158,7 +158,7 @@ fun ConfigScreen(
|
|||
checked = config.backupInterface,
|
||||
onCheckedChange = { viewModel.updateBackupInterface(it) },
|
||||
label = "Interface",
|
||||
description = "Contains installed addons and their configuration.",
|
||||
description = "Contains installed addons.",
|
||||
)
|
||||
|
||||
// === Options ===
|
||||
|
|
|
|||
|
|
@ -0,0 +1,215 @@
|
|||
package com.rukira.wowbackup.ui.status
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.rukira.wowbackup.backup.BackupScheduler
|
||||
import com.rukira.wowbackup.platform.DesktopActions
|
||||
import java.io.File
|
||||
|
||||
@Composable
|
||||
fun StatusScreen(
|
||||
viewModel: StatusViewModel,
|
||||
onNavigateToConfig: () -> Unit,
|
||||
onNavigateToRestore: () -> Unit,
|
||||
) {
|
||||
val uiState by viewModel.state.collectAsState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text("Status", style = MaterialTheme.typography.headlineMedium)
|
||||
|
||||
// Missing config warning
|
||||
if (!uiState.isConfigured) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
"Configuration incomplete",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Text(
|
||||
"Set up WoW Backup to get started.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(onClick = onNavigateToConfig) {
|
||||
Text("Open Settings")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backup progress (shown when running)
|
||||
if (uiState.isBackupRunning) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
"Backup in progress...",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
val progress = uiState.backupProgress
|
||||
if (progress != null && progress.totalFiles > 0) {
|
||||
LinearProgressIndicator(
|
||||
progress = { progress.completedFiles.toFloat() / progress.totalFiles },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"${progress.completedFiles} / ${progress.totalFiles} files",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
Text(
|
||||
progress.currentFile,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
maxLines = 1,
|
||||
)
|
||||
} else {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last backup card
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text("Last backup", style = MaterialTheme.typography.titleSmall)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
if (uiState.lastBackupTime != null) {
|
||||
Text(uiState.lastBackupTime!!, style = MaterialTheme.typography.bodyLarge)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
val statusColor = if (uiState.lastBackupSuccess == true) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.error
|
||||
}
|
||||
Text(
|
||||
uiState.lastBackupStatus ?: "",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = statusColor,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
"Never",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Next backup card
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text("Next backup", style = MaterialTheme.typography.titleSmall)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
if (uiState.nextBackupTime != null) {
|
||||
Text(uiState.nextBackupTime!!, style = MaterialTheme.typography.bodyLarge)
|
||||
} else {
|
||||
Text(
|
||||
"Not scheduled",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (uiState.isWoWRunning) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
val note = if (uiState.forceBackupEnabled) {
|
||||
"WoW is running (force backup enabled)"
|
||||
} else {
|
||||
"WoW is running — backup will be skipped"
|
||||
}
|
||||
Text(
|
||||
note,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backup count
|
||||
if (uiState.totalBackups > 0) {
|
||||
Text(
|
||||
"${uiState.totalBackups} backup(s) stored",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = { BackupScheduler.triggerBackupNow() },
|
||||
enabled = !uiState.isBackupRunning && uiState.isConfigured,
|
||||
) {
|
||||
Text("Backup Now")
|
||||
}
|
||||
OutlinedButton(onClick = onNavigateToConfig) {
|
||||
Text("Settings")
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
uiState.backupPath?.let { DesktopActions.openFolder(File(it)) }
|
||||
},
|
||||
enabled = uiState.backupPath != null,
|
||||
) {
|
||||
Text("Open Folder")
|
||||
}
|
||||
OutlinedButton(onClick = onNavigateToRestore, enabled = false) {
|
||||
Text("Restore (Coming Soon)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
package com.rukira.wowbackup.ui.status
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.rukira.wowbackup.backup.BackupHistory
|
||||
import com.rukira.wowbackup.backup.BackupProgress
|
||||
import com.rukira.wowbackup.backup.BackupResult
|
||||
import com.rukira.wowbackup.backup.BackupScheduler
|
||||
import com.rukira.wowbackup.config.ConfigManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import java.io.File
|
||||
import kotlin.time.Clock
|
||||
|
||||
data class StatusUiState(
|
||||
val isConfigured: Boolean = false,
|
||||
val lastBackupTime: String? = null,
|
||||
val lastBackupSuccess: Boolean? = null,
|
||||
val lastBackupStatus: String? = null,
|
||||
val nextBackupTime: String? = null,
|
||||
val isWoWRunning: Boolean = false,
|
||||
val forceBackupEnabled: Boolean = false,
|
||||
val isBackupRunning: Boolean = false,
|
||||
val backupProgress: BackupProgress? = null,
|
||||
val totalBackups: Int = 0,
|
||||
val backupPath: String? = null,
|
||||
)
|
||||
|
||||
@OptIn(kotlin.time.ExperimentalTime::class)
|
||||
class StatusViewModel : ViewModel() {
|
||||
|
||||
// Only recomputes when backupPath or lastBackupResult changes — not on every progress tick
|
||||
private val totalBackups = combine(
|
||||
ConfigManager.config.map { it.backupPath }.distinctUntilChanged(),
|
||||
BackupScheduler.state.map { it.lastBackupResult }.distinctUntilChanged(),
|
||||
) { backupPath, _ ->
|
||||
if (backupPath != null) {
|
||||
BackupHistory.listBackups(File(backupPath)).size
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}.flowOn(Dispatchers.IO).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
|
||||
|
||||
val state = combine(
|
||||
ConfigManager.config,
|
||||
BackupScheduler.state,
|
||||
totalBackups,
|
||||
) { config, scheduler, backupCount ->
|
||||
StatusUiState(
|
||||
isConfigured = config.isConfigured,
|
||||
lastBackupTime = scheduler.lastBackupTime?.let { formatRelativeTime(it) },
|
||||
lastBackupSuccess = scheduler.lastBackupResult?.let { it is BackupResult.Success },
|
||||
lastBackupStatus = formatBackupResult(scheduler.lastBackupResult),
|
||||
nextBackupTime = scheduler.nextBackupTime?.let { formatNextTime(it) },
|
||||
isWoWRunning = scheduler.isWoWRunning,
|
||||
forceBackupEnabled = config.forceBackupWhileRunning,
|
||||
isBackupRunning = scheduler.isRunning,
|
||||
backupProgress = scheduler.currentProgress,
|
||||
totalBackups = backupCount,
|
||||
backupPath = config.backupPath,
|
||||
)
|
||||
}.flowOn(Dispatchers.IO).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), StatusUiState())
|
||||
|
||||
private fun formatBackupResult(result: BackupResult?): String? = when (result) {
|
||||
is BackupResult.Success -> {
|
||||
val sizeMb = "%.1f MB".format(result.sizeBytes / (1024.0 * 1024.0))
|
||||
"Success (${result.fileCount} files, $sizeMb)"
|
||||
}
|
||||
is BackupResult.Failure -> "Failed: ${result.reason}"
|
||||
null -> null
|
||||
}
|
||||
|
||||
@OptIn(kotlin.time.ExperimentalTime::class)
|
||||
private fun formatRelativeTime(time: LocalDateTime): String {
|
||||
val tz = TimeZone.currentSystemDefault()
|
||||
val now = Clock.System.now().toLocalDateTime(tz)
|
||||
|
||||
val hoursDiff = (now.date.toEpochDays() - time.date.toEpochDays()) * 24L +
|
||||
(now.hour - time.hour)
|
||||
|
||||
return when {
|
||||
hoursDiff < 1 -> "Less than an hour ago"
|
||||
hoursDiff < 24 -> "${hoursDiff}h ago"
|
||||
hoursDiff < 48 -> "Yesterday"
|
||||
else -> "${hoursDiff / 24} days ago"
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatNextTime(time: LocalDateTime): String {
|
||||
val tz = TimeZone.currentSystemDefault()
|
||||
|
||||
@OptIn(kotlin.time.ExperimentalTime::class)
|
||||
val now = Clock.System.now().toLocalDateTime(tz)
|
||||
val timeStr = "%02d:%02d".format(time.hour, time.minute)
|
||||
|
||||
return when {
|
||||
time.date == now.date -> "Today at $timeStr"
|
||||
time.date.toEpochDays() - now.date.toEpochDays() == 1L -> "Tomorrow at $timeStr"
|
||||
else -> "${time.date} at $timeStr"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue