diff --git a/SeforimApp/src/commonMain/composeResources/values/strings.xml b/SeforimApp/src/commonMain/composeResources/values/strings.xml
index 57aa3c3b3..76f336f09 100644
--- a/SeforimApp/src/commonMain/composeResources/values/strings.xml
+++ b/SeforimApp/src/commonMain/composeResources/values/strings.xml
@@ -560,6 +560,8 @@
שגיאה לא ידועה
חזור
נסה שוב
+ לא ניתן למחוק את מסד הנתונים הקודם — ייתכן שהוא נעול על ידי תוכנה אחרת (אנטי-וירוס או חיפוש Windows) או על ידי מופע קודם של היישום. סגרו תוכנות אלו, הפעילו מחדש את היישום ונסו שוב.
+ אין מספיק שטח פנוי בכונן להתקנת מסד הנתונים. פנו מקום בכונן ונסו שוב.
עדכון מקובץ מקומי
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/DatabaseCleanupUseCase.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/DatabaseCleanupUseCase.kt
index b76c450f0..959b49042 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/DatabaseCleanupUseCase.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/DatabaseCleanupUseCase.kt
@@ -1,87 +1,147 @@
package io.github.kdroidfilter.seforimapp.features.database.update
import io.github.kdroidfilter.seforimapp.core.settings.AppSettings
+import io.github.kdroidfilter.seforimapp.framework.database.PendingDbCleanup
+import io.github.kdroidfilter.seforimapp.framework.database.resetDatabasePathCache
import io.github.kdroidfilter.seforimapp.logger.debugln
+import io.github.kdroidfilter.seforimapp.logger.warnln
import io.github.vinceglb.filekit.FileKit
import io.github.vinceglb.filekit.databasesDir
import io.github.vinceglb.filekit.path
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
+import java.nio.file.Files
import kotlin.coroutines.cancellation.CancellationException
+/**
+ * Removes a previously installed database and all of its companion artifacts
+ * (Lucene indexes, catalog, lexical dictionary, version stamp, download leftovers)
+ * before a fresh install.
+ *
+ * Deletion is authoritative: it targets the directory of the *actual* configured
+ * database ([AppSettings.getDatabasePath]) as well as the default databases
+ * directory, so a database installed by an older build in a non-default location is
+ * still removed. NIO [Files.deleteIfExists] is used so a locked file — common on
+ * Windows when an antivirus, the Windows Search indexer, or a leftover handle holds
+ * it — surfaces an exception we can log instead of the silent `false` returned by
+ * [File.delete]. Files that cannot be removed are reported via [CleanupResult.Incomplete]
+ * and recorded for a retry at the next launch via [PendingDbCleanup], so the caller
+ * can refuse to start a multi-GB download that would otherwise fill the disk.
+ */
class DatabaseCleanupUseCase {
- suspend fun cleanupDatabaseFiles(): Unit =
- withContext(Dispatchers.IO) {
- try {
- // Get databases directory
- val dbDir = File(FileKit.databasesDir.path)
- if (!dbDir.exists()) {
- return@withContext // Nothing to clean
- }
-
- // Get current database path to understand naming pattern
- val currentDbPath = AppSettings.getDatabasePath()
- val currentDbFile = currentDbPath?.let { File(it) }
-
- // Clear database path in settings first
- AppSettings.setDatabasePath(null)
-
- // List all files in databases directory
- val files = dbDir.listFiles() ?: emptyArray()
+ sealed interface CleanupResult {
+ /** Every known artifact was removed (or was already absent). */
+ data class Success(
+ val freedBytes: Long,
+ ) : CleanupResult
- for (file in files) {
- val fileName = file.name.lowercase()
-
- // Delete database files (.db)
- if (fileName.endsWith(".db")) {
- runCatching { file.delete() }
- }
+ /** Some files could not be deleted (e.g. locked by another process). */
+ data class Incomplete(
+ val undeletable: List,
+ ) : CleanupResult
+ }
- // Delete Lucene index directories (.lucene, .lookup.lucene)
- if (fileName.endsWith(".lucene") || fileName.contains(".lookup.lucene")) {
- runCatching { deleteDirectory(file) }
- }
+ suspend fun cleanupDatabaseFiles(): CleanupResult =
+ withContext(Dispatchers.IO) {
+ val currentDbPath = AppSettings.getDatabasePath()
- // Delete version files (release_info.txt)
- if (fileName == "release_info.txt") {
- runCatching { file.delete() }
- }
+ // The old database is going away: forget the recorded path and the cached
+ // resolution so the app re-resolves the freshly installed location later.
+ AppSettings.setDatabasePath(null)
+ resetDatabasePathCache()
- // Delete catalog files (.proto)
- if (fileName.endsWith(".proto") || fileName == "catalog.proto") {
- runCatching { file.delete() }
- }
+ // Candidate directories: the real DB directory (may be non-default for
+ // legacy installs) plus the current default databases directory.
+ val dirs = LinkedHashSet()
+ currentDbPath?.let { File(it).parentFile?.let(dirs::add) }
+ runCatching { File(FileKit.databasesDir.path) }.getOrNull()?.let(dirs::add)
- // Delete temporary/download files
- if (fileName.endsWith(".tar.zst") ||
- fileName.endsWith(".part01") ||
- fileName.endsWith(".part02") ||
- fileName.endsWith(".zst") ||
- fileName.endsWith(".tmp")
- ) {
- runCatching { file.delete() }
- }
+ var freed = 0L
+ val undeletable = mutableListOf()
- // If we know the current database file, also clean related files
- if (currentDbFile != null && file.absolutePath.startsWith(currentDbFile.absolutePath)) {
- runCatching { file.delete() }
+ try {
+ for (dir in dirs) {
+ val files = dir.takeIf { it.exists() }?.listFiles() ?: continue
+ for (file in files) {
+ if (!isDatabaseArtifact(file)) continue
+ val size = sizeOf(file)
+ if (deleteRecursively(file)) {
+ freed += size
+ } else {
+ undeletable += file
+ }
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
- // Log error but don't fail the cleanup process
- debugln { "Warning: Error during database cleanup: ${e.message}" }
+ warnln { "[DatabaseCleanup] Unexpected error during cleanup: ${e.message}" }
+ }
+
+ if (undeletable.isEmpty()) {
+ debugln { "[DatabaseCleanup] Removed previous database artifacts, freed ${freed / (1024 * 1024)} MB" }
+ CleanupResult.Success(freed)
+ } else {
+ warnln {
+ "[DatabaseCleanup] ${undeletable.size} file(s) could not be deleted (locked?): " +
+ undeletable.joinToString { it.name }
+ }
+ PendingDbCleanup.record(undeletable)
+ CleanupResult.Incomplete(undeletable)
}
}
- private fun deleteDirectory(directory: File) {
- if (directory.isDirectory) {
- directory.listFiles()?.forEach { child ->
- deleteDirectory(child)
+ /** True for files this app installs alongside the database and must remove on reinstall. */
+ private fun isDatabaseArtifact(file: File): Boolean {
+ val name = file.name.lowercase()
+ return name.endsWith(".db") ||
+ // seforim.db, lexical.db
+ name.endsWith(".db-wal") ||
+ name.endsWith(".db-shm") ||
+ // SQLite WAL/SHM sidecars
+ name.endsWith(".lucene") ||
+ name.contains(".lookup.lucene") ||
+ // Lucene index dirs
+ name == "catalog.pb" ||
+ // precomputed catalog (previously mis-targeted as ".proto")
+ name == "release_info.txt" ||
+ // version stamp
+ name == "delta-cache" ||
+ // delta updater work dir
+ name == PendingDbCleanup.MARKER_NAME ||
+ // stale pending-cleanup marker
+ // download / extraction leftovers
+ name.endsWith(".tar.zst") ||
+ name.endsWith(".part01") ||
+ name.endsWith(".part02") ||
+ name.endsWith(".zst") ||
+ name.endsWith(".tmp")
+ }
+
+ private fun sizeOf(file: File): Long =
+ runCatching {
+ if (file.isDirectory) {
+ file.walkBottomUp().filter { it.isFile }.sumOf { it.length() }
+ } else {
+ file.length()
}
+ }.getOrDefault(0L)
+
+ /**
+ * Deletes a file or directory tree using NIO so failures throw (and are logged)
+ * instead of silently returning false. Returns true if nothing remains afterwards.
+ */
+ private fun deleteRecursively(target: File): Boolean {
+ if (target.isDirectory) {
+ target.listFiles()?.forEach { deleteRecursively(it) }
+ }
+ return try {
+ Files.deleteIfExists(target.toPath())
+ !target.exists()
+ } catch (e: Exception) {
+ warnln { "[DatabaseCleanup] Could not delete ${target.absolutePath}: ${e.javaClass.simpleName} ${e.message}" }
+ !target.exists()
}
- directory.delete()
}
}
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/DatabasePreparationUseCase.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/DatabasePreparationUseCase.kt
new file mode 100644
index 000000000..b0840e7c2
--- /dev/null
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/DatabasePreparationUseCase.kt
@@ -0,0 +1,51 @@
+package io.github.kdroidfilter.seforimapp.features.database.update
+
+import io.github.kdroidfilter.seforimapp.features.onboarding.diskspace.AvailableDiskSpaceUseCase
+
+/**
+ * Single gate that must pass before any database download or extraction:
+ * 1. remove the previous database (which frees the disk space it occupied), then
+ * 2. verify enough free space remains for a fresh install.
+ *
+ * Returning a typed [Result] lets callers refuse to start a multi-GB transfer that
+ * would otherwise fill the disk and make the app appear to "freeze" mid-download —
+ * the exact failure mode users hit when an old database was left in place.
+ */
+class DatabasePreparationUseCase(
+ private val cleanupUseCase: DatabaseCleanupUseCase,
+ private val diskSpaceUseCase: AvailableDiskSpaceUseCase,
+) {
+ sealed interface Result {
+ /** Old database removed and enough free space — safe to download/extract. */
+ data object Ready : Result
+
+ /** The old database could not be deleted (locked); a restart will retry it. */
+ data class CleanupFailed(
+ val undeletable: List,
+ ) : Result
+
+ /** Not enough free disk space after cleanup. */
+ data class InsufficientSpace(
+ val availableBytes: Long,
+ val requiredBytes: Long,
+ ) : Result
+ }
+
+ suspend fun prepareForInstall(): Result {
+ when (val cleanup = cleanupUseCase.cleanupDatabaseFiles()) {
+ is DatabaseCleanupUseCase.CleanupResult.Incomplete ->
+ return Result.CleanupFailed(cleanup.undeletable.map { it.absolutePath })
+ is DatabaseCleanupUseCase.CleanupResult.Success -> Unit
+ }
+
+ val disk = diskSpaceUseCase.getDiskSpaceInfo()
+ return if (disk.hasEnoughSpace) {
+ Result.Ready
+ } else {
+ Result.InsufficientSpace(
+ availableBytes = disk.availableBytes,
+ requiredBytes = AvailableDiskSpaceUseCase.REQUIRED_SPACE_BYTES,
+ )
+ }
+ }
+}
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/screens/OfflineUpdateScreen.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/screens/OfflineUpdateScreen.kt
index 718f22a05..633c5c0b3 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/screens/OfflineUpdateScreen.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/screens/OfflineUpdateScreen.kt
@@ -9,9 +9,11 @@ import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import dev.zacsweers.metrox.viewmodel.metroViewModel
import io.github.kdroidfilter.seforimapp.core.presentation.utils.LocalWindowViewModelStoreOwner
+import io.github.kdroidfilter.seforimapp.features.database.update.DatabasePreparationUseCase
import io.github.kdroidfilter.seforimapp.features.database.update.navigation.DatabaseUpdateDestination
import io.github.kdroidfilter.seforimapp.features.database.update.navigation.DatabaseUpdateProgressBarState
import io.github.kdroidfilter.seforimapp.features.onboarding.data.OnboardingProcessRepository
+import io.github.kdroidfilter.seforimapp.features.onboarding.download.DownloadErrorKind
import io.github.kdroidfilter.seforimapp.features.onboarding.extract.ExtractEvents
import io.github.kdroidfilter.seforimapp.features.onboarding.extract.ExtractViewModel
import io.github.kdroidfilter.seforimapp.features.onboarding.offline.pickDatabaseParts
@@ -34,12 +36,11 @@ fun OfflineUpdateScreen(
metroViewModel(viewModelStoreOwner = LocalWindowViewModelStoreOwner.current)
val extractState by extractViewModel.state.collectAsState()
val processRepository: OnboardingProcessRepository = LocalAppGraph.current.onboardingProcessRepository
- val cleanupUseCase = LocalAppGraph.current.databaseCleanupUseCase
+ val prepUseCase = LocalAppGraph.current.databasePreparationUseCase
var part01Path by remember { mutableStateOf(null) }
var hasStartedExtraction by remember { mutableStateOf(false) }
- var cleanupCompleted by remember { mutableStateOf(false) }
- var isCleaningUp by remember { mutableStateOf(false) }
+ var prepErrorKind by remember { mutableStateOf(null) }
val scope = rememberCoroutineScope()
LaunchedEffect(extractState) {
@@ -59,16 +60,21 @@ fun OfflineUpdateScreen(
p1: String,
) {
scope.launch {
- // Nettoyer les anciens fichiers avant de commencer l'extraction
- if (!cleanupCompleted) {
- cleanupUseCase.cleanupDatabaseFiles()
- cleanupCompleted = true
+ prepErrorKind = null
+ // Remove the old database and verify free space before extracting ~7.5 GB.
+ when (prepUseCase.prepareForInstall()) {
+ DatabasePreparationUseCase.Result.Ready -> {
+ // Start extraction with part01 path; ExtractUseCase discovers part02 automatically
+ DatabaseUpdateProgressBarState.setDownloadStarted()
+ processRepository.setPendingZstPath(p1)
+ extractViewModel.onEvent(ExtractEvents.StartIfPending)
+ hasStartedExtraction = true
+ }
+ is DatabasePreparationUseCase.Result.CleanupFailed ->
+ prepErrorKind = DownloadErrorKind.CLEANUP_FAILED
+ is DatabasePreparationUseCase.Result.InsufficientSpace ->
+ prepErrorKind = DownloadErrorKind.INSUFFICIENT_SPACE
}
- // Start extraction with part01 path; ExtractUseCase discovers part02 automatically
- DatabaseUpdateProgressBarState.setDownloadStarted()
- processRepository.setPendingZstPath(p1)
- extractViewModel.onEvent(ExtractEvents.StartIfPending)
- hasStartedExtraction = true
}
}
@@ -88,6 +94,40 @@ fun OfflineUpdateScreen(
horizontalAlignment = Alignment.CenterHorizontally,
) {
when {
+ // Pre-extraction gate failed (old DB locked, or not enough disk space)
+ prepErrorKind != null -> {
+ val kind = prepErrorKind
+ Text(
+ text =
+ when (kind) {
+ DownloadErrorKind.CLEANUP_FAILED -> stringResource(Res.string.db_install_cleanup_failed)
+ DownloadErrorKind.INSUFFICIENT_SPACE -> stringResource(Res.string.db_install_insufficient_space)
+ null -> ""
+ },
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(0.8f),
+ )
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ OutlinedButton(
+ onClick = { navController.popBackStack() },
+ ) {
+ Text(stringResource(Res.string.db_update_back))
+ }
+
+ DefaultButton(
+ onClick = {
+ prepErrorKind = null
+ part01Path = null
+ },
+ ) {
+ Text(stringResource(Res.string.db_update_retry))
+ }
+ }
+ }
+
!hasStartedExtraction -> {
// File selection phase
Icon(
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/screens/OnlineUpdateScreen.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/screens/OnlineUpdateScreen.kt
index 6e2dd1fc0..39213b403 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/screens/OnlineUpdateScreen.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/screens/OnlineUpdateScreen.kt
@@ -11,13 +11,13 @@ import dev.zacsweers.metrox.viewmodel.metroViewModel
import io.github.kdroidfilter.seforimapp.core.presentation.utils.LocalWindowViewModelStoreOwner
import io.github.kdroidfilter.seforimapp.features.database.update.navigation.DatabaseUpdateDestination
import io.github.kdroidfilter.seforimapp.features.database.update.navigation.DatabaseUpdateProgressBarState
+import io.github.kdroidfilter.seforimapp.features.onboarding.download.DownloadErrorKind
import io.github.kdroidfilter.seforimapp.features.onboarding.download.DownloadEvents
import io.github.kdroidfilter.seforimapp.features.onboarding.download.DownloadProgressDetails
import io.github.kdroidfilter.seforimapp.features.onboarding.download.DownloadViewModel
import io.github.kdroidfilter.seforimapp.features.onboarding.extract.ExtractEvents
import io.github.kdroidfilter.seforimapp.features.onboarding.extract.ExtractViewModel
import io.github.kdroidfilter.seforimapp.features.onboarding.ui.components.OnBoardingScaffold
-import io.github.kdroidfilter.seforimapp.framework.di.LocalAppGraph
import io.github.kdroidfilter.seforimapp.icons.Download_for_offline
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.jewel.foundation.theme.JewelTheme
@@ -32,18 +32,15 @@ fun OnlineUpdateScreen(
val downloadViewModel: DownloadViewModel =
metroViewModel(viewModelStoreOwner = LocalWindowViewModelStoreOwner.current)
val downloadState by downloadViewModel.state.collectAsState()
- val cleanupUseCase = LocalAppGraph.current.databaseCleanupUseCase
val extractViewModel: ExtractViewModel =
metroViewModel(viewModelStoreOwner = LocalWindowViewModelStoreOwner.current)
val extractState by extractViewModel.state.collectAsState()
- var cleanupCompleted by remember { mutableStateOf(false) }
var hasStartedExtraction by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
- // Nettoyer les anciens fichiers de base de données avant de commencer
- cleanupUseCase.cleanupDatabaseFiles()
- cleanupCompleted = true
+ // Cleanup of the old database + disk-space gate run inside DownloadViewModel
+ // before the transfer starts (see DatabasePreparationUseCase).
DatabaseUpdateProgressBarState.setDownloadStarted()
downloadViewModel.onEvent(DownloadEvents.Start)
}
@@ -79,7 +76,10 @@ fun OnlineUpdateScreen(
horizontalAlignment = Alignment.CenterHorizontally,
) {
when {
- !downloadState.inProgress && !downloadState.completed && downloadState.errorMessage == null -> {
+ !downloadState.inProgress &&
+ !downloadState.completed &&
+ downloadState.errorMessage == null &&
+ downloadState.errorKind == null -> {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -137,6 +137,37 @@ fun OnlineUpdateScreen(
}
}
+ // Pre-download gate failed (old DB locked, or not enough disk space)
+ downloadState.errorKind != null -> {
+ val kind = downloadState.errorKind
+ Text(
+ text =
+ when (kind) {
+ DownloadErrorKind.CLEANUP_FAILED -> stringResource(Res.string.db_install_cleanup_failed)
+ DownloadErrorKind.INSUFFICIENT_SPACE -> stringResource(Res.string.db_install_insufficient_space)
+ null -> ""
+ },
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(0.8f),
+ )
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ OutlinedButton(
+ onClick = { navController.popBackStack() },
+ ) {
+ Text(stringResource(Res.string.db_update_back))
+ }
+
+ DefaultButton(
+ onClick = { downloadViewModel.onEvent(DownloadEvents.Start) },
+ ) {
+ Text(stringResource(Res.string.db_update_retry))
+ }
+ }
+ }
+
downloadState.errorMessage != null -> {
Text(
text = stringResource(Res.string.db_update_download_error),
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/download/DownloadScreen.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/download/DownloadScreen.kt
index d64512bb7..0f62e7a2d 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/download/DownloadScreen.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/download/DownloadScreen.kt
@@ -29,6 +29,8 @@ import org.jetbrains.jewel.ui.component.DefaultErrorBanner
import org.jetbrains.jewel.ui.component.Icon
import org.jetbrains.jewel.ui.theme.defaultBannerStyle
import seforimapp.seforimapp.generated.resources.Res
+import seforimapp.seforimapp.generated.resources.db_install_cleanup_failed
+import seforimapp.seforimapp.generated.resources.db_install_insufficient_space
import seforimapp.seforimapp.generated.resources.onboarding_downloading_message
import seforimapp.seforimapp.generated.resources.onboarding_error_occurred
import seforimapp.seforimapp.generated.resources.onboarding_error_with_detail
@@ -98,11 +100,18 @@ fun DownloadView(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
- // Error banner with retry
- if (state.errorMessage != null) {
- val generic = stringResource(Res.string.onboarding_error_occurred)
- val detail = state.errorMessage.takeIf { it.isNotBlank() }
- val message = detail?.let { stringResource(Res.string.onboarding_error_with_detail, it) } ?: generic
+ // Error banner with retry (pre-download gate failure or download error)
+ if (state.errorKind != null || state.errorMessage != null) {
+ val message =
+ when (state.errorKind) {
+ DownloadErrorKind.CLEANUP_FAILED -> stringResource(Res.string.db_install_cleanup_failed)
+ DownloadErrorKind.INSUFFICIENT_SPACE -> stringResource(Res.string.db_install_insufficient_space)
+ null -> {
+ val detail = state.errorMessage?.takeIf { it.isNotBlank() }
+ detail?.let { stringResource(Res.string.onboarding_error_with_detail, it) }
+ ?: stringResource(Res.string.onboarding_error_occurred)
+ }
+ }
val retryLabel = stringResource(Res.string.retry_button)
DefaultErrorBanner(
text = message,
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/download/DownloadState.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/download/DownloadState.kt
index 28bd9af6f..44f669fc9 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/download/DownloadState.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/download/DownloadState.kt
@@ -1,5 +1,14 @@
package io.github.kdroidfilter.seforimapp.features.onboarding.download
+/** Categorizes a pre-download gate failure so the UI can show a localized message. */
+enum class DownloadErrorKind {
+ /** The previous database could not be removed (locked); a restart will retry it. */
+ CLEANUP_FAILED,
+
+ /** Not enough free disk space remains for a fresh install. */
+ INSUFFICIENT_SPACE,
+}
+
data class DownloadState(
val inProgress: Boolean,
val progress: Float,
@@ -7,5 +16,6 @@ data class DownloadState(
val totalBytes: Long?,
val speedBytesPerSec: Long,
val errorMessage: String? = null,
+ val errorKind: DownloadErrorKind? = null,
val completed: Boolean = false,
)
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/download/DownloadViewModel.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/download/DownloadViewModel.kt
index 6786fdcad..9c48ea29c 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/download/DownloadViewModel.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/download/DownloadViewModel.kt
@@ -6,8 +6,8 @@ import dev.zacsweers.metro.ContributesIntoMap
import dev.zacsweers.metro.Inject
import dev.zacsweers.metrox.viewmodel.ViewModelKey
import io.github.kdroidfilter.seforimapp.core.coroutines.runSuspendCatching
+import io.github.kdroidfilter.seforimapp.features.database.update.DatabasePreparationUseCase
import io.github.kdroidfilter.seforimapp.features.onboarding.data.OnboardingProcessRepository
-import io.github.kdroidfilter.seforimapp.features.onboarding.download.DownloadUseCase
import io.github.kdroidfilter.seforimapp.framework.di.AppScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -23,6 +23,7 @@ import kotlinx.coroutines.launch
class DownloadViewModel(
private val useCase: DownloadUseCase,
private val processRepository: OnboardingProcessRepository,
+ private val preparationUseCase: DatabasePreparationUseCase,
) : ViewModel() {
private val _inProgress = MutableStateFlow(false)
private val _progress = MutableStateFlow(0f)
@@ -30,8 +31,13 @@ class DownloadViewModel(
private val _total = MutableStateFlow(null)
private val _speed = MutableStateFlow(0L)
private val _error = MutableStateFlow(null)
+ private val _errorKind = MutableStateFlow(null)
private val _completed = MutableStateFlow(false)
+ // Set while the pre-download gate runs, so a stray Start event can't run it twice.
+ @Volatile
+ private var preparing = false
+
private data class DownloadProgressSnapshot(
val inProgress: Boolean,
val progress: Float,
@@ -55,8 +61,9 @@ class DownloadViewModel(
combine(
progressSnapshot,
_error,
+ _errorKind,
_completed,
- ) { snapshot, error, completed ->
+ ) { snapshot, error, errorKind, completed ->
DownloadState(
inProgress = snapshot.inProgress,
progress = snapshot.progress,
@@ -64,6 +71,7 @@ class DownloadViewModel(
totalBytes = snapshot.totalBytes,
speedBytesPerSec = snapshot.speedBytesPerSec,
errorMessage = error,
+ errorKind = errorKind,
completed = completed,
)
}.stateIn(
@@ -88,17 +96,35 @@ class DownloadViewModel(
}
private fun startIfNeeded() {
- if (_inProgress.value || _completed.value) return
+ if (_inProgress.value || _completed.value || preparing) return
+ preparing = true
viewModelScope.launch(Dispatchers.Default) {
runSuspendCatching {
_error.value = null
+ _errorKind.value = null
_completed.value = false
- _inProgress.value = true
_progress.value = 0f
_downloaded.value = 0L
_total.value = null
_speed.value = 0L
+ // Gate: remove the old database and verify free space BEFORE transferring
+ // anything. Refusing here is what prevents a multi-GB download from filling
+ // the disk and freezing when an old database was left in place.
+ when (preparationUseCase.prepareForInstall()) {
+ DatabasePreparationUseCase.Result.Ready -> Unit
+ is DatabasePreparationUseCase.Result.CleanupFailed -> {
+ _errorKind.value = DownloadErrorKind.CLEANUP_FAILED
+ return@runSuspendCatching
+ }
+ is DatabasePreparationUseCase.Result.InsufficientSpace -> {
+ _errorKind.value = DownloadErrorKind.INSUFFICIENT_SPACE
+ return@runSuspendCatching
+ }
+ }
+
+ _inProgress.value = true
+
val path =
useCase.downloadLatestBundle { read, total, progress, speed ->
_downloaded.value = read
@@ -119,6 +145,7 @@ class DownloadViewModel(
_speed.value = 0L
_error.value = it.message ?: it.toString()
}
+ preparing = false
}
}
}
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/offline/OfflineFileSelectionScreen.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/offline/OfflineFileSelectionScreen.kt
index 36491228e..a8310c2c3 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/offline/OfflineFileSelectionScreen.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/offline/OfflineFileSelectionScreen.kt
@@ -9,7 +9,9 @@ import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import dev.zacsweers.metrox.viewmodel.metroViewModel
import io.github.kdroidfilter.seforimapp.core.presentation.utils.LocalWindowViewModelStoreOwner
+import io.github.kdroidfilter.seforimapp.features.database.update.DatabasePreparationUseCase
import io.github.kdroidfilter.seforimapp.features.onboarding.data.OnboardingProcessRepository
+import io.github.kdroidfilter.seforimapp.features.onboarding.download.DownloadErrorKind
import io.github.kdroidfilter.seforimapp.features.onboarding.extract.ExtractEvents
import io.github.kdroidfilter.seforimapp.features.onboarding.extract.ExtractViewModel
import io.github.kdroidfilter.seforimapp.features.onboarding.navigation.OnBoardingDestination
@@ -35,11 +37,11 @@ fun OfflineFileSelectionScreen(
val extractViewModel: ExtractViewModel = metroViewModel(viewModelStoreOwner = LocalWindowViewModelStoreOwner.current)
val processRepository: OnboardingProcessRepository = LocalAppGraph.current.onboardingProcessRepository
- val cleanupUseCase = LocalAppGraph.current.databaseCleanupUseCase
+ val prepUseCase = LocalAppGraph.current.databasePreparationUseCase
var part01Path by remember { mutableStateOf(null) }
var hasStartedExtraction by remember { mutableStateOf(false) }
- var cleanupCompleted by remember { mutableStateOf(false) }
+ var prepErrorKind by remember { mutableStateOf(null) }
val scope = rememberCoroutineScope()
// Function to start extraction with part01 path
@@ -48,21 +50,25 @@ fun OfflineFileSelectionScreen(
p1: String,
) {
scope.launch {
- // Nettoyer les anciens fichiers avant de commencer l'extraction
- if (!cleanupCompleted) {
- cleanupUseCase.cleanupDatabaseFiles()
- cleanupCompleted = true
- }
-
- // Start extraction with part01 path; ExtractUseCase discovers part02 automatically
- progressBarState.setProgress(0.7f)
- processRepository.setPendingZstPath(p1)
- extractViewModel.onEvent(ExtractEvents.StartIfPending)
- hasStartedExtraction = true
+ prepErrorKind = null
+ // Remove the old database and verify free space before extracting ~7.5 GB.
+ when (prepUseCase.prepareForInstall()) {
+ DatabasePreparationUseCase.Result.Ready -> {
+ // Start extraction with part01 path; ExtractUseCase discovers part02 automatically
+ progressBarState.setProgress(0.7f)
+ processRepository.setPendingZstPath(p1)
+ extractViewModel.onEvent(ExtractEvents.StartIfPending)
+ hasStartedExtraction = true
- // Move forward and clear all previous onboarding steps so back is disabled
- navController.navigate(OnBoardingDestination.ExtractScreen) {
- popUpTo(0) { inclusive = true }
+ // Move forward and clear all previous onboarding steps so back is disabled
+ navController.navigate(OnBoardingDestination.ExtractScreen) {
+ popUpTo(0) { inclusive = true }
+ }
+ }
+ is DatabasePreparationUseCase.Result.CleanupFailed ->
+ prepErrorKind = DownloadErrorKind.CLEANUP_FAILED
+ is DatabasePreparationUseCase.Result.InsufficientSpace ->
+ prepErrorKind = DownloadErrorKind.INSUFFICIENT_SPACE
}
}
}
@@ -108,6 +114,20 @@ fun OfflineFileSelectionScreen(
)
}
+ if (prepErrorKind != null) {
+ val kind = prepErrorKind
+ Text(
+ text =
+ when (kind) {
+ DownloadErrorKind.CLEANUP_FAILED -> stringResource(Res.string.db_install_cleanup_failed)
+ DownloadErrorKind.INSUFFICIENT_SPACE -> stringResource(Res.string.db_install_insufficient_space)
+ null -> ""
+ },
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(0.8f),
+ )
+ }
+
DefaultButton(
onClick = { pickFiles(scope) },
) {
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/typeofinstall/TypeOfInstallationScreen.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/typeofinstall/TypeOfInstallationScreen.kt
index f0ecaa55b..3930b95cd 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/typeofinstall/TypeOfInstallationScreen.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/typeofinstall/TypeOfInstallationScreen.kt
@@ -3,7 +3,6 @@ package io.github.kdroidfilter.seforimapp.features.onboarding.typeofinstall
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
@@ -14,13 +13,9 @@ import androidx.navigation.NavController
import io.github.kdroidfilter.seforimapp.features.onboarding.navigation.OnBoardingDestination
import io.github.kdroidfilter.seforimapp.features.onboarding.navigation.ProgressBarState
import io.github.kdroidfilter.seforimapp.features.onboarding.ui.components.OnBoardingScaffold
-import io.github.kdroidfilter.seforimapp.framework.di.LocalAppGraph
import io.github.kdroidfilter.seforimapp.icons.Download_for_offline
import io.github.kdroidfilter.seforimapp.icons.Unarchive
import io.github.kdroidfilter.seforimapp.theme.PreviewContainer
-import io.github.santimattius.structured.annotations.StructuredScope
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.jewel.foundation.theme.JewelTheme
import org.jetbrains.jewel.ui.Orientation
@@ -39,38 +34,26 @@ fun TypeOfInstallationScreen(
LaunchedEffect(Unit) {
progressBarState.setProgress(0.3f)
}
- val cleanupUseCase = LocalAppGraph.current.databaseCleanupUseCase
- val scope = rememberCoroutineScope()
- fun goOnline(
- @StructuredScope scope: CoroutineScope,
- ) {
- scope.launch {
- // Clean existing database and related files before online installation
- cleanupUseCase.cleanupDatabaseFiles()
- // Move forward and clear all previous onboarding steps so back is disabled
- navController.navigate(OnBoardingDestination.DatabaseOnlineInstallerScreen) {
- popUpTo(0) { inclusive = true }
- }
+ fun goOnline() {
+ // Old-database cleanup + disk-space gate run inside the download step
+ // (DownloadViewModel / DatabasePreparationUseCase).
+ // Move forward and clear all previous onboarding steps so back is disabled.
+ navController.navigate(OnBoardingDestination.DatabaseOnlineInstallerScreen) {
+ popUpTo(0) { inclusive = true }
}
}
- fun goOffline(
- @StructuredScope scope: CoroutineScope,
- ) {
- scope.launch {
- // Clean existing database and related files before offline installation
- cleanupUseCase.cleanupDatabaseFiles()
- // Move forward and clear all previous onboarding steps so back is disabled
- navController.navigate(OnBoardingDestination.OfflineFileSelectionScreen) {
- popUpTo(0) { inclusive = true }
- }
+ fun goOffline() {
+ // Cleanup + disk-space gate run in OfflineFileSelectionScreen before extraction.
+ navController.navigate(OnBoardingDestination.OfflineFileSelectionScreen) {
+ popUpTo(0) { inclusive = true }
}
}
TypeOfInstallationView(
- onOnlineInstallation = { goOnline(scope) },
- onOfflineInstallation = { goOffline(scope) },
+ onOnlineInstallation = { goOnline() },
+ onOfflineInstallation = { goOffline() },
)
}
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/DatabaseUtils.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/DatabaseUtils.kt
index 9c22f5f78..65e9f90d8 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/DatabaseUtils.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/DatabaseUtils.kt
@@ -20,9 +20,38 @@ import java.io.File
private const val DEFAULT_DB_NAME = "seforim.db"
/**
- * Lazily computed database path. Thread-safe and computed only once.
+ * Cached database path. Resolved on first access and kept for the runtime, but can
+ * be invalidated with [resetDatabasePathCache] after a reinstall changes the database
+ * location (e.g. following [io.github.kdroidfilter.seforimapp.features.database.update.DatabaseCleanupUseCase]).
*/
-private val cachedDatabasePath: String by lazy {
+@Volatile
+private var cachedDatabasePath: String? = null
+private val databasePathLock = Any()
+
+/**
+ * Gets the database path, preferring an environment variable if present,
+ * falling back to AppSettings, and finally checking the default location.
+ *
+ * The path is resolved once and cached (thread-safe); call [resetDatabasePathCache]
+ * to force re-resolution after the database is reinstalled or relocated.
+ */
+fun getDatabasePath(): String {
+ cachedDatabasePath?.let { return it }
+ return synchronized(databasePathLock) {
+ cachedDatabasePath ?: resolveDatabasePath().also { cachedDatabasePath = it }
+ }
+}
+
+/**
+ * Clears the cached database path so the next [getDatabasePath] re-resolves it.
+ * Called after a reinstall so the app opens the freshly installed database rather
+ * than a stale path captured at startup.
+ */
+fun resetDatabasePathCache() {
+ synchronized(databasePathLock) { cachedDatabasePath = null }
+}
+
+private fun resolveDatabasePath(): String {
// 1) Prefer an explicit environment variable override if provided
val envDbPath = System.getenv("SEFORIMAPP_DATABASE_PATH")?.takeIf { it.isNotBlank() }
@@ -50,17 +79,9 @@ private val cachedDatabasePath: String by lazy {
throw IllegalStateException("Database file not found at $dbPath")
}
- dbPath
+ return dbPath
}
-/**
- * Gets the database path, preferring an environment variable if present,
- * falling back to AppSettings, and finally checking the default location.
- *
- * The path is computed once and cached for the entire runtime (thread-safe).
- */
-fun getDatabasePath(): String = cachedDatabasePath
-
/**
* Singleton holder for the precomputed catalog and its extracted data.
* The catalog is loaded once at application startup and cached for the entire session.
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/PendingDbCleanup.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/PendingDbCleanup.kt
new file mode 100644
index 000000000..f0a38c4ea
--- /dev/null
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/PendingDbCleanup.kt
@@ -0,0 +1,80 @@
+package io.github.kdroidfilter.seforimapp.framework.database
+
+import io.github.kdroidfilter.seforimapp.logger.infoln
+import io.github.kdroidfilter.seforimapp.logger.warnln
+import io.github.vinceglb.filekit.FileKit
+import io.github.vinceglb.filekit.databasesDir
+import io.github.vinceglb.filekit.path
+import java.io.File
+import java.nio.file.Files
+
+/**
+ * Persists a failed database cleanup across restarts.
+ *
+ * When [io.github.kdroidfilter.seforimapp.features.database.update.DatabaseCleanupUseCase]
+ * cannot delete an old artifact — typically a file still locked by an antivirus, the
+ * Windows Search indexer, or a leftover handle — it records the absolute paths here.
+ * [runOnce] is invoked at startup, BEFORE the repository opens the database, to retry
+ * the deletion once the transient lock is gone. This is what lets the app recover on
+ * its own instead of asking the user to delete the old database by hand.
+ */
+object PendingDbCleanup {
+ const val MARKER_NAME = "pending-db-cleanup.txt"
+
+ private fun markerFile(): File? = runCatching { File(File(FileKit.databasesDir.path), MARKER_NAME) }.getOrNull()
+
+ /** Records [files] (merged with any existing entries, de-duplicated) for a retry next launch. */
+ fun record(files: List) {
+ if (files.isEmpty()) return
+ val marker = markerFile() ?: return
+ val existing =
+ if (marker.exists()) runCatching { marker.readLines() }.getOrDefault(emptyList()) else emptyList()
+ val all =
+ (existing + files.map { it.absolutePath })
+ .map { it.trim() }
+ .filter { it.isNotEmpty() }
+ .toSortedSet()
+ runCatching {
+ marker.parentFile?.mkdirs()
+ marker.writeText(all.joinToString("\n"))
+ }.onFailure { warnln { "[PendingDbCleanup] Could not write marker: ${it.message}" } }
+ }
+
+ /**
+ * Retries any pending deletions. Cheap stat when no marker is present; never throws.
+ * Clears the marker only once every recorded path is gone.
+ */
+ fun runOnce() {
+ val marker = markerFile() ?: return
+ if (!marker.exists()) return
+
+ val paths =
+ runCatching { marker.readLines() }
+ .getOrDefault(emptyList())
+ .map { it.trim() }
+ .filter { it.isNotEmpty() }
+ if (paths.isEmpty()) {
+ runCatching { Files.deleteIfExists(marker.toPath()) }
+ return
+ }
+
+ val remaining = paths.filterNot { deleteRecursively(File(it)) }
+ if (remaining.isEmpty()) {
+ runCatching { Files.deleteIfExists(marker.toPath()) }
+ infoln { "[PendingDbCleanup] All pending database deletions completed." }
+ } else {
+ runCatching { marker.writeText(remaining.joinToString("\n")) }
+ warnln { "[PendingDbCleanup] ${remaining.size} file(s) still locked; will retry next launch." }
+ }
+ }
+
+ /** Deletes a file or directory tree via NIO. Returns true if nothing remains afterwards. */
+ private fun deleteRecursively(target: File): Boolean {
+ if (!target.exists()) return true
+ if (target.isDirectory) target.listFiles()?.forEach { deleteRecursively(it) }
+ return runCatching {
+ Files.deleteIfExists(target.toPath())
+ !target.exists()
+ }.getOrDefault(!target.exists())
+ }
+}
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/di/AppGraph.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/di/AppGraph.kt
index 7da9ed611..07d4876a5 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/di/AppGraph.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/di/AppGraph.kt
@@ -12,6 +12,7 @@ import io.github.kdroidfilter.seforimapp.core.catalog.CatalogAccess
import io.github.kdroidfilter.seforimapp.core.selection.SelectionContext
import io.github.kdroidfilter.seforimapp.core.settings.CategoryDisplaySettingsStore
import io.github.kdroidfilter.seforimapp.features.database.update.DatabaseCleanupUseCase
+import io.github.kdroidfilter.seforimapp.features.database.update.DatabasePreparationUseCase
import io.github.kdroidfilter.seforimapp.features.onboarding.data.OnboardingProcessRepository
import io.github.kdroidfilter.seforimapp.features.search.SearchHomeViewModel
import io.github.kdroidfilter.seforimapp.framework.desktop.DesktopManager
@@ -44,6 +45,7 @@ abstract class AppGraph : ViewModelGraph {
abstract val onboardingProcessRepository: OnboardingProcessRepository
abstract val databaseCleanupUseCase: DatabaseCleanupUseCase
+ abstract val databasePreparationUseCase: DatabasePreparationUseCase
/** Seforim.db delta-update facade (recoverIfNeeded + checkAndApply). */
abstract val dbDeltaUpdateService: io.github.kdroidfilter.seforimapp.framework.update.DbDeltaUpdateService
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/di/modules/OnboardingBindings.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/di/modules/OnboardingBindings.kt
index 648f6f68c..7c31ebc5a 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/di/modules/OnboardingBindings.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/di/modules/OnboardingBindings.kt
@@ -7,6 +7,7 @@ import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.SingleIn
import io.github.kdroidfilter.seforimapp.core.settings.AppSettings
import io.github.kdroidfilter.seforimapp.features.database.update.DatabaseCleanupUseCase
+import io.github.kdroidfilter.seforimapp.features.database.update.DatabasePreparationUseCase
import io.github.kdroidfilter.seforimapp.features.onboarding.data.OnboardingProcessRepository
import io.github.kdroidfilter.seforimapp.features.onboarding.data.databaseFetcher
import io.github.kdroidfilter.seforimapp.features.onboarding.diskspace.AvailableDiskSpaceUseCase
@@ -52,4 +53,11 @@ object OnboardingBindings {
@Provides
@SingleIn(AppScope::class)
fun provideDatabaseCleanupUseCase(): DatabaseCleanupUseCase = DatabaseCleanupUseCase()
+
+ @Provides
+ @SingleIn(AppScope::class)
+ fun provideDatabasePreparationUseCase(
+ cleanupUseCase: DatabaseCleanupUseCase,
+ diskSpaceUseCase: AvailableDiskSpaceUseCase,
+ ): DatabasePreparationUseCase = DatabasePreparationUseCase(cleanupUseCase, diskSpaceUseCase)
}
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/main.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/main.kt
index 68c8b658a..9b026d5b8 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/main.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/main.kt
@@ -51,6 +51,7 @@ import io.github.kdroidfilter.seforimapp.features.settings.SettingsWindowEvents
import io.github.kdroidfilter.seforimapp.features.settings.SettingsWindowViewModel
import io.github.kdroidfilter.seforimapp.features.update.UpdateDialog
import io.github.kdroidfilter.seforimapp.framework.database.DatabaseVersionManager
+import io.github.kdroidfilter.seforimapp.framework.database.PendingDbCleanup
import io.github.kdroidfilter.seforimapp.framework.database.getDatabasePath
import io.github.kdroidfilter.seforimapp.framework.di.AppGraph
import io.github.kdroidfilter.seforimapp.framework.di.LocalAppGraph
@@ -150,6 +151,11 @@ fun main(args: Array) {
FileKit.init(appId)
+ // Retry any database cleanup a previous run could not finish (e.g. a file locked
+ // by antivirus/Windows Search). Runs once, before the SQLDelight repository opens
+ // the DB, so a fresh install no longer needs the user to delete the old DB by hand.
+ remember { PendingDbCleanup.runOnce() }
+
val windowState =
rememberWindowState(
position = WindowPosition.Aligned(Alignment.Center),