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"
minSdk = project.property("minSdk").toString().toInt()
targetSdk = project.property("targetSdk").toString().toInt()
versionCode = 1
versionName = "1.0"
versionCode = 3
versionName = "1.1.1"
val isFdroid = project.hasProperty("fdroid")
if (isFdroid) {
@@ -153,7 +153,7 @@ android {
aboutLibraries {
collect {
fetchRemoteLicense = true
fetchRemoteLicense = false
fetchRemoteFunding = false
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)
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
import androidx.compose.material3.Typography
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@@ -85,3 +86,8 @@ val Typography = Typography(
letterSpacing = 0.5.sp
)
)
val Typography.dictionaryTitleLarge: TextStyle
get() = titleLarge.copy(
platformStyle = PlatformTextStyle(includeFontPadding = true)
)
@@ -27,7 +27,7 @@ class DownloadManager(
companion object {
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 val TERMINAL_STATE_DURATION = 2.seconds
}
@@ -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}")
@@ -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)
@@ -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,6 +22,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntSize
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.research.R
import com.example.research.common.ui.theme.dictionaryTitleLarge
import com.example.research.feature.search.SearchAction
import com.example.research.ui.theme.Spacing
import com.example.research.ui.theme.AppWindowInsets
@@ -258,7 +259,7 @@ fun ArticleTitleBar(
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
style = MaterialTheme.typography.dictionaryTitleLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -1,7 +1,6 @@
package com.example.research.ui.main.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
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.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -27,7 +28,7 @@ import androidx.compose.runtime.snapshotFlow
import com.example.research.ui.theme.AppWindowInsets
import androidx.compose.ui.Alignment
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.res.painterResource
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.unit.dp
import com.example.research.R
import com.example.research.common.ui.theme.dictionaryTitleLarge
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)
@Composable
@@ -99,6 +102,7 @@ fun MainTopBar(
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun SearchField(
searchState: TextFieldState,
enabled: Boolean,
@@ -110,8 +114,6 @@ private fun SearchField(
modifier: Modifier = Modifier
) {
val containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
val contentColor = MaterialTheme.colorScheme.onSurface
val placeholderColor = MaterialTheme.colorScheme.onSurfaceVariant
val currentOnQueryChange by rememberUpdatedState(onQueryChange)
@@ -123,57 +125,57 @@ private fun SearchField(
}
}
Surface(
modifier = modifier.height(TopBarHeight),
color = containerColor,
shape = TopBarShape
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
TextField(
state = searchState,
enabled = enabled,
textStyle = MaterialTheme.typography.dictionaryTitleLarge,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
lineLimits = TextFieldLineLimits.SingleLine,
leadingIcon = {
Icon(
painter = searchIcon,
contentDescription = null,
tint = placeholderColor,
modifier = Modifier.size(24.dp)
)
BasicTextField(
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()) {
},
trailingIcon = if (searchState.text.isNotEmpty()) {
{
IconButton(
onClick = {
searchState.edit { replace(0, length, "") }
onClearQuery()
},
modifier = Modifier
.size(40.dp)
.testTag("clear_search")
.semantics { testTagsAsResourceId = true }
) {
Icon(
painter = closeIcon,
contentDescription = clearLabel,
tint = placeholderColor,
modifier = Modifier.size(24.dp)
)
}
} else {
Box(modifier = Modifier.size(40.dp))
}
}
}
} else {
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.compose.*
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.util.removeDictionarySuffixes
import com.example.research.ui.theme.Spacing
@@ -83,7 +84,7 @@ private fun SearchResultItem(
showDictionaryName: Boolean = true
) {
// 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 subtitleColor = MaterialTheme.colorScheme.onSurfaceVariant
@@ -82,6 +82,8 @@ class SettingsViewModel(
private val pendingSourceUrls = mutableSetOf<String>()
private var isDownloadInProgress = false
private var cancelRefreshPending = false
init {
setupStateObservation()
initialize()
@@ -143,6 +145,7 @@ class SettingsViewModel(
when (operationState.downloadState) {
is DownloadState.Loading, is DownloadState.Extracting -> {
isDownloadInProgress = true
cancelRefreshPending = false
}
is DownloadState.Success -> {
pendingSourceUrls.clear()
@@ -178,11 +181,7 @@ class SettingsViewModel(
}
is DownloadState.Cancelled -> {
isDownloadInProgress = false
dictionaryStatus.value = if (dictionaryState.dictionaries.isEmpty()) {
DictionaryStatus.Empty
} else {
DictionaryStatus.UpToDate
}
cancelRefreshPending = true
if (pendingSourceUrls.isNotEmpty()) {
pendingSourceUrls.forEach { urlTemplate ->
val source = dictionaryState.dictionarySources.find {
@@ -193,6 +192,12 @@ class SettingsViewModel(
pendingSourceUrls.clear()
}
}
is DownloadState.Idle -> {
if (cancelRefreshPending) {
cancelRefreshPending = false
refreshDictionaryStatus()
}
}
else -> {}
}
@@ -407,10 +412,6 @@ class SettingsViewModel(
}
dictionaryStatus.value = DictionaryStatus.Checking
val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
dictionaryStatus.value = status
if (status !is DictionaryStatus.NeedsUpdate) return@launch
val installedSources = installedEnabledSources(
preferencesManager.dictionarySources.first(),
@@ -481,13 +482,12 @@ class SettingsViewModel(
getApplication<Application>().stopService(stopIntent)
}
}
private fun refreshDictionaryStatus() {
viewModelScope.launch {
val dictionaries = localDictionaryRepository.dictionaries.first()
dictionaryStatus.value = if (dictionaries.isNotEmpty()) {
DictionaryStatus.UpToDate
} else {
DictionaryStatus.Empty
}
dictionaryStatus.value = DictionaryStatus.Checking
dictionaryStatus.value = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
}
}
@@ -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
)
@@ -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
android.nonTransitiveRClass=true
# Android build optimizations
org.gradle.configuration-cache=true
org.gradle.configuration-cache.problems=warn
# SDK Versions
compileSdk=37
minSdk=31