fix: probe source availability before showing download progress

Instant server failures (404/HTML error page) no longer flash a 0%
progress bar; the check mirrors the downloader's previous-day fallback
and tolerates transient network errors, which still go through the
downloader's retry path.
This commit is contained in:
2026-09-05 10:03:54 +08:00
parent 43823e3bfa
commit 98ef99ab53
4 changed files with 118 additions and 70 deletions
@@ -62,7 +62,8 @@ class ReSearchApplication : Application() {
downloadManager = DownloadManager( downloadManager = DownloadManager(
dictionaryRepository = downloadDictionaryRepository, dictionaryRepository = downloadDictionaryRepository,
unknownErrorMessage = getString(R.string.unknown_error) unknownErrorMessage = getString(R.string.unknown_error),
sourceUnavailableMessage = getString(R.string.download_file_not_found)
) )
dictionaryImportManager = DictionaryImportManager( dictionaryImportManager = DictionaryImportManager(
@@ -12,6 +12,7 @@ import kotlin.time.Duration.Companion.seconds
class DownloadManager( class DownloadManager(
private val dictionaryRepository: DictionaryRepository, private val dictionaryRepository: DictionaryRepository,
private val unknownErrorMessage: String, private val unknownErrorMessage: String,
private val sourceUnavailableMessage: String,
dispatcher: CoroutineDispatcher = Dispatchers.IO, dispatcher: CoroutineDispatcher = Dispatchers.IO,
externalScope: CoroutineScope? = null externalScope: CoroutineScope? = null
) { ) {
@@ -49,22 +50,32 @@ class DownloadManager(
downloadJob = downloadScope.launch { downloadJob = downloadScope.launch {
mutex.withLock { mutex.withLock {
try { try {
updateDownloadProgressState(DownloadState.Loading, 0f) val enabledSources = try {
dictionaryRepository.getEnabledSources()
val hasEnabledSources = try {
dictionaryRepository.hasEnabledSources()
} catch (_: Exception) { } catch (_: Exception) {
false emptyList()
} }
if (!hasEnabledSources) { if (enabledSources.isEmpty()) {
dictionaryRepository.performAllCleanup() dictionaryRepository.performAllCleanup()
updateDownloadProgressState(DownloadState.Success, 1f) updateDownloadProgressState(DownloadState.Success, 1f)
return@withLock return@withLock
} }
val downloadableSources =
dictionaryRepository.filterServerAvailableSources(enabledSources)
if (downloadableSources.isEmpty()) {
updateDownloadProgressState(
DownloadState.Error(sourceUnavailableMessage), 0f
)
return@withLock
}
updateDownloadProgressState(DownloadState.Loading, 0f)
var lastReportedProgressBucket = -1 var lastReportedProgressBucket = -1
val result = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_DOWNLOAD) { val result = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_DOWNLOAD) {
dictionaryRepository.downloadDictionaries { progress -> dictionaryRepository.downloadSources(downloadableSources) { progress ->
val progressBucket = progressBucket(progress, DOWNLOAD_PROGRESS_STEP_PERCENT) val progressBucket = progressBucket(progress, DOWNLOAD_PROGRESS_STEP_PERCENT)
if (progressBucket != lastReportedProgressBucket) { if (progressBucket != lastReportedProgressBucket) {
lastReportedProgressBucket = progressBucket lastReportedProgressBucket = progressBucket
@@ -130,16 +141,36 @@ class DownloadManager(
downloadJob = downloadScope.launch { downloadJob = downloadScope.launch {
mutex.withLock { mutex.withLock {
try { try {
updateDownloadProgressState(DownloadState.Loading, 0f)
if (sourceUrls.isEmpty()) { if (sourceUrls.isEmpty()) {
updateDownloadProgressState(DownloadState.Success, 1f) updateDownloadProgressState(DownloadState.Success, 1f)
return@withLock return@withLock
} }
val sources = try {
dictionaryRepository.getEnabledSources(sourceUrls)
} catch (_: Exception) {
emptyList()
}
if (sources.isEmpty()) {
updateDownloadProgressState(DownloadState.Success, 1f)
return@withLock
}
val downloadableSources =
dictionaryRepository.filterServerAvailableSources(sources)
if (downloadableSources.isEmpty()) {
updateDownloadProgressState(
DownloadState.Error(sourceUnavailableMessage), 0f
)
return@withLock
}
updateDownloadProgressState(DownloadState.Loading, 0f)
var lastReportedProgressBucket = -1 var lastReportedProgressBucket = -1
val result = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_DOWNLOAD) { val result = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_DOWNLOAD) {
dictionaryRepository.downloadSpecificSources(sourceUrls) { progress -> dictionaryRepository.downloadSources(downloadableSources) { progress ->
val progressBucket = progressBucket(progress, DOWNLOAD_PROGRESS_STEP_PERCENT) val progressBucket = progressBucket(progress, DOWNLOAD_PROGRESS_STEP_PERCENT)
if (progressBucket != lastReportedProgressBucket) { if (progressBucket != lastReportedProgressBucket) {
lastReportedProgressBucket = progressBucket lastReportedProgressBucket = progressBucket
@@ -12,6 +12,12 @@ import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
enum class SourceAvailability {
AVAILABLE,
NOT_FOUND,
UNKNOWN
}
class DictionaryChecker( class DictionaryChecker(
private val fileStorageManager: FileStorageManager, private val fileStorageManager: FileStorageManager,
private val client: OkHttpClient private val client: OkHttpClient
@@ -85,7 +91,26 @@ class DictionaryChecker(
} }
} }
private fun isSourceAvailableOnServer(source: DictionarySource, date: String): Boolean { suspend fun checkSourceAvailability(source: DictionarySource): SourceAvailability =
withContext(Dispatchers.IO) {
val currentDate = DateUtils.getCurrentDateString()
when (probeSource(source, currentDate)) {
SourceAvailability.AVAILABLE -> SourceAvailability.AVAILABLE
SourceAvailability.UNKNOWN -> SourceAvailability.UNKNOWN
SourceAvailability.NOT_FOUND -> {
if (DictionarySource.hasDatePlaceholder(source.urlTemplate)) {
probeSource(source, DateUtils.getPreviousDateString())
} else {
SourceAvailability.NOT_FOUND
}
}
}
}
private fun isSourceAvailableOnServer(source: DictionarySource, date: String): Boolean =
probeSource(source, date) == SourceAvailability.AVAILABLE
private fun probeSource(source: DictionarySource, date: String): SourceAvailability {
return try { return try {
val url = DictionarySource.buildUrl(source.urlTemplate, date) val url = DictionarySource.buildUrl(source.urlTemplate, date)
val request = Request.Builder() val request = Request.Builder()
@@ -94,12 +119,17 @@ class DictionaryChecker(
.build() .build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
response.isSuccessful && if (response.isSuccessful &&
response.header("Content-Type")?.startsWith("text/html") != true response.header("Content-Type")?.startsWith("text/html") != true
) {
SourceAvailability.AVAILABLE
} else {
SourceAvailability.NOT_FOUND
}
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Failed to check source availability for ${source.urlTemplate}: ${e.message}") Log.w(TAG, "Failed to check source availability for ${source.urlTemplate}: ${e.message}")
false SourceAvailability.UNKNOWN
} }
} }
@@ -8,6 +8,8 @@ import com.example.research.core.domain.model.DictionarySource
import com.example.research.data.local.preferences.PreferencesManager import com.example.research.data.local.preferences.PreferencesManager
import com.example.research.common.util.FileStorageManager import com.example.research.common.util.FileStorageManager
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -40,74 +42,58 @@ class DictionaryRepository(
checker.areDictionariesUpToDate(uri, sourcesToCheck, files) checker.areDictionariesUpToDate(uri, sourcesToCheck, files)
} }
suspend fun hasEnabledSources(): Boolean = withContext(Dispatchers.IO) { suspend fun getEnabledSources(sourceUrls: List<String>? = null): List<DictionarySource> =
preferencesManager.dictionarySources.first().any { it.isEnabled } withContext(Dispatchers.IO) {
} val enabledSources = preferencesManager.dictionarySources.first()
.filter { it.isEnabled }
suspend fun downloadDictionaries( if (sourceUrls == null) {
onProgress: (Float) -> Unit return@withContext enabledSources
): Result<Unit> = withContext(Dispatchers.IO) { }
val folderUri = preferencesManager.dictionaryPath val sourceKeys = sourceUrls.mapTo(mutableSetOf(), DictionarySource::identityKey)
val uri = Uri.fromFile(File(folderUri)) enabledSources.mapNotNull { source ->
val files = fileStorageManager.listFilesInFolder(uri) if (DictionarySource.identityKey(source.urlTemplate) !in sourceKeys) {
cleaner.deleteCorruptedArchives(uri, files) null
} else {
synchronized(filesBeforeDownload) { source.copy(urlTemplate = DictionarySource.normalizeTemplate(source.urlTemplate))
filesBeforeDownload = files.map { it.name }.toMutableSet() }
}
val sources = preferencesManager.dictionarySources.first()
val enabledSources = sources.filter { it.isEnabled }
synchronized(downloadedPrefixes) {
downloadedPrefixes = enabledSources.mapNotNull {
DictionarySource.extractPrefix(it.urlTemplate)
}.toMutableSet()
}
downloader.downloadDictionaries(
folderUri = uri,
sources = enabledSources,
onProgress = onProgress
)
}
suspend fun downloadSpecificSources(
sourceUrls: List<String>,
onProgress: (Float) -> Unit
): Result<Unit> = withContext(Dispatchers.IO) {
val folderUri = preferencesManager.dictionaryPath
val uri = Uri.fromFile(File(folderUri))
val files = fileStorageManager.listFilesInFolder(uri)
cleaner.deleteCorruptedArchives(uri, files)
synchronized(filesBeforeDownload) {
filesBeforeDownload = files.map { it.name }.toMutableSet()
}
val allSources = preferencesManager.dictionarySources.first()
val sourceKeys = sourceUrls.mapTo(mutableSetOf(), DictionarySource::identityKey)
val sourcesToDownload = allSources.mapNotNull { source ->
if (!source.isEnabled || DictionarySource.identityKey(source.urlTemplate) !in sourceKeys) {
null
} else {
source.copy(urlTemplate = DictionarySource.normalizeTemplate(source.urlTemplate))
} }
} }
if (sourcesToDownload.isEmpty()) { suspend fun filterServerAvailableSources(
return@withContext Result.success(Unit) sources: List<DictionarySource>
): List<DictionarySource> = withContext(Dispatchers.IO) {
if (sources.isEmpty()) {
return@withContext emptyList()
}
sources
.map { source -> async { source to checker.checkSourceAvailability(source) } }
.awaitAll()
.filter { (_, availability) -> availability != SourceAvailability.NOT_FOUND }
.map { (source, _) -> source }
}
suspend fun downloadSources(
sources: List<DictionarySource>,
onProgress: (Float) -> Unit
): Result<Unit> = withContext(Dispatchers.IO) {
val folderUri = preferencesManager.dictionaryPath
val uri = Uri.fromFile(File(folderUri))
val files = fileStorageManager.listFilesInFolder(uri)
cleaner.deleteCorruptedArchives(uri, files)
synchronized(filesBeforeDownload) {
filesBeforeDownload = files.map { it.name }.toMutableSet()
} }
synchronized(downloadedPrefixes) { synchronized(downloadedPrefixes) {
downloadedPrefixes = sourcesToDownload.mapNotNull { downloadedPrefixes = sources.mapNotNull {
DictionarySource.extractPrefix(it.urlTemplate) DictionarySource.extractPrefix(it.urlTemplate)
}.toMutableSet() }.toMutableSet()
} }
downloader.downloadDictionaries( downloader.downloadDictionaries(
folderUri = uri, folderUri = uri,
sources = sourcesToDownload, sources = sources,
onProgress = onProgress onProgress = onProgress
) )
} }