18 Commits
Author SHA1 Message Date
OneWay 380e4ca916 fix: latch terminal notification state and post pipeline progress on one thread
A late ImportState Success snapshot collected after the success
notification overwrote it with an ongoing progress record. Post
progress on Main behind a volatile terminal latch; notifications go
through NotificationPort, the pipeline lives in the coordinator, and
the service finish is injected.
2026-09-06 15:32:35 +08:00
OneWay 47b1197a92 fix: defer import errors until the copy loop finishes
Errors and skips are collected while the copy loop runs and reported
once it ends. The final import state is exhaustive - success, already
added, invalid name, or nothing supported found - so every import run
reaches a terminal state and the pipeline notification always resolves
instead of lingering as an indeterminate progress bar.
2026-09-05 21:55:01 +08:00
OneWay c1563b8172 fix: tie back arrow to pipeline idle state
Computed from operation states in SettingsViewModel instead of a fixed
delay; cancel and error release the arrow immediately.
2026-09-05 05:55:56 +08:00
OneWay 4a00d8adff fix: key search result rows by dictionary, offset and word
Paging result rows had no keys and were matched by position: appended
pages and query diffs could not reuse row state. Keys derived from
dictionaryPath+offset+word are stable per row, and the paging source
collapses duplicate entries for one dictionary card: case and diacritic
alias variants of a headword share the normalized word, so entries are
deduplicated by dictionaryPath+word+offset before emitting.
2026-09-05 03:00:44 +08:00
OneWay 944d2e6634 perf(about): parse aboutlibraries.json off the main thread
Bump AboutLibraries plugin to 15.0.4 -> 15.2.0; parse
aboutlibraries.json on Dispatchers.IO via produceState (header renders
immediately, rows fill in); drop animateContentSize from rows.
2026-09-05 00:37:41 +08:00
OneWay d7de09a152 fix: open Settings immediately on cold start when no dictionaries
Seed and synchronously read hadNoDictionaries before setContent so
Settings shows from the first frame until the startup scan confirms.
2026-09-04 19:09:19 +08:00
OneWay e21a134d65 fix: hide dictionary rows from accessibility while mutations blocked
invisibleToUser() removes blocked content from the accessibility tree;
mergeDescendants only regrouped traversal order.
2026-09-04 18:04:32 +08:00
OneWay b9baf61264 fix: re-check mutation-block inside gesture coroutines
Stale drag-tick coroutines launched before the flip no-op at execution
time instead of moving the row after the reset.
2026-09-04 14:59:59 +08:00
OneWay 7b63ee4377 refactor: unify swipe reset to snap before clearing offset
Snap the Animatable first, clear rawOffset after - no frame with the
row still translated while the delete affordance is hidden.
2026-09-04 13:28:27 +08:00
OneWay d892427a3f fix: key dictionary list items by path
Swipe state follows its row instead of migrating to a neighbor on
delete or reorder.
2026-09-04 02:05:59 +08:00
OneWay 628813c3f6 refactor: isolate dictionary rows from mutation-block recomposition
Delete-block flag becomes a () -> Boolean provider read at state/gesture
time, so flipping it no longer recomposes the whole Dictionaries block.
2026-09-04 01:53:06 +08:00
OneWay 4f202f8c10 refactor: replace deprecated currentWindowAdaptiveInfo with V2 2026-09-02 16:18:39 +08:00
OneWay 44ebe68f75 refactor: expose state flows via asStateFlow
Drops the explicit getters and an unused import, and lets an emptyList
type argument be inferred.
2026-09-02 12:55:48 +08:00
OneWay 5de466792a chore: remove unused index comparison and decode helpers
IndexSearcher.compareEntryGroups / IndexComparisonSummary /
IndexComparisonException / readEntryOrNull and AboutLibrariesData.decode
have no call sites left in the app.
2026-09-02 11:30:51 +08:00
OneWay b6e6bd328c fix: reset dictionary progress bar between indexing operations
monotonicTargetProgress is held in remember and only ever grows via
maxOf, so when a new indexing starts after a previous one reached a
higher value the bar is left at the old level while the percent label
starts at 0. Snap it to the current target when the section becomes
visible so the bar follows the new operation from its first frame.
2026-09-02 10:56:32 +08:00
OneWay 5132ae66b9 fix: cancel stale progress notification on process start
Notifications belong to the package and outlive the process: after the
system kills the foreground service mid-download the frozen progress
notification stays in the shade forever. A fresh process means no
service is running, so any leftover notification is stale.
2026-09-02 08:37:43 +08:00
OneWay 5151665216 perf: mark Compose state types as @Immutable
Compose compiler reports showed DownloadState's sealed root as Uncertain
and DownloadProgressState/SettingsUiState as runtime stability, so
skippability checks were deferred to runtime. The annotations make them
compile-time stable and turn any future mutable field into an explicit
contract break.
2026-09-01 17:28:17 +08:00
OneWay 96461bd7cf fix: probe source availability before showing download progress
Probe the source URLs up front so the download flow only enters the
progress state when something is actually downloadable, and report
Idle instead of a fake Success when nothing is: the early return
skipped the terminal-state delay and reset, leaving a phantom 100%
Success (and a stuck "indexing" bar plus a success notification) for
downloads that never happened.
2026-09-01 11:45:41 +08:00
5 changed files with 73 additions and 50 deletions
@@ -1,16 +1,45 @@
package com.example.research.core.domain.model
import kotlin.math.roundToInt
import androidx.compose.runtime.Immutable
/**
* Snapshot of an ongoing indexing operation.
*
* [progress] is the authoritative [0f, 1f] completion value: the repository
* aggregates a file-size-weighted sum across files incrementally while
* indexing, so reading it is O(1).
* [progress] is the authoritative [0f, 1f] completion value and is O(1) to
* compute: the repository's internal `ProgressTracker` maintains a running
* sum across files incrementally and stores it in [aggregateSum], avoiding
* an O(n) walk over [perFileProgress] on every read.
* [perFileProgress] is kept for diagnostics; callers should prefer
* [progress] / [progressPercent].
*/
@Immutable
data class IndexingProgress(
val currentFile: String = "",
val currentIndex: Int = 0,
val totalFiles: Int = 0,
val isIndexing: Boolean = false,
val progress: Float = 0f,
)
val currentFileProgress: Float = 0f,
val label: String = "",
val perFileProgress: Map<String, Float> = emptyMap(),
/** Pre-aggregated sum in [0f, totalFiles] supplied by the producer; -1f = unknown. */
val aggregateSum: Float = -1f,
) {
val progress: Float
get() = if (totalFiles > 0) {
when {
aggregateSum >= 0f -> (aggregateSum / totalFiles).coerceIn(0f, 1f)
perFileProgress.isNotEmpty() -> {
val totalProgress = perFileProgress.values.sum()
(totalProgress / totalFiles).coerceIn(0f, 1f)
}
else -> {
val completedFiles = (currentIndex - 1).coerceAtLeast(0)
((completedFiles + currentFileProgress) / totalFiles).coerceIn(0f, 1f)
}
}
} else 0f
val progressPercent: Int
get() = (progress * 100).roundToInt().coerceIn(0, 100)
}
@@ -70,9 +70,13 @@ private class ProgressTracker(files: List<LocalDictionaryRepository.DiscoveredFi
aggregateWeightedMicros.addAndGet(newMicros - prevMicros)
}
fun progress(): Float {
/**
* Current weighted sum mapped back to [0f, totalFiles] so the existing
* IndexingProgress model can keep deriving progress as aggregate/total.
*/
fun aggregateSum(): Float {
val weightedProgress = aggregateWeightedMicros.get().toDouble() / (totalWeight.toDouble() * 1_000_000.0)
return weightedProgress.coerceIn(0.0, 1.0).toFloat()
return (weightedProgress.coerceIn(0.0, 1.0) * totalFiles).toFloat()
}
fun reset() {
@@ -188,59 +192,61 @@ class LocalDictionaryRepository(
mutableIndexingProgress.emit(
IndexingProgress(
isIndexing = true,
progress = progressTracker?.progress() ?: 0f,
currentFile = context.getString(R.string.indexing_label),
totalFiles = filesToScan.size,
currentIndex = filesToScan.size - filesToIndex.size,
aggregateSum = progressTracker?.aggregateSum() ?: -1f,
)
)
val lastEmittedPercent = AtomicInteger(-1)
val lastProgressEmitMs = AtomicLong(0L)
val publishCurrentProgress = {
val tracker = progressTracker
if (tracker != null) {
val progress = tracker.progress()
lastEmittedPercent.set((progress * 100f).toInt().coerceIn(0, 100))
lastProgressEmitMs.set(SystemClock.elapsedRealtime())
mutableIndexingProgress.update { p ->
p.copy(progress = progress)
}
}
}
supervisorScope {
filesToIndex.map { file ->
filesToIndex.mapIndexed { index, file ->
async {
indexingSemaphore.withPermit {
coroutineContext.ensureActive()
yield()
val res = indexDictionary(file) { _, _, prog ->
val res = indexDictionary(file) { _, op, prog ->
if (prog >= 0f) {
progressTracker?.updateFileProgress(file.name, prog)
}
val progress = progressTracker?.progress() ?: 0f
val newPercent = (progress * 100f).toInt().coerceIn(0, 100)
val tracker = progressTracker
val aggregate = tracker?.aggregateSum() ?: -1f
val newPercent = if (filesToScan.isNotEmpty() && aggregate >= 0f) {
((aggregate / filesToScan.size) * 100f).toInt().coerceIn(0, 100)
} else -1
val currentIdx = filesToScan.size - filesToIndex.size + index + 1
val previous = mutableIndexingProgress.value
val now = SystemClock.elapsedRealtime()
val percentChanged = newPercent != lastEmittedPercent.get()
val percentChanged = newPercent >= 0 && newPercent != lastEmittedPercent.get()
val fileChanged = previous.currentFile != file.name
val completed = newPercent >= 100 || prog >= 1f
val intervalElapsed =
now - lastProgressEmitMs.get() >= MIN_PROGRESS_EMIT_INTERVAL_MS
val shouldEmit = completed || (percentChanged && intervalElapsed)
val shouldEmit = fileChanged || completed || (percentChanged && intervalElapsed)
if (shouldEmit) {
lastEmittedPercent.set(newPercent)
if (newPercent >= 0) lastEmittedPercent.set(newPercent)
lastProgressEmitMs.set(now)
mutableIndexingProgress.update { p ->
p.copy(progress = progress)
p.copy(
currentFile = file.name,
currentIndex = currentIdx,
currentFileProgress = prog,
label = op.ifEmpty { p.label },
aggregateSum = aggregate,
)
}
}
}
if (res is OperationResult.Success) {
progressTracker?.updateFileProgress(file.name, 1.0f)
publishCurrentProgress()
totalArticlesIndexed.addAndGet(res.data.articleCount)
val dictionary = res.data
addDictionary(dictionary)
@@ -1,8 +1,8 @@
package com.example.research.ui.settings.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
@@ -19,10 +19,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.example.research.R
private const val MILLIS_PER_PROGRESS_UNIT = 10_000
private const val MIN_PROGRESS_MOTION_MS = 50
private const val MAX_PROGRESS_MOTION_MS = 650
@Composable
fun DictionaryProgressSection(
visible: Boolean,
@@ -62,21 +58,11 @@ fun DictionaryProgressSection(
displayedTestTag = testTag
}
}
val animatedState = remember { Animatable(0f) }
LaunchedEffect(monotonicTargetProgress) {
val delta = monotonicTargetProgress - animatedState.value
if (delta > 0f) {
animatedState.animateTo(
targetValue = monotonicTargetProgress,
animationSpec = tween(
durationMillis = (delta * MILLIS_PER_PROGRESS_UNIT).toInt()
.coerceIn(MIN_PROGRESS_MOTION_MS, MAX_PROGRESS_MOTION_MS),
easing = LinearEasing,
),
)
}
}
val animatedProgress = animatedState.value
val animatedProgress by animateFloatAsState(
targetValue = monotonicTargetProgress,
animationSpec = tween(durationMillis = 650, easing = FastOutSlowInEasing),
label = "dictionary-progress"
)
Column(modifier = Modifier.testTag(displayedTestTag)) {
Row(
+1
View File
@@ -12,6 +12,7 @@
<string name="language_english">Английский</string>
<string name="language_russian">Русский</string>
<string name="language_system">Системный</string>
<string name="indexing_label">Индексация словарей</string>
<string name="clear_search">Очистить поиск</string>
<string name="article_no_selected">Статья не выбрана</string>
<string name="article_return_to_search">Вернуться к поиску</string>
+1
View File
@@ -12,6 +12,7 @@
<string name="language_english">English</string>
<string name="language_russian">Russian</string>
<string name="language_system">System</string>
<string name="indexing_label">Indexing dictionaries</string>
<string name="clear_search">Clear search</string>
<string name="article_no_selected">No article selected</string>
<string name="article_return_to_search">Return to search</string>