diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dad13ad..7f4f101 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -92,8 +92,8 @@ android { applicationId = "com.example.research" minSdk = project.property("minSdk").toString().toInt() targetSdk = project.property("targetSdk").toString().toInt() - versionCode = 1 - versionName = "1.0" + versionCode = 2 + versionName = "1.1.0" val isFdroid = project.hasProperty("fdroid") if (isFdroid) { diff --git a/app/src/main/baseline-prof.txt b/app/src/main/baseline-prof.txt index f188bc6..91d1246 100644 --- a/app/src/main/baseline-prof.txt +++ b/app/src/main/baseline-prof.txt @@ -32,3 +32,41 @@ HSPLcom/example/research/ui/article/ArticleState**->**(**)** # About screen (bonus optimization) HSPLcom/example/research/ui/about/AboutScreenKt**->**(**)** +# Updated 2026-07-11: cover the settings/dictionary flow and the Compose framework. + +# Settings + dictionary management flow +HSPLcom/example/research/ui/settings/**->**(**)** +Lcom/example/research/ui/settings/** +HSPLcom/example/research/common/ui/components/**->**(**)** +Lcom/example/research/common/ui/components/** +HSPLcom/example/research/common/progress/**->**(**)** +Lcom/example/research/common/progress/** +HSPLcom/example/research/feature/download/**->**(**)** +Lcom/example/research/feature/download/** + +# Pre-verify the app classes already covered by method rules above +Lcom/example/research/** + +# Compose framework: the alpha AARs ship near-empty baseline profiles, so first +# compositions run interpreted without these rules. +HSPLandroidx/compose/runtime/**->**(**)** +Landroidx/compose/runtime/** +HSPLandroidx/compose/ui/**->**(**)** +Landroidx/compose/ui/** +HSPLandroidx/compose/foundation/**->**(**)** +Landroidx/compose/foundation/** +HSPLandroidx/compose/material3/**->**(**)** +Landroidx/compose/material3/** +HSPLandroidx/compose/animation/**->**(**)** +Landroidx/compose/animation/** +HSPLandroidx/compose/material/**->**(**)** +Landroidx/compose/material/** + +# Support libraries on the same cold path +HSPLandroidx/lifecycle/**->**(**)** +Landroidx/lifecycle/** +HSPLandroidx/activity/compose/**->**(**)** +Landroidx/activity/compose/** +HSPLkotlinx/coroutines/**->**(**)** +Lkotlinx/coroutines/** + diff --git a/app/src/main/java/com/example/research/feature/download/repository/DictionaryChecker.kt b/app/src/main/java/com/example/research/feature/download/repository/DictionaryChecker.kt index 68b0137..a5c8910 100644 --- a/app/src/main/java/com/example/research/feature/download/repository/DictionaryChecker.kt +++ b/app/src/main/java/com/example/research/feature/download/repository/DictionaryChecker.kt @@ -94,7 +94,8 @@ class DictionaryChecker( .build() client.newCall(request).execute().use { response -> - response.isSuccessful + response.isSuccessful && + response.header("Content-Type")?.startsWith("text/html") != true } } catch (e: Exception) { Log.w(TAG, "Failed to check source availability for ${source.urlTemplate}: ${e.message}") diff --git a/app/src/main/java/com/example/research/feature/download/repository/DictionaryDownloader.kt b/app/src/main/java/com/example/research/feature/download/repository/DictionaryDownloader.kt index 7d168eb..469d9b7 100644 --- a/app/src/main/java/com/example/research/feature/download/repository/DictionaryDownloader.kt +++ b/app/src/main/java/com/example/research/feature/download/repository/DictionaryDownloader.kt @@ -33,6 +33,8 @@ import kotlin.time.Duration.Companion.seconds class HttpException(val statusCode: Int, message: String) : IOException(message) +class InvalidPayloadException(message: String) : IOException(message) + class DictionaryDownloader( private val context: Context, private val client: OkHttpClient, @@ -44,6 +46,9 @@ class DictionaryDownloader( private val RETRY_DELAY_BASE = 1.seconds private val MAX_RETRY_DELAY = 10.seconds private val FILE_CREATION_RETRY_DELAY = 200.milliseconds + + private fun isGzipHeader(buffer: ByteArray, length: Int): Boolean = + length >= 2 && buffer[0] == 0x1f.toByte() && buffer[1] == 0x8b.toByte() } private suspend fun deletePartialFileSafely( @@ -139,7 +144,9 @@ class DictionaryDownloader( } val error = currentResult.exceptionOrNull() - if (hasDatePlaceholder && error is HttpException && error.statusCode == 404) { + val currentBuildMissing = (error is HttpException && error.statusCode == 404) || + error is InvalidPayloadException + if (hasDatePlaceholder && currentBuildMissing) { val fallbackUrl = DictionarySource.buildUrl(source.urlTemplate, fallbackDate) val fallbackFileName = DictionarySource.extractFileName(source.urlTemplate, fallbackDate) return@withContext downloadFile( @@ -234,6 +241,13 @@ class DictionaryDownloader( ensureActive() val body = response.body + val expectGzip = fileName.lowercase().endsWith(DictionaryConfig.GZ_EXTENSION) + if (expectGzip && body.contentType()?.type == "text") { + response.close() + return@withContext Result.failure( + InvalidPayloadException(context.getString(R.string.download_file_not_found)) + ) + } val contentLength = body.contentLength() fileUri = fileStorageManager.createFileInFolder(folderUri, fileName, overwrite = true) if (fileUri == null) { @@ -261,6 +275,9 @@ class DictionaryDownloader( while (input.read(buffer).also { bytesRead = it } != -1) { ensureActive() + if (expectGzip && totalBytesRead == 0L && bytesRead > 0 && !isGzipHeader(buffer, bytesRead)) { + throw InvalidPayloadException(context.getString(R.string.download_file_not_found)) + } output.write(buffer, 0, bytesRead) totalBytesRead += bytesRead if (contentLength > 0) { @@ -301,6 +318,11 @@ class DictionaryDownloader( deletePartialFileSafely(fileUri, folderUri, fileName) throw e } + if (e is InvalidPayloadException) { + deletePartialFileIfExists(folderUri, fileName) + deletePartialFileSafely(fileUri, folderUri, fileName) + return@withContext Result.failure(e) + } if (e.isNetworkError() && currentRetry < maxRetries) { deletePartialFileIfExists(folderUri, fileName) deletePartialFileSafely(fileUri, folderUri, fileName) diff --git a/app/src/main/java/com/example/research/feature/download/repository/DictionaryRepository.kt b/app/src/main/java/com/example/research/feature/download/repository/DictionaryRepository.kt index 9918f25..7ab9f2b 100644 --- a/app/src/main/java/com/example/research/feature/download/repository/DictionaryRepository.kt +++ b/app/src/main/java/com/example/research/feature/download/repository/DictionaryRepository.kt @@ -86,8 +86,14 @@ class DictionaryRepository( } val allSources = preferencesManager.dictionarySources.first() - val normalizedSourceUrls = sourceUrls.map { DictionarySource.normalizeTemplate(it) } - val sourcesToDownload = allSources.filter { it.urlTemplate in normalizedSourceUrls && it.isEnabled } + 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()) { return@withContext Result.success(Unit) diff --git a/app/src/main/java/com/example/research/ui/settings/components/DictionaryProgressSection.kt b/app/src/main/java/com/example/research/ui/settings/components/DictionaryProgressSection.kt index 26c855a..fafed91 100644 --- a/app/src/main/java/com/example/research/ui/settings/components/DictionaryProgressSection.kt +++ b/app/src/main/java/com/example/research/ui/settings/components/DictionaryProgressSection.kt @@ -29,39 +29,48 @@ fun DictionaryProgressSection( percent: Int, testTag: String, ) { - val targetProgress = progressProvider().coerceIn(0f, 1f) - var monotonicTargetProgress by remember { mutableFloatStateOf(0f) } - LaunchedEffect(visible, targetProgress) { - monotonicTargetProgress = if (visible) { - maxOf(monotonicTargetProgress, targetProgress) - } else { - 0f - } - } - val animatedProgress by animateFloatAsState( - targetValue = monotonicTargetProgress, - animationSpec = tween(durationMillis = 650, easing = FastOutSlowInEasing), - label = "dictionary-progress" - ) - AnimatedVisibility( visible = visible, enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically(), - modifier = modifier.testTag(testTag) + modifier = modifier ) { - Column { + val targetProgress = progressProvider().coerceIn(0f, 1f) + var monotonicTargetProgress by remember { mutableFloatStateOf(0f) } + var displayedLabel by remember { mutableStateOf(label) } + var displayedPercent by remember { mutableIntStateOf(percent) } + var displayedTestTag by remember { mutableStateOf(testTag) } + + LaunchedEffect(visible, targetProgress) { + if (visible) { + monotonicTargetProgress = maxOf(monotonicTargetProgress, targetProgress) + } + } + SideEffect { + if (visible) { + displayedLabel = label + displayedPercent = percent + displayedTestTag = testTag + } + } + val animatedProgress by animateFloatAsState( + targetValue = monotonicTargetProgress, + animationSpec = tween(durationMillis = 650, easing = FastOutSlowInEasing), + label = "dictionary-progress" + ) + + Column(modifier = Modifier.testTag(displayedTestTag)) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { Text( - text = label, + text = displayedLabel, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary ) Text( - text = "$percent%", + text = "$displayedPercent%", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary ) diff --git a/fastlane/metadata/android/en-US/changelogs/2.txt b/fastlane/metadata/android/en-US/changelogs/2.txt new file mode 100644 index 0000000..549f437 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/2.txt @@ -0,0 +1,5 @@ +- Dictionary updates: a missing daily build served as an HTML page no longer breaks the update; the app now falls back to the previous day's build +- Fixed update check reporting an update when none is available on the server +- Fixed source matching when a dictionary source was added with a dated URL +- Fixed the progress bar animating backwards when a download or indexing finishes +- Faster first opening of screens (ahead-of-time compilation profile now covers the whole UI) diff --git a/fastlane/metadata/android/ru-RU/changelogs/2.txt b/fastlane/metadata/android/ru-RU/changelogs/2.txt new file mode 100644 index 0000000..53f65fc --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/2.txt @@ -0,0 +1,5 @@ +- Обновление словарей: отсутствующая суточная сборка, отдаваемая как HTML-страница, больше не ломает обновление — приложение переключается на сборку за предыдущий день +- Исправлена проверка обновлений, сообщавшая об обновлении, которого нет на сервере +- Исправлено сопоставление источников, добавленных по URL с датой +- Исправлена обратная анимация индикатора прогресса при завершении загрузки и индексации +- Ускорено первое открытие экранов (AOT-профиль компиляции покрывает весь интерфейс)