4 Commits
Author SHA1 Message Date
OneWay a5f3af83a4 Fix dictionary update flow and rebuild the search field on Material 3
Fixed the update card reappearing for a moment after tapping it: the tap
re-ran the status check and republished NeedsUpdate before the download
service reported Loading, so the card flashed back in that gap.

Fixed the update card never returning after cancelling a download: both the
Cancelled branch and cancelDownload() forced the status to UpToDate, and the
card's tap is the only thing that re-evaluates it. The status is now
re-derived on the Cancelled to Idle transition, once the partially downloaded
archive - which carries today's date and would otherwise be read as a current
dictionary - has been cleaned up.

Fixed the download progress bar staying at 0% far too long: progress was
published in 3-percentage-point buckets and then scaled by the download stage
weight, so the bar only moved after about 7% of the archive had transferred.

Rebuilt the search field on the Material 3 TextField instead of a hand-rolled
BasicTextField inside a Surface, so it picks up the standard container, cursor
and icon treatment. Dictionary entry titles in the search field, the results
list and the article bar now share a dictionaryTitleLarge style that keeps the
font padding, so their line height lines up with the Material 3 text field.
2026-07-13 19:20:25 +08:00
OneWay db0e8ba6ea 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.
2026-07-11 11:37:04 +08:00
OneWay 4c2faf3c94 Disabled Gradle configuration cache
Disabled Gradle configuration cache for reproducible builds
2026-07-08 11:31:08 +00:00
OneWay 69a5ff6304 Disable fetchRemoteLicense for reproducible builds
AboutLibraries fetched license text over the network at build time, which is unavailable on F-Droid's isolated buildserver and produced different license identifiers (SPDX id vs. hash fallback) between environments, breaking APK reproducibility.
2026-07-07 06:23:05 +00:00
18 changed files with 229 additions and 86 deletions
+3 -3
View File
@@ -92,8 +92,8 @@ android {
applicationId = "com.example.research" applicationId = "com.example.research"
minSdk = project.property("minSdk").toString().toInt() minSdk = project.property("minSdk").toString().toInt()
targetSdk = project.property("targetSdk").toString().toInt() targetSdk = project.property("targetSdk").toString().toInt()
versionCode = 1 versionCode = 3
versionName = "1.0" versionName = "1.1.1"
val isFdroid = project.hasProperty("fdroid") val isFdroid = project.hasProperty("fdroid")
if (isFdroid) { if (isFdroid) {
@@ -153,7 +153,7 @@ android {
aboutLibraries { aboutLibraries {
collect { collect {
fetchRemoteLicense = true fetchRemoteLicense = false
fetchRemoteFunding = false fetchRemoteFunding = false
configPath = file("config") configPath = file("config")
} }
@@ -0,0 +1,45 @@
package com.example.research.core.performance
import android.os.Trace
import java.util.concurrent.atomic.AtomicInteger
object ReSearchTrace {
const val SEARCH_PAGING = "ReSearch/Search/Paging"
const val SEARCH_DIRECT = "ReSearch/Search/Direct"
const val ARTICLE_PARSE = "ReSearch/Article/Parse"
const val ARTICLE_READ = "ReSearch/Article/Read"
const val ARTICLE_PRE_MEASURE = "ReSearch/Article/PreMeasure"
const val DICTIONARY_DOWNLOAD = "ReSearch/Dictionary/Download"
const val DICTIONARY_EXTRACT = "ReSearch/Dictionary/Extract"
const val DICTIONARY_INDEX = "ReSearch/Dictionary/Index"
const val DICTIONARY_IMPORT = "ReSearch/Dictionary/Import"
const val FLOW_UPDATE_CARD_APPEAR = "ReSearch/Flow/UpdateCardAppear"
const val FLOW_UPDATING = "ReSearch/Flow/Updating"
const val FLOW_PROGRESS_DISAPPEAR = "ReSearch/Flow/ProgressDisappear"
private val nextCookie = AtomicInteger()
@PublishedApi
internal val isAndroidRuntime = System.getProperty("java.vm.name") == "Dalvik"
inline fun <T> section(name: String, block: () -> T): T {
if (!isAndroidRuntime) return block()
Trace.beginSection(name)
return try {
block()
} finally {
Trace.endSection()
}
}
suspend fun <T> asyncSection(name: String, block: suspend () -> T): T {
if (!isAndroidRuntime) return block()
val cookie = nextCookie.incrementAndGet()
Trace.beginAsyncSection(name, cookie)
return try {
block()
} finally {
Trace.endAsyncSection(name, cookie)
}
}
}
+38
View File
@@ -32,3 +32,41 @@ HSPLcom/example/research/ui/article/ArticleState**->**(**)**
# About screen (bonus optimization) # About screen (bonus optimization)
HSPLcom/example/research/ui/about/AboutScreenKt**->**(**)** 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/**
@@ -1,6 +1,7 @@
package com.example.research.common.ui.theme package com.example.research.common.ui.theme
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -85,3 +86,8 @@ val Typography = Typography(
letterSpacing = 0.5.sp letterSpacing = 0.5.sp
) )
) )
val Typography.dictionaryTitleLarge: TextStyle
get() = titleLarge.copy(
platformStyle = PlatformTextStyle(includeFontPadding = true)
)
@@ -27,7 +27,7 @@ class DownloadManager(
companion object { companion object {
private const val TAG = "DownloadManager" private const val TAG = "DownloadManager"
private const val DOWNLOAD_PROGRESS_STEP_PERCENT = 3 private const val DOWNLOAD_PROGRESS_STEP_PERCENT = 1
private const val EXTRACT_PROGRESS_STEP_PERCENT = 5 private const val EXTRACT_PROGRESS_STEP_PERCENT = 5
private val TERMINAL_STATE_DURATION = 2.seconds private val TERMINAL_STATE_DURATION = 2.seconds
} }
@@ -94,7 +94,8 @@ class DictionaryChecker(
.build() .build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
response.isSuccessful response.isSuccessful &&
response.header("Content-Type")?.startsWith("text/html") != true
} }
} 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}")
@@ -33,6 +33,8 @@ import kotlin.time.Duration.Companion.seconds
class HttpException(val statusCode: Int, message: String) : IOException(message) class HttpException(val statusCode: Int, message: String) : IOException(message)
class InvalidPayloadException(message: String) : IOException(message)
class DictionaryDownloader( class DictionaryDownloader(
private val context: Context, private val context: Context,
private val client: OkHttpClient, private val client: OkHttpClient,
@@ -44,6 +46,9 @@ class DictionaryDownloader(
private val RETRY_DELAY_BASE = 1.seconds private val RETRY_DELAY_BASE = 1.seconds
private val MAX_RETRY_DELAY = 10.seconds private val MAX_RETRY_DELAY = 10.seconds
private val FILE_CREATION_RETRY_DELAY = 200.milliseconds 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( private suspend fun deletePartialFileSafely(
@@ -139,7 +144,9 @@ class DictionaryDownloader(
} }
val error = currentResult.exceptionOrNull() 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 fallbackUrl = DictionarySource.buildUrl(source.urlTemplate, fallbackDate)
val fallbackFileName = DictionarySource.extractFileName(source.urlTemplate, fallbackDate) val fallbackFileName = DictionarySource.extractFileName(source.urlTemplate, fallbackDate)
return@withContext downloadFile( return@withContext downloadFile(
@@ -234,6 +241,13 @@ class DictionaryDownloader(
ensureActive() ensureActive()
val body = response.body 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() val contentLength = body.contentLength()
fileUri = fileStorageManager.createFileInFolder(folderUri, fileName, overwrite = true) fileUri = fileStorageManager.createFileInFolder(folderUri, fileName, overwrite = true)
if (fileUri == null) { if (fileUri == null) {
@@ -261,6 +275,9 @@ class DictionaryDownloader(
while (input.read(buffer).also { bytesRead = it } != -1) { while (input.read(buffer).also { bytesRead = it } != -1) {
ensureActive() 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) output.write(buffer, 0, bytesRead)
totalBytesRead += bytesRead totalBytesRead += bytesRead
if (contentLength > 0) { if (contentLength > 0) {
@@ -301,6 +318,11 @@ class DictionaryDownloader(
deletePartialFileSafely(fileUri, folderUri, fileName) deletePartialFileSafely(fileUri, folderUri, fileName)
throw e throw e
} }
if (e is InvalidPayloadException) {
deletePartialFileIfExists(folderUri, fileName)
deletePartialFileSafely(fileUri, folderUri, fileName)
return@withContext Result.failure(e)
}
if (e.isNetworkError() && currentRetry < maxRetries) { if (e.isNetworkError() && currentRetry < maxRetries) {
deletePartialFileIfExists(folderUri, fileName) deletePartialFileIfExists(folderUri, fileName)
deletePartialFileSafely(fileUri, folderUri, fileName) deletePartialFileSafely(fileUri, folderUri, fileName)
@@ -86,8 +86,14 @@ class DictionaryRepository(
} }
val allSources = preferencesManager.dictionarySources.first() val allSources = preferencesManager.dictionarySources.first()
val normalizedSourceUrls = sourceUrls.map { DictionarySource.normalizeTemplate(it) } val sourceKeys = sourceUrls.mapTo(mutableSetOf(), DictionarySource::identityKey)
val sourcesToDownload = allSources.filter { it.urlTemplate in normalizedSourceUrls && it.isEnabled } 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()) { if (sourcesToDownload.isEmpty()) {
return@withContext Result.success(Unit) return@withContext Result.success(Unit)
@@ -22,6 +22,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.research.R import com.example.research.R
import com.example.research.common.ui.theme.dictionaryTitleLarge
import com.example.research.feature.search.SearchAction import com.example.research.feature.search.SearchAction
import com.example.research.ui.theme.Spacing import com.example.research.ui.theme.Spacing
import com.example.research.ui.theme.AppWindowInsets import com.example.research.ui.theme.AppWindowInsets
@@ -258,7 +259,7 @@ fun ArticleTitleBar(
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.dictionaryTitleLarge,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@@ -1,7 +1,6 @@
package com.example.research.ui.main.components package com.example.research.ui.main.components
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -9,16 +8,18 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -27,7 +28,7 @@ import androidx.compose.runtime.snapshotFlow
import com.example.research.ui.theme.AppWindowInsets import com.example.research.ui.theme.AppWindowInsets
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -36,9 +37,11 @@ import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.research.R import com.example.research.R
import com.example.research.common.ui.theme.dictionaryTitleLarge
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import com.example.research.ui.theme.Spacing
private val TopBarHeight = 56.dp private val TopBarHeight = Spacing.topBarHeight
private val TopBarShape = RoundedCornerShape(28.dp) private val TopBarShape = RoundedCornerShape(28.dp)
@Composable @Composable
@@ -99,6 +102,7 @@ fun MainTopBar(
} }
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun SearchField( private fun SearchField(
searchState: TextFieldState, searchState: TextFieldState,
enabled: Boolean, enabled: Boolean,
@@ -110,8 +114,6 @@ private fun SearchField(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val containerColor = MaterialTheme.colorScheme.surfaceContainerHighest val containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
val contentColor = MaterialTheme.colorScheme.onSurface
val placeholderColor = MaterialTheme.colorScheme.onSurfaceVariant
val currentOnQueryChange by rememberUpdatedState(onQueryChange) val currentOnQueryChange by rememberUpdatedState(onQueryChange)
@@ -123,57 +125,57 @@ private fun SearchField(
} }
} }
Surface( TextField(
modifier = modifier.height(TopBarHeight), state = searchState,
color = containerColor, enabled = enabled,
shape = TopBarShape textStyle = MaterialTheme.typography.dictionaryTitleLarge,
) { keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
Row( lineLimits = TextFieldLineLimits.SingleLine,
modifier = Modifier.padding(horizontal = 16.dp), leadingIcon = {
verticalAlignment = Alignment.CenterVertically
) {
Icon( Icon(
painter = searchIcon, painter = searchIcon,
contentDescription = null, contentDescription = null,
tint = placeholderColor,
modifier = Modifier.size(24.dp) modifier = Modifier.size(24.dp)
) )
},
BasicTextField( trailingIcon = if (searchState.text.isNotEmpty()) {
state = searchState, {
enabled = enabled,
textStyle = MaterialTheme.typography.titleLarge.copy(color = contentColor),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
lineLimits = TextFieldLineLimits.SingleLine,
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp)
.testTag("search_field")
.semantics { testTagsAsResourceId = true }
)
if (searchState.text.isNotEmpty()) {
IconButton( IconButton(
onClick = { onClick = {
searchState.edit { replace(0, length, "") } searchState.edit { replace(0, length, "") }
onClearQuery() onClearQuery()
}, },
modifier = Modifier modifier = Modifier
.size(40.dp)
.testTag("clear_search") .testTag("clear_search")
.semantics { testTagsAsResourceId = true } .semantics { testTagsAsResourceId = true }
) { ) {
Icon( Icon(
painter = closeIcon, painter = closeIcon,
contentDescription = clearLabel, contentDescription = clearLabel,
tint = placeholderColor,
modifier = Modifier.size(24.dp) modifier = Modifier.size(24.dp)
) )
} }
}
} else { } else {
Box(modifier = Modifier.size(40.dp)) null
} },
} shape = TopBarShape,
} contentPadding = TextFieldDefaults.contentPaddingWithoutLabel(
top = 8.dp,
bottom = 8.dp
),
colors = TextFieldDefaults.colors(
focusedContainerColor = containerColor,
unfocusedContainerColor = containerColor,
disabledContainerColor = containerColor,
cursorColor = MaterialTheme.colorScheme.primary,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent
),
modifier = modifier
.height(TopBarHeight)
.testTag("search_field")
.semantics { testTagsAsResourceId = true }
)
} }
@@ -17,6 +17,7 @@ import androidx.compose.ui.unit.dp
import androidx.paging.LoadState import androidx.paging.LoadState
import androidx.paging.compose.* import androidx.paging.compose.*
import com.example.research.R import com.example.research.R
import com.example.research.common.ui.theme.dictionaryTitleLarge
import com.example.research.core.domain.model.IndexEntry import com.example.research.core.domain.model.IndexEntry
import com.example.research.core.util.removeDictionarySuffixes import com.example.research.core.util.removeDictionarySuffixes
import com.example.research.ui.theme.Spacing import com.example.research.ui.theme.Spacing
@@ -83,7 +84,7 @@ private fun SearchResultItem(
showDictionaryName: Boolean = true showDictionaryName: Boolean = true
) { ) {
// Cache text styles to avoid recreation on every composition // Cache text styles to avoid recreation on every composition
val titleStyle = MaterialTheme.typography.titleLarge val titleStyle = MaterialTheme.typography.dictionaryTitleLarge
val subtitleStyle = MaterialTheme.typography.bodySmall val subtitleStyle = MaterialTheme.typography.bodySmall
val subtitleColor = MaterialTheme.colorScheme.onSurfaceVariant val subtitleColor = MaterialTheme.colorScheme.onSurfaceVariant
@@ -82,6 +82,8 @@ class SettingsViewModel(
private val pendingSourceUrls = mutableSetOf<String>() private val pendingSourceUrls = mutableSetOf<String>()
private var isDownloadInProgress = false private var isDownloadInProgress = false
private var cancelRefreshPending = false
init { init {
setupStateObservation() setupStateObservation()
initialize() initialize()
@@ -143,6 +145,7 @@ class SettingsViewModel(
when (operationState.downloadState) { when (operationState.downloadState) {
is DownloadState.Loading, is DownloadState.Extracting -> { is DownloadState.Loading, is DownloadState.Extracting -> {
isDownloadInProgress = true isDownloadInProgress = true
cancelRefreshPending = false
} }
is DownloadState.Success -> { is DownloadState.Success -> {
pendingSourceUrls.clear() pendingSourceUrls.clear()
@@ -178,11 +181,7 @@ class SettingsViewModel(
} }
is DownloadState.Cancelled -> { is DownloadState.Cancelled -> {
isDownloadInProgress = false isDownloadInProgress = false
dictionaryStatus.value = if (dictionaryState.dictionaries.isEmpty()) { cancelRefreshPending = true
DictionaryStatus.Empty
} else {
DictionaryStatus.UpToDate
}
if (pendingSourceUrls.isNotEmpty()) { if (pendingSourceUrls.isNotEmpty()) {
pendingSourceUrls.forEach { urlTemplate -> pendingSourceUrls.forEach { urlTemplate ->
val source = dictionaryState.dictionarySources.find { val source = dictionaryState.dictionarySources.find {
@@ -193,6 +192,12 @@ class SettingsViewModel(
pendingSourceUrls.clear() pendingSourceUrls.clear()
} }
} }
is DownloadState.Idle -> {
if (cancelRefreshPending) {
cancelRefreshPending = false
refreshDictionaryStatus()
}
}
else -> {} else -> {}
} }
@@ -407,10 +412,6 @@ class SettingsViewModel(
} }
dictionaryStatus.value = DictionaryStatus.Checking dictionaryStatus.value = DictionaryStatus.Checking
val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
dictionaryStatus.value = status
if (status !is DictionaryStatus.NeedsUpdate) return@launch
val installedSources = installedEnabledSources( val installedSources = installedEnabledSources(
preferencesManager.dictionarySources.first(), preferencesManager.dictionarySources.first(),
@@ -481,13 +482,12 @@ class SettingsViewModel(
getApplication<Application>().stopService(stopIntent) getApplication<Application>().stopService(stopIntent)
} }
viewModelScope.launch {
val dictionaries = localDictionaryRepository.dictionaries.first()
dictionaryStatus.value = if (dictionaries.isNotEmpty()) {
DictionaryStatus.UpToDate
} else {
DictionaryStatus.Empty
} }
private fun refreshDictionaryStatus() {
viewModelScope.launch {
dictionaryStatus.value = DictionaryStatus.Checking
dictionaryStatus.value = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
} }
} }
@@ -28,14 +28,29 @@ fun DictionaryProgressSection(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
percent: Int, percent: Int,
testTag: String, testTag: String,
) {
AnimatedVisibility(
visible = visible,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
modifier = modifier
) { ) {
val targetProgress = progressProvider().coerceIn(0f, 1f) val targetProgress = progressProvider().coerceIn(0f, 1f)
var monotonicTargetProgress by remember { mutableFloatStateOf(0f) } 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) { LaunchedEffect(visible, targetProgress) {
monotonicTargetProgress = if (visible) { if (visible) {
maxOf(monotonicTargetProgress, targetProgress) monotonicTargetProgress = maxOf(monotonicTargetProgress, targetProgress)
} else { }
0f }
SideEffect {
if (visible) {
displayedLabel = label
displayedPercent = percent
displayedTestTag = testTag
} }
} }
val animatedProgress by animateFloatAsState( val animatedProgress by animateFloatAsState(
@@ -44,24 +59,18 @@ fun DictionaryProgressSection(
label = "dictionary-progress" label = "dictionary-progress"
) )
AnimatedVisibility( Column(modifier = Modifier.testTag(displayedTestTag)) {
visible = visible,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
modifier = modifier.testTag(testTag)
) {
Column {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Text( Text(
text = label, text = displayedLabel,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
Text( Text(
text = "$percent%", text = "$displayedPercent%",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary 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,3 @@
- Fixed the dictionary update card after starting or cancelling a download
- Fixed download progress staying at 0% for too long
- Rebuilt the search field on Material 3 and aligned dictionary title typography
@@ -0,0 +1,5 @@
- Обновление словарей: отсутствующая суточная сборка, отдаваемая как HTML-страница, больше не ломает обновление — приложение переключается на сборку за предыдущий день
- Исправлена проверка обновлений, сообщавшая об обновлении, которого нет на сервере
- Исправлено сопоставление источников, добавленных по URL с датой
- Исправлена обратная анимация индикатора прогресса при завершении загрузки и индексации
- Ускорено первое открытие экранов (AOT-профиль компиляции покрывает весь интерфейс)
@@ -0,0 +1,3 @@
- Исправлена карточка обновления словаря после запуска и отмены загрузки
- Исправлено зависание индикатора прогресса загрузки на 0%
- Строка поиска переведена на Material 3; выровнена типографика заголовков словаря
-4
View File
@@ -40,10 +40,6 @@ kotlin.daemon.jvmargs=-Xmx3g -XX:+UseParallelGC
# thereby reducing the size of the R class for that library # thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
# Android build optimizations
org.gradle.configuration-cache=true
org.gradle.configuration-cache.problems=warn
# SDK Versions # SDK Versions
compileSdk=37 compileSdk=37
minSdk=31 minSdk=31