Performance improvements. Backup status screen

This commit is contained in:
Rukira 2026-03-04 14:19:19 +00:00
parent 171bf78be8
commit 0c8290f16e
8 changed files with 455 additions and 54 deletions

View file

@ -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(

View file

@ -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)
}
}
}
}

View file

@ -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() } }
},
)
}
}

View file

@ -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,

View file

@ -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" }
}
}
}

View file

@ -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 ===

View file

@ -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)")
}
}
}
}

View file

@ -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"
}
}
}