Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions SeforimApp/src/commonMain/composeResources/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,8 @@
<string name="db_update_download_error_unknown">שגיאה לא ידועה</string>
<string name="db_update_back">חזור</string>
<string name="db_update_retry">נסה שוב</string>
<string name="db_install_cleanup_failed">לא ניתן למחוק את מסד הנתונים הקודם — ייתכן שהוא נעול על ידי תוכנה אחרת (אנטי-וירוס או חיפוש Windows) או על ידי מופע קודם של היישום. סגרו תוכנות אלו, הפעילו מחדש את היישום ונסו שוב.</string>
<string name="db_install_insufficient_space">אין מספיק שטח פנוי בכונן להתקנת מסד הנתונים. פנו מקום בכונן ונסו שוב.</string>

<!-- Offline Update Screen -->
<string name="db_update_offline_title">עדכון מקובץ מקומי</string>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<File>,
) : 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<File>()
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<File>()

// 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()
}
}
Original file line number Diff line number Diff line change
@@ -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<String>,
) : 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,
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<String?>(null) }
var hasStartedExtraction by remember { mutableStateOf(false) }
var cleanupCompleted by remember { mutableStateOf(false) }
var isCleaningUp by remember { mutableStateOf(false) }
var prepErrorKind by remember { mutableStateOf<DownloadErrorKind?>(null) }
val scope = rememberCoroutineScope()

LaunchedEffect(extractState) {
Expand All @@ -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
}
}

Expand All @@ -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(
Expand Down
Loading
Loading