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:
@@ -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
|
||||||
|
|||||||
+33
-3
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+42
-56
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user