From 8d5eab78fce2c7d426f0f535ce5095b459c78ee4 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sat, 6 Jun 2026 23:38:18 +0300 Subject: [PATCH] fix(install): remove old database and gate disk space before download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several Windows users reported the app freezing during the database download. Root cause: the old database (~7.5 GB) and Lucene indexes were not reliably removed before a ~10 GB download + extraction, so the volume filled mid-transfer. On Windows File.delete() returns false silently when a file is locked, the cleanup only scanned the default directory, and the database-update flow had no disk-space gate. - DatabaseCleanupUseCase: authoritative cleanup targeting the real DB directory (from AppSettings) plus the default one; NIO Files.deleteIfExists so locked files surface an exception instead of a silent false; removes seforim.db (+ -wal/-shm), *.lucene, *.lookup.lucene, catalog.pb (was wrongly matched as .proto), lexical.db, release_info.txt, delta-cache and download leftovers; returns Success(freedBytes) / Incomplete(undeletable). - DatabasePreparationUseCase: single gate (cleanup + free-space check) that every download/extraction entry point must pass before starting. - DownloadViewModel runs the gate before transferring; online and offline screens (onboarding + update) surface a localized "old DB locked" or "insufficient space" message and refuse to start. - PendingDbCleanup: records files that could not be deleted and retries at the next launch, before the repository opens the DB — automating the manual "delete the old database" workaround once the transient lock is gone. - DatabaseUtils: the database path cache is now resettable so a reinstall opens the freshly installed database instead of a stale path. --- .../composeResources/values/strings.xml | 2 + .../database/update/DatabaseCleanupUseCase.kt | 172 ++++++++++++------ .../update/DatabasePreparationUseCase.kt | 51 ++++++ .../update/screens/OfflineUpdateScreen.kt | 64 +++++-- .../update/screens/OnlineUpdateScreen.kt | 45 ++++- .../onboarding/download/DownloadScreen.kt | 19 +- .../onboarding/download/DownloadState.kt | 10 + .../onboarding/download/DownloadViewModel.kt | 35 +++- .../offline/OfflineFileSelectionScreen.kt | 52 ++++-- .../typeofinstall/TypeOfInstallationScreen.kt | 41 ++--- .../framework/database/DatabaseUtils.kt | 43 +++-- .../framework/database/PendingDbCleanup.kt | 80 ++++++++ .../seforimapp/framework/di/AppGraph.kt | 2 + .../di/modules/OnboardingBindings.kt | 8 + .../io/github/kdroidfilter/seforimapp/main.kt | 6 + 15 files changed, 490 insertions(+), 140 deletions(-) create mode 100644 SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/DatabasePreparationUseCase.kt create mode 100644 SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/PendingDbCleanup.kt 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),