Release 1.1.0
- Fixed handling of an HTML "not found" payload served with HTTP 200 for a missing daily build: DictionaryDownloader validates Content-Type and the gzip magic bytes and falls back to the previous day's build. - Fixed the update check counting a text/html HEAD response as an available build (DictionaryChecker). - Fixed dictionary source matching: sources are matched by normalized identity key and the normalized template is handed to the downloader in downloadSpecificSources (DictionaryRepository). - Fixed the dictionary progress indicator animating backwards to 0% during its exit animation; it now stays frozen while it fades out (DictionaryProgressSection). - Fixed slow first composition of every screen by extending the checked-in baseline profile to the settings/dictionary flow and the Compose framework: the Compose alpha AARs ship near-empty profiles, so every first composition ran interpreted.
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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/**
|
||||
|
||||
|
||||
+2
-1
@@ -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}")
|
||||
|
||||
+23
-1
@@ -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)
|
||||
|
||||
+8
-2
@@ -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)
|
||||
|
||||
+22
-13
@@ -29,13 +29,28 @@ fun DictionaryProgressSection(
|
||||
percent: Int,
|
||||
testTag: String,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically(),
|
||||
modifier = modifier
|
||||
) {
|
||||
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) {
|
||||
monotonicTargetProgress = if (visible) {
|
||||
maxOf(monotonicTargetProgress, targetProgress)
|
||||
} else {
|
||||
0f
|
||||
if (visible) {
|
||||
monotonicTargetProgress = maxOf(monotonicTargetProgress, targetProgress)
|
||||
}
|
||||
}
|
||||
SideEffect {
|
||||
if (visible) {
|
||||
displayedLabel = label
|
||||
displayedPercent = percent
|
||||
displayedTestTag = testTag
|
||||
}
|
||||
}
|
||||
val animatedProgress by animateFloatAsState(
|
||||
@@ -44,24 +59,18 @@ fun DictionaryProgressSection(
|
||||
label = "dictionary-progress"
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically(),
|
||||
modifier = modifier.testTag(testTag)
|
||||
) {
|
||||
Column {
|
||||
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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,5 @@
|
||||
- Обновление словарей: отсутствующая суточная сборка, отдаваемая как HTML-страница, больше не ломает обновление — приложение переключается на сборку за предыдущий день
|
||||
- Исправлена проверка обновлений, сообщавшая об обновлении, которого нет на сервере
|
||||
- Исправлено сопоставление источников, добавленных по URL с датой
|
||||
- Исправлена обратная анимация индикатора прогресса при завершении загрузки и индексации
|
||||
- Ускорено первое открытие экранов (AOT-профиль компиляции покрывает весь интерфейс)
|
||||
Reference in New Issue
Block a user