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(
dictionaryRepository = downloadDictionaryRepository,
unknownErrorMessage = getString(R.string.unknown_error)
unknownErrorMessage = getString(R.string.unknown_error),
sourceUnavailableMessage = getString(R.string.download_file_not_found)
)
dictionaryImportManager = DictionaryImportManager(
@@ -12,6 +12,7 @@ import kotlin.time.Duration.Companion.seconds
class DownloadManager(
private val dictionaryRepository: DictionaryRepository,
private val unknownErrorMessage: String,
private val sourceUnavailableMessage: String,
dispatcher: CoroutineDispatcher = Dispatchers.IO,
externalScope: CoroutineScope? = null
) {
@@ -49,22 +50,32 @@ class DownloadManager(
downloadJob = downloadScope.launch {
mutex.withLock {
try {
updateDownloadProgressState(DownloadState.Loading, 0f)
val hasEnabledSources = try {
dictionaryRepository.hasEnabledSources()
val enabledSources = try {
dictionaryRepository.getEnabledSources()
} catch (_: Exception) {
false
emptyList()
}
if (!hasEnabledSources) {
if (enabledSources.isEmpty()) {
dictionaryRepository.performAllCleanup()
updateDownloadProgressState(DownloadState.Success, 1f)
return@withLock
}
val downloadableSources =
dictionaryRepository.filterServerAvailableSources(enabledSources)
if (downloadableSources.isEmpty()) {
updateDownloadProgressState(
DownloadState.Error(sourceUnavailableMessage), 0f
)
return@withLock
}
updateDownloadProgressState(DownloadState.Loading, 0f)
var lastReportedProgressBucket = -1
val result = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_DOWNLOAD) {
dictionaryRepository.downloadDictionaries { progress ->
dictionaryRepository.downloadSources(downloadableSources) { progress ->
val progressBucket = progressBucket(progress, DOWNLOAD_PROGRESS_STEP_PERCENT)
if (progressBucket != lastReportedProgressBucket) {
lastReportedProgressBucket = progressBucket
@@ -130,16 +141,36 @@ class DownloadManager(
downloadJob = downloadScope.launch {
mutex.withLock {
try {
updateDownloadProgressState(DownloadState.Loading, 0f)
if (sourceUrls.isEmpty()) {
updateDownloadProgressState(DownloadState.Success, 1f)
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
val result = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_DOWNLOAD) {
dictionaryRepository.downloadSpecificSources(sourceUrls) { progress ->
dictionaryRepository.downloadSources(downloadableSources) { progress ->
val progressBucket = progressBucket(progress, DOWNLOAD_PROGRESS_STEP_PERCENT)
if (progressBucket != lastReportedProgressBucket) {
lastReportedProgressBucket = progressBucket
@@ -12,6 +12,12 @@ import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
enum class SourceAvailability {
AVAILABLE,
NOT_FOUND,
UNKNOWN
}
class DictionaryChecker(
private val fileStorageManager: FileStorageManager,
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 {
val url = DictionarySource.buildUrl(source.urlTemplate, date)
val request = Request.Builder()
@@ -94,12 +119,17 @@ class DictionaryChecker(
.build()
client.newCall(request).execute().use { response ->
response.isSuccessful &&
if (response.isSuccessful &&
response.header("Content-Type")?.startsWith("text/html") != true
) {
SourceAvailability.AVAILABLE
} else {
SourceAvailability.NOT_FOUND
}
}
} catch (e: Exception) {
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.common.util.FileStorageManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
@@ -40,74 +42,58 @@ class DictionaryRepository(
checker.areDictionariesUpToDate(uri, sourcesToCheck, files)
}
suspend fun hasEnabledSources(): Boolean = withContext(Dispatchers.IO) {
preferencesManager.dictionarySources.first().any { it.isEnabled }
}
suspend fun downloadDictionaries(
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 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))
suspend fun getEnabledSources(sourceUrls: List<String>? = null): List<DictionarySource> =
withContext(Dispatchers.IO) {
val enabledSources = preferencesManager.dictionarySources.first()
.filter { it.isEnabled }
if (sourceUrls == null) {
return@withContext enabledSources
}
val sourceKeys = sourceUrls.mapTo(mutableSetOf(), DictionarySource::identityKey)
enabledSources.mapNotNull { source ->
if (DictionarySource.identityKey(source.urlTemplate) !in sourceKeys) {
null
} else {
source.copy(urlTemplate = DictionarySource.normalizeTemplate(source.urlTemplate))
}
}
}
if (sourcesToDownload.isEmpty()) {
return@withContext Result.success(Unit)
suspend fun filterServerAvailableSources(
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) {
downloadedPrefixes = sourcesToDownload.mapNotNull {
downloadedPrefixes = sources.mapNotNull {
DictionarySource.extractPrefix(it.urlTemplate)
}.toMutableSet()
}
downloader.downloadDictionaries(
folderUri = uri,
sources = sourcesToDownload,
sources = sources,
onProgress = onProgress
)
}