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
31 changed files with 578 additions and 382 deletions
@@ -1,5 +1,8 @@
package com.example.research package com.example.research
import androidx.compose.runtime.Immutable
@Immutable
sealed interface DictionaryStatus { sealed interface DictionaryStatus {
data object Unknown : DictionaryStatus data object Unknown : DictionaryStatus
data object Checking : DictionaryStatus data object Checking : DictionaryStatus
@@ -83,6 +83,7 @@ class MainActivity : ComponentActivity() {
val app = application as ReSearchApplication val app = application as ReSearchApplication
val initialTheme = loadInitialTheme(app) val initialTheme = loadInitialTheme(app)
val initialLanguage = loadInitialLanguage(app) val initialLanguage = loadInitialLanguage(app)
val initialHadNoDictionaries = loadInitialHadNoDictionaries(app)
handleIntent(intent) handleIntent(intent)
setContent { setContent {
@@ -104,7 +105,8 @@ class MainActivity : ComponentActivity() {
) { ) {
AppNavigation( AppNavigation(
searchViewModel = searchViewModel, searchViewModel = searchViewModel,
settingsViewModel = settingsViewModel settingsViewModel = settingsViewModel,
seedNoDictionaries = initialHadNoDictionaries
) )
} }
} }
@@ -165,6 +167,16 @@ class MainActivity : ComponentActivity() {
} }
} }
private fun loadInitialHadNoDictionaries(app: ReSearchApplication): Boolean {
return try {
runBlocking {
app.preferencesManager.hadNoDictionaries.first()
}
} catch (e: Exception) {
true
}
}
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
handleIntent(intent) handleIntent(intent)
@@ -2,6 +2,7 @@ package com.example.research
import android.app.Application import android.app.Application
import com.example.research.common.progress.DictionaryProgressStateHolder import com.example.research.common.progress.DictionaryProgressStateHolder
import com.example.research.common.util.NotificationHelper
import com.example.research.data.local.preferences.PreferencesManager import com.example.research.data.local.preferences.PreferencesManager
import com.example.research.data.repository.LocalDictionaryRepository import com.example.research.data.repository.LocalDictionaryRepository
import com.example.research.feature.download.DownloadManager import com.example.research.feature.download.DownloadManager
@@ -36,11 +37,18 @@ class ReSearchApplication : Application() {
private set private set
lateinit var dictionaryProgressStateHolder: DictionaryProgressStateHolder lateinit var dictionaryProgressStateHolder: DictionaryProgressStateHolder
private set private set
lateinit var dictionaryProgressPresenter: com.example.research.common.progress.DictionaryProgressPresenter
private set
lateinit var dictionaryPipelineCoordinator: com.example.research.common.progress.DictionaryPipelineCoordinator
private set
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
val notificationHelper = NotificationHelper(this)
notificationHelper.cancelProgressNotification()
preferencesManager = PreferencesManager(this) preferencesManager = PreferencesManager(this)
applicationScope.launch(Dispatchers.IO) { applicationScope.launch(Dispatchers.IO) {
preferencesManager.sanitizeDictionarySources() preferencesManager.sanitizeDictionarySources()
@@ -77,6 +85,40 @@ class ReSearchApplication : Application() {
localDictionaryRepository = localDictionaryRepository, localDictionaryRepository = localDictionaryRepository,
) )
dictionaryProgressPresenter = com.example.research.common.progress.DictionaryProgressPresenter(
scope = applicationScope,
progressSnapshot = dictionaryProgressStateHolder.progressSnapshot,
notifications = notificationHelper,
)
dictionaryPipelineCoordinator = com.example.research.common.progress.DictionaryPipelineCoordinator(
scope = applicationScope,
steps = com.example.research.common.progress.DefaultReindexingSteps(
localDictionaryRepository = localDictionaryRepository,
downloadDictionaryRepository = downloadDictionaryRepository,
),
importOperations = dictionaryImportManager,
ui = dictionaryProgressPresenter,
dictionaryPath = { preferencesManager.dictionaryPath },
finishService = {
val intent = android.content.Intent(
this,
com.example.research.feature.download.service.DictionaryForegroundService::class.java
).apply {
action = com.example.research.feature.download.service.DictionaryForegroundService.ACTION_FINISH
}
try {
startService(intent)
} catch (_: Exception) {
}
},
)
dictionaryImportManager.onFlowStarted = { dictionaryProgressPresenter.beginFlow() }
dictionaryImportManager.onTerminal = dictionaryPipelineCoordinator::onImportTerminal
downloadManager.onFlowStarted = { dictionaryProgressPresenter.beginFlow() }
downloadManager.onDownloadSuccess = dictionaryPipelineCoordinator::onDownloadSuccess
downloadManager.onTerminal = dictionaryPipelineCoordinator::onDownloadTerminal
} }
override fun onTrimMemory(level: Int) { override fun onTrimMemory(level: Int) {
@@ -0,0 +1,131 @@
package com.example.research.common.progress
import com.example.research.core.util.OperationResult
import com.example.research.feature.download.model.DownloadState
import com.example.research.ui.settings.ImportState
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import kotlin.time.Duration.Companion.seconds
class DictionaryPipelineCoordinator(
private val scope: CoroutineScope,
private val steps: ReindexingSteps,
private val importOperations: ImportFlowOperations,
private val ui: PipelineTerminalUi,
private val dictionaryPath: () -> String,
private val finishService: () -> Unit,
private val launchDispatcher: CoroutineDispatcher = Dispatchers.Main,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
private var pipelineJob: Job? = null
fun onImportTerminal(state: ImportState) {
when (state) {
is ImportState.Success -> runPipeline(isImport = true)
is ImportState.Error -> {
ui.showError()
finishService()
}
else -> {}
}
}
fun onDownloadSuccess() = runPipeline(isImport = false)
fun onDownloadTerminal(state: DownloadState) {
when (state) {
is DownloadState.Error -> {
ui.showError()
finishService()
}
is DownloadState.Cancelled -> {
ui.cancelProgress()
finishService()
}
else -> {}
}
}
fun cancelPipeline() {
pipelineJob?.cancel()
pipelineJob = null
}
private fun runPipeline(isImport: Boolean) {
pipelineJob?.cancel()
pipelineJob = scope.launch(launchDispatcher) {
val dir = if (isImport) File(dictionaryPath()) else null
val filesBeforeReindex = dir?.listFiles()?.map { it.name }?.toSet() ?: emptySet()
try {
triggerReindexing(isImport)
if (isImport) {
importOperations.getAndClearImportedFiles()
importOperations.clearImportState()
ui.showImportSuccess()
} else {
ui.showDownloadSuccess()
}
} catch (e: CancellationException) {
if (isImport) {
val filesToCleanup = importOperations.getAndClearImportedFiles()
filesToCleanup.forEach { file ->
try {
if (file.exists()) file.delete()
} catch (_: Exception) {
}
}
cleanupNewFiles(dir, filesBeforeReindex)
}
throw e
} catch (e: Exception) {
ui.showError()
} finally {
finishService()
}
}
}
private suspend fun triggerReindexing(isImport: Boolean) = withContext(ioDispatcher) {
steps.waitForIndexingCompletion()
delay(1.seconds)
val path = dictionaryPath()
if (isImport) {
importOperations.updateExtractionProgress(0f)
}
steps.extractArchives(onProgress = { progress ->
if (isImport) {
importOperations.updateExtractionProgress(progress)
}
})
val result = steps.scanDirectory(path)
if (result is OperationResult.Success && result.data > 0) {
steps.warmupIndexes()
steps.performAllCleanup()
}
if (isImport) {
importOperations.markImportPipelineSuccess()
}
}
private fun cleanupNewFiles(dir: File?, filesBeforeSnapshot: Set<String>) {
try {
dir?.listFiles()?.forEach { file ->
if (file.name !in filesBeforeSnapshot) {
try {
file.delete()
} catch (_: Exception) {
}
}
}
} catch (_: Exception) {
}
}
}
@@ -0,0 +1,56 @@
package com.example.research.common.progress
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class DictionaryProgressPresenter(
private val scope: CoroutineScope,
private val progressSnapshot: StateFlow<DictionaryProgressModel.Snapshot?>,
private val notifications: NotificationPort,
mainDispatcher: CoroutineDispatcher = Dispatchers.Main,
) : PipelineTerminalUi {
@Volatile
private var terminalActive = false
init {
scope.launch(mainDispatcher) {
progressSnapshot.collect { snapshot ->
when {
snapshot == null -> {
if (!terminalActive && notifications.hasActiveProgressNotification()) {
notifications.cancelProgressNotification()
}
}
!terminalActive -> notifications.showProgress(snapshot.percent, snapshot.titleRes)
}
}
}
}
fun beginFlow() {
terminalActive = false
}
override fun showImportSuccess() {
notifications.showImportSuccess()
terminalActive = true
}
override fun showDownloadSuccess() {
notifications.showDownloadSuccess()
terminalActive = true
}
override fun showError() {
notifications.showError()
terminalActive = true
}
override fun cancelProgress() {
terminalActive = false
notifications.cancelProgressNotification()
}
}
@@ -0,0 +1,10 @@
package com.example.research.common.progress
interface NotificationPort {
fun showProgress(percent: Int, titleRes: Int)
fun showImportSuccess()
fun showDownloadSuccess()
fun showError()
fun cancelProgressNotification()
fun hasActiveProgressNotification(): Boolean
}
@@ -0,0 +1,65 @@
package com.example.research.common.progress
import com.example.research.core.util.OperationResult
import com.example.research.data.repository.LocalDictionaryRepository
import com.example.research.feature.download.repository.DictionaryRepository
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import java.io.File
import kotlin.time.Duration.Companion.milliseconds
interface PipelineTerminalUi {
fun showImportSuccess()
fun showDownloadSuccess()
fun showError()
fun cancelProgress()
}
interface ImportFlowOperations {
fun updateExtractionProgress(progress: Float)
fun markImportPipelineSuccess()
fun clearImportState()
fun getAndClearImportedFiles(): List<File>
}
interface ReindexingSteps {
suspend fun waitForIndexingCompletion(): Boolean
suspend fun extractArchives(onProgress: (Float) -> Unit)
suspend fun scanDirectory(path: String): OperationResult<Int>
suspend fun warmupIndexes()
suspend fun performAllCleanup()
}
class DefaultReindexingSteps(
private val localDictionaryRepository: LocalDictionaryRepository,
private val downloadDictionaryRepository: DictionaryRepository,
) : ReindexingSteps {
override suspend fun waitForIndexingCompletion(): Boolean {
if (!localDictionaryRepository.indexingProgress.first().isIndexing) return true
var waitCount = 0
while (localDictionaryRepository.indexingProgress.first().isIndexing && waitCount < MAX_POLL) {
delay(POLL_INTERVAL)
waitCount++
}
return waitCount < MAX_POLL
}
override suspend fun extractArchives(onProgress: (Float) -> Unit) {
downloadDictionaryRepository.extractArchives(onProgress = onProgress)
}
override suspend fun scanDirectory(path: String): OperationResult<Int> =
localDictionaryRepository.scanDirectory(path)
override suspend fun warmupIndexes() = localDictionaryRepository.warmupIndexes()
override suspend fun performAllCleanup() {
downloadDictionaryRepository.performAllCleanup()
}
private companion object {
const val MAX_POLL = 100
val POLL_INTERVAL = 200.milliseconds
}
}
@@ -7,8 +7,9 @@ import androidx.core.app.*
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.example.research.MainActivity import com.example.research.MainActivity
import com.example.research.R import com.example.research.R
import com.example.research.common.progress.NotificationPort
import com.example.research.feature.download.receiver.DownloadCancelReceiver import com.example.research.feature.download.receiver.DownloadCancelReceiver
class NotificationHelper(private val context: Context) { class NotificationHelper(private val context: Context) : NotificationPort {
companion object { companion object {
const val CHANNEL_ID = "download_progress_channel" const val CHANNEL_ID = "download_progress_channel"
const val NOTIFICATION_ID = 1 const val NOTIFICATION_ID = 1
@@ -64,6 +65,22 @@ class NotificationHelper(private val context: Context) {
android.Manifest.permission.POST_NOTIFICATIONS android.Manifest.permission.POST_NOTIFICATIONS
) == android.content.pm.PackageManager.PERMISSION_GRANTED ) == android.content.pm.PackageManager.PERMISSION_GRANTED
} }
override fun showProgress(percent: Int, titleRes: Int) {
showUnifiedProgressNotification(
title = context.getString(titleRes),
contentText = "$percent%",
progressPercent = percent,
)
}
override fun showDownloadSuccess() = showSuccessNotification()
override fun showImportSuccess() = showImportSuccessNotification()
override fun showError() = showErrorNotification()
override fun cancelProgressNotification() = cancelNotification()
fun showSuccessNotification() { fun showSuccessNotification() {
if (!canShowNotification()) return if (!canShowNotification()) return
lastNotificationKey = 0 lastNotificationKey = 0
@@ -93,6 +110,8 @@ class NotificationHelper(private val context: Context) {
notificationManager.cancel(NOTIFICATION_ID) notificationManager.cancel(NOTIFICATION_ID)
} }
override fun hasActiveProgressNotification(): Boolean = lastNotificationKey != 0
fun showUnifiedProgressNotification( fun showUnifiedProgressNotification(
title: String, title: String,
contentText: String, contentText: String,
@@ -1,6 +1,8 @@
package com.example.research.core.domain.model package com.example.research.core.domain.model
import kotlin.math.roundToInt import kotlin.math.roundToInt
import androidx.compose.runtime.Immutable
/** /**
* Snapshot of an ongoing indexing operation. * Snapshot of an ongoing indexing operation.
* *
@@ -11,6 +13,7 @@ import kotlin.math.roundToInt
* [perFileProgress] is kept for diagnostics; callers should prefer * [perFileProgress] is kept for diagnostics; callers should prefer
* [progress] / [progressPercent]. * [progress] / [progressPercent].
*/ */
@Immutable
data class IndexingProgress( data class IndexingProgress(
val currentFile: String = "", val currentFile: String = "",
val currentIndex: Int = 0, val currentIndex: Int = 0,
@@ -25,6 +25,7 @@ class PreferencesManager(private val context: Context) {
val IS_LANGUAGE_EXPANDED = booleanPreferencesKey("is_language_expanded") val IS_LANGUAGE_EXPANDED = booleanPreferencesKey("is_language_expanded")
val IS_DICTIONARIES_EXPANDED = booleanPreferencesKey("is_dictionaries_expanded") val IS_DICTIONARIES_EXPANDED = booleanPreferencesKey("is_dictionaries_expanded")
val DISABLED_DICTIONARY_PATHS = stringSetPreferencesKey("disabled_dictionary_paths") val DISABLED_DICTIONARY_PATHS = stringSetPreferencesKey("disabled_dictionary_paths")
val HAD_NO_DICTIONARIES = booleanPreferencesKey("had_no_dictionaries")
} }
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
@@ -67,6 +68,17 @@ class PreferencesManager(private val context: Context) {
preferences[PreferencesKeys.DISABLED_DICTIONARY_PATHS] ?: emptySet() preferences[PreferencesKeys.DISABLED_DICTIONARY_PATHS] ?: emptySet()
} }
val hadNoDictionaries: Flow<Boolean> = context.dataStore.data
.map { preferences ->
preferences[PreferencesKeys.HAD_NO_DICTIONARIES] ?: true
}
suspend fun setHadNoDictionaries(value: Boolean) {
context.dataStore.edit { preferences ->
preferences[PreferencesKeys.HAD_NO_DICTIONARIES] = value
}
}
suspend fun setDictionaryActive(path: String, isActive: Boolean) { suspend fun setDictionaryActive(path: String, isActive: Boolean) {
context.dataStore.edit { preferences -> context.dataStore.edit { preferences ->
val disabledPaths = preferences[PreferencesKeys.DISABLED_DICTIONARY_PATHS] val disabledPaths = preferences[PreferencesKeys.DISABLED_DICTIONARY_PATHS]
@@ -46,9 +46,8 @@ class IndexEntryPagingSource(
private data class EmittedKey( private data class EmittedKey(
val dictPosition: Int, val dictPosition: Int,
val originalWord: String, val word: String,
val offset: Long, val offset: Long
val length: Int
) )
private val emittedArticles = HashSet<EmittedKey>() private val emittedArticles = HashSet<EmittedKey>()
@@ -103,6 +102,7 @@ class IndexEntryPagingSource(
val normalizedQuery = query.sanitizeQuery().lowercase().trim() val normalizedQuery = query.sanitizeQuery().lowercase().trim()
val ranked = rawResults.map { it.second } val ranked = rawResults.map { it.second }
.rankBySearchRelevance(SearchRankingContext(normalizedQuery)) .rankBySearchRelevance(SearchRankingContext(normalizedQuery))
.distinctBy { Triple(it.dictionaryPath, it.word, it.offset.value) }
rawResults.forEach { (position, entry) -> emittedArticles.add(entry.emittedKey(position)) } rawResults.forEach { (position, entry) -> emittedArticles.add(entry.emittedKey(position)) }
@@ -184,7 +184,7 @@ class IndexEntryPagingSource(
} }
private fun IndexEntry.emittedKey(dictPosition: Int) = private fun IndexEntry.emittedKey(dictPosition: Int) =
EmittedKey(dictPosition, originalWord, offset.value, length.value) EmittedKey(dictPosition, word, offset.value)
override fun getRefreshKey(state: PagingState<TailKey, IndexEntry>): TailKey? { override fun getRefreshKey(state: PagingState<TailKey, IndexEntry>): TailKey? {
return null return null
@@ -45,12 +45,6 @@ class IndexSearcher(private val context: Context) {
val dataStartOffset: Long val dataStartOffset: Long
) )
data class IndexComparisonSummary(
val expectedCount: Int,
val actualCount: Int,
val comparedCount: Int
)
data class RangeCursor( data class RangeCursor(
val rangeQuery: String, val rangeQuery: String,
val absoluteIndex: Int, val absoluteIndex: Int,
@@ -72,8 +66,6 @@ class IndexSearcher(private val context: Context) {
val cursorAfter: RangeCursor? val cursorAfter: RangeCursor?
) )
class IndexComparisonException(message: String) : IllegalStateException(message)
suspend fun findFirstEntry(pathOrUri: String, query: String): IndexEntry? = withContext(Dispatchers.Default) { suspend fun findFirstEntry(pathOrUri: String, query: String): IndexEntry? = withContext(Dispatchers.Default) {
val normalizedQuery = query.sanitizeQuery().lowercase().trim() val normalizedQuery = query.sanitizeQuery().lowercase().trim()
try { try {
@@ -244,99 +236,6 @@ class IndexSearcher(private val context: Context) {
} }
} }
suspend fun compareEntryGroups(
expectedIndexPath: String,
actualIndexPath: String,
onGroup: suspend (word: String, expected: List<IndexEntry>, actual: List<IndexEntry>) -> Unit
): IndexComparisonSummary = withContext(Dispatchers.IO) {
val expectedMetadata = loadMetadata(expectedIndexPath)
val actualMetadata = loadMetadata(actualIndexPath)
if (expectedMetadata.totalCount != actualMetadata.totalCount) {
throw IndexComparisonException(
"Index entry count differs: expected=${expectedMetadata.totalCount}, " +
"actual=${actualMetadata.totalCount}"
)
}
openSource(expectedIndexPath).use { expectedSource ->
openSource(actualIndexPath).use { actualSource ->
expectedSource.seek(expectedMetadata.dataStartOffset)
actualSource.seek(actualMetadata.dataStartOffset)
var expectedNext = readEntryOrNull(expectedSource, expectedMetadata, expectedMetadata.totalCount > 0)
var actualNext = readEntryOrNull(actualSource, actualMetadata, actualMetadata.totalCount > 0)
var comparedCount = 0
while (expectedNext != null && actualNext != null) {
if (expectedNext.word != actualNext.word) {
throw IndexComparisonException(
"Index words differ at entry $comparedCount: " +
"expected=${expectedNext.word}, actual=${actualNext.word}"
)
}
val word = expectedNext.word
val expectedGroup = mutableListOf<IndexEntry>()
val actualGroup = mutableListOf<IndexEntry>()
while (true) {
val current = expectedNext ?: break
if (current.word != word) break
expectedGroup.add(current)
comparedCount++
expectedNext = readEntryOrNull(
expectedSource,
expectedMetadata,
comparedCount < expectedMetadata.totalCount
)
}
var actualReadCount = comparedCount - expectedGroup.size
while (true) {
val current = actualNext ?: break
if (current.word != word) break
actualGroup.add(current)
actualReadCount++
actualNext = readEntryOrNull(
actualSource,
actualMetadata,
actualReadCount < actualMetadata.totalCount
)
}
if (expectedGroup.size != actualGroup.size) {
throw IndexComparisonException(
"Headword multiplicity differs for '$word': " +
"expected=${expectedGroup.size}, actual=${actualGroup.size}"
)
}
onGroup(word, expectedGroup, actualGroup)
if (comparedCount % 10_000 == 0) yield()
}
if (expectedNext != null || actualNext != null) {
throw IndexComparisonException(
"One index ended before the other at entry $comparedCount"
)
}
IndexComparisonSummary(
expectedCount = expectedMetadata.totalCount,
actualCount = actualMetadata.totalCount,
comparedCount = comparedCount
)
}
}
}
private fun readEntryOrNull(
source: RandomAccessSource,
metadata: IndexMetadata,
shouldRead: Boolean
): IndexEntry? = if (shouldRead) readEntry(source, metadata) else null
fun trimMemory(level: Int) { fun trimMemory(level: Int) {
metadataCache.trim(level) metadataCache.trim(level)
resultsCache.trim(level) resultsCache.trim(level)
@@ -27,9 +27,9 @@ import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
@@ -95,12 +95,10 @@ class LocalDictionaryRepository(
override val coroutineContext = Job() + ioDispatcher override val coroutineContext = Job() + ioDispatcher
private val mutableDictionaries = MutableStateFlow<List<Dictionary>>(emptyList()) private val mutableDictionaries = MutableStateFlow<List<Dictionary>>(emptyList())
val dictionaries: StateFlow<List<Dictionary>> val dictionaries: StateFlow<List<Dictionary>> = mutableDictionaries.asStateFlow()
get() = mutableDictionaries
private val mutableIndexingProgress = MutableStateFlow(IndexingProgress()) private val mutableIndexingProgress = MutableStateFlow(IndexingProgress())
val indexingProgress: StateFlow<IndexingProgress> val indexingProgress: StateFlow<IndexingProgress> = mutableIndexingProgress.asStateFlow()
get() = mutableIndexingProgress
private val indexingMutex = Mutex() private val indexingMutex = Mutex()
private val indexingSemaphore = Semaphore(1) private val indexingSemaphore = Semaphore(1)
@@ -489,7 +487,7 @@ class LocalDictionaryRepository(
if (dictFile.exists() && !dictFile.delete()) { if (dictFile.exists() && !dictFile.delete()) {
return@withContext Pair( return@withContext Pair(
"Failed to delete dictionary file: ${dictFile.absolutePath}", "Failed to delete dictionary file: ${dictFile.absolutePath}",
emptyList<String>(), emptyList(),
) )
} }
@@ -41,11 +41,21 @@ class DownloadManager(
val clampedProgress = progress.coerceIn(0f, 1f) val clampedProgress = progress.coerceIn(0f, 1f)
mutableDownloadProgressState.value = DownloadProgressState(state, clampedProgress) mutableDownloadProgressState.value = DownloadProgressState(state, clampedProgress)
mutableDownloadState.value = state mutableDownloadState.value = state
if (state is DownloadState.Success) {
onDownloadSuccess?.invoke()
} else if (state is DownloadState.Error || state is DownloadState.Cancelled) {
onTerminal?.invoke(state)
}
} }
var onFlowStarted: (() -> Unit)? = null
var onDownloadSuccess: (() -> Unit)? = null
var onTerminal: ((DownloadState) -> Unit)? = null
fun startDownload() { fun startDownload() {
if (downloadState.value is DownloadState.Loading) return if (downloadState.value is DownloadState.Loading) return
if (!downloadScope.isActive) return if (!downloadScope.isActive) return
onFlowStarted?.invoke()
cancelCleanupJob = null cancelCleanupJob = null
downloadJob = downloadScope.launch { downloadJob = downloadScope.launch {
mutex.withLock { mutex.withLock {
@@ -58,7 +68,7 @@ class DownloadManager(
if (enabledSources.isEmpty()) { if (enabledSources.isEmpty()) {
dictionaryRepository.performAllCleanup() dictionaryRepository.performAllCleanup()
updateDownloadProgressState(DownloadState.Success, 1f) updateDownloadProgressState(DownloadState.Idle, 0f)
return@withLock return@withLock
} }
@@ -137,12 +147,13 @@ class DownloadManager(
fun startDownloadForSources(sourceUrls: List<String>) { fun startDownloadForSources(sourceUrls: List<String>) {
if (downloadState.value is DownloadState.Loading) return if (downloadState.value is DownloadState.Loading) return
if (!downloadScope.isActive) return if (!downloadScope.isActive) return
onFlowStarted?.invoke()
cancelCleanupJob = null cancelCleanupJob = null
downloadJob = downloadScope.launch { downloadJob = downloadScope.launch {
mutex.withLock { mutex.withLock {
try { try {
if (sourceUrls.isEmpty()) { if (sourceUrls.isEmpty()) {
updateDownloadProgressState(DownloadState.Success, 1f) updateDownloadProgressState(DownloadState.Idle, 0f)
return@withLock return@withLock
} }
@@ -153,7 +164,7 @@ class DownloadManager(
} }
if (sources.isEmpty()) { if (sources.isEmpty()) {
updateDownloadProgressState(DownloadState.Success, 1f) updateDownloadProgressState(DownloadState.Idle, 0f)
return@withLock return@withLock
} }
@@ -1,4 +1,8 @@
package com.example.research.feature.download.model package com.example.research.feature.download.model
import androidx.compose.runtime.Immutable
@Immutable
sealed class DownloadState { sealed class DownloadState {
data object Idle : DownloadState() data object Idle : DownloadState()
data object Loading : DownloadState() data object Loading : DownloadState()
@@ -7,3 +11,6 @@ sealed class DownloadState {
data class Error(val message: String) : DownloadState() data class Error(val message: String) : DownloadState()
data object Cancelled : DownloadState() data object Cancelled : DownloadState()
} }
val DownloadState.isActive: Boolean
get() = this is DownloadState.Loading || this is DownloadState.Extracting || this is DownloadState.Success
@@ -3,19 +3,19 @@ package com.example.research.feature.download.service
import android.app.Service import android.app.Service
import android.content.Intent import android.content.Intent
import android.os.IBinder import android.os.IBinder
import android.util.Log
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import com.example.research.ReSearchApplication import com.example.research.ReSearchApplication
import com.example.research.common.progress.renderTitle import com.example.research.common.progress.DictionaryPipelineCoordinator
import com.example.research.common.progress.DictionaryProgressPresenter
import com.example.research.common.util.NotificationHelper import com.example.research.common.util.NotificationHelper
import com.example.research.core.util.OperationResult
import com.example.research.data.repository.LocalDictionaryRepository import com.example.research.data.repository.LocalDictionaryRepository
import com.example.research.feature.download.DownloadManager import com.example.research.feature.download.DownloadManager
import com.example.research.feature.download.model.DownloadState import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.* import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.* import kotlinx.coroutines.SupervisorJob
import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.cancel
import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.Job
class DictionaryForegroundService : Service() { class DictionaryForegroundService : Service() {
@@ -23,21 +23,21 @@ class DictionaryForegroundService : Service() {
const val ACTION_START = "com.example.research.START_DOWNLOAD" const val ACTION_START = "com.example.research.START_DOWNLOAD"
const val ACTION_STOP = "com.example.research.STOP_DOWNLOAD" const val ACTION_STOP = "com.example.research.STOP_DOWNLOAD"
const val ACTION_IMPORT = "com.example.research.START_IMPORT" const val ACTION_IMPORT = "com.example.research.START_IMPORT"
const val ACTION_FINISH = "com.example.research.FINISH_PIPELINE"
private const val NOTIFICATION_ID = NotificationHelper.NOTIFICATION_ID private const val NOTIFICATION_ID = NotificationHelper.NOTIFICATION_ID
} }
private lateinit var downloadManager: DownloadManager private lateinit var downloadManager: DownloadManager
private lateinit var localDictionaryRepository: LocalDictionaryRepository private lateinit var localDictionaryRepository: LocalDictionaryRepository
private lateinit var dictionaryImportManager: com.example.research.feature.import.DictionaryImportManager private lateinit var dictionaryImportManager: com.example.research.feature.import.DictionaryImportManager
private lateinit var progressStateHolder: com.example.research.common.progress.DictionaryProgressStateHolder private lateinit var pipelineCoordinator: DictionaryPipelineCoordinator
private lateinit var presenter: DictionaryProgressPresenter
private val notificationHelper by lazy { private val notificationHelper by lazy {
NotificationHelper(this) NotificationHelper(this)
} }
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
@Volatile @Volatile
private var isHandlingSuccess = false
@Volatile
private var latestStartId = 0 private var latestStartId = 0
override fun onCreate() { override fun onCreate() {
@@ -47,164 +47,8 @@ class DictionaryForegroundService : Service() {
downloadManager = app.downloadManager downloadManager = app.downloadManager
localDictionaryRepository = app.localDictionaryRepository localDictionaryRepository = app.localDictionaryRepository
dictionaryImportManager = app.dictionaryImportManager dictionaryImportManager = app.dictionaryImportManager
progressStateHolder = app.dictionaryProgressStateHolder pipelineCoordinator = app.dictionaryPipelineCoordinator
observeDownloadState() presenter = app.dictionaryProgressPresenter
observeImportState()
observeUnifiedProgress()
}
private fun observeDownloadState() {
serviceScope.launch {
downloadManager.downloadState.collect { state ->
when (state) {
is DownloadState.Success -> handleSuccess()
is DownloadState.Error -> {
notificationHelper.showErrorNotification()
stopForegroundService(removeNotification = false)
}
is DownloadState.Cancelled -> {
notificationHelper.cancelNotification()
stopForegroundService()
}
else -> {}
}
}
}
}
private fun observeImportState() {
serviceScope.launch {
dictionaryImportManager.importState.collect { state ->
when (state) {
is com.example.research.ui.settings.ImportState.Success -> handleSuccess()
is com.example.research.ui.settings.ImportState.Error -> {
notificationHelper.showErrorNotification()
stopForegroundService(removeNotification = false)
}
else -> {}
}
}
}
}
private fun observeUnifiedProgress() {
serviceScope.launch {
progressStateHolder.progressSnapshot
.collect { snapshot ->
if (snapshot != null) {
notificationHelper.showUnifiedProgressNotification(
title = snapshot.renderTitle(this@DictionaryForegroundService),
contentText = "${snapshot.percent}%",
progressPercent = snapshot.percent,
)
}
}
}
}
private suspend fun triggerReindexing(isImportFlow: Boolean) = withContext(Dispatchers.IO) {
try {
waitForIndexingCompletion()
delay(1.seconds)
val app = application as? ReSearchApplication
?: return@withContext
val path = app.preferencesManager.dictionaryPath
if (isImportFlow) {
dictionaryImportManager.updateExtractionProgress(0f)
}
app.downloadDictionaryRepository.extractArchives(onProgress = { progress ->
if (isImportFlow) {
dictionaryImportManager.updateExtractionProgress(progress)
}
})
val result = localDictionaryRepository.scanDirectory(path)
if (result is OperationResult.Success && result.data > 0) {
localDictionaryRepository.warmupIndexes()
app.downloadDictionaryRepository.performAllCleanup()
}
if (isImportFlow) {
dictionaryImportManager.markImportPipelineSuccess()
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e("DictionaryForegroundService", "triggerReindexing() failed: ${e.message}", e)
}
}
private suspend fun waitForIndexingCompletion(): Boolean {
val isIndexing = localDictionaryRepository.indexingProgress.first().isIndexing
if (!isIndexing) {
return true
}
var waitCount = 0
while (localDictionaryRepository.indexingProgress.first().isIndexing && waitCount < 100) {
delay(200.milliseconds)
waitCount++
}
return waitCount < 100
}
private fun handleSuccess() {
if (isHandlingSuccess) return
isHandlingSuccess = true
serviceScope.launch {
try {
val isImportFlow = dictionaryImportManager.importState.value !is com.example.research.ui.settings.ImportState.Idle
if (isImportFlow) {
val app = application as? ReSearchApplication
val path = app?.preferencesManager?.dictionaryPath
val dictionariesDir = path?.let { java.io.File(it) }
val filesBeforeReindex = dictionariesDir?.listFiles()?.map { it.name }?.toSet() ?: emptySet()
try {
triggerReindexing(true)
dictionaryImportManager.getAndClearImportedFiles()
} catch (e: CancellationException) {
val filesToCleanup = dictionaryImportManager.getAndClearImportedFiles()
filesToCleanup.forEach { file ->
try { if (file.exists()) file.delete() } catch (_: Exception) { /* Ignore cleanup failure */ }
}
if (dictionariesDir != null) {
cleanupNewFiles(dictionariesDir, filesBeforeReindex)
}
throw e
}
} else {
triggerReindexing(false)
}
if (isImportFlow) {
notificationHelper.showImportSuccessNotification()
dictionaryImportManager.clearImportState()
} else {
notificationHelper.showSuccessNotification()
}
} catch (e: Exception) {
if (e !is CancellationException) {
Log.e("DownloadForegroundService", "handleSuccess() failed: ${e.message}", e)
notificationHelper.showErrorNotification()
}
} finally {
isHandlingSuccess = false
stopForegroundService(removeNotification = false)
}
}
}
private fun cleanupNewFiles(dir: java.io.File, filesBeforeSnapshot: Set<String>) {
try {
dir.listFiles()?.forEach { file ->
if (file.name !in filesBeforeSnapshot) {
try { file.delete() } catch (_: Exception) { /* Ignore delete failure */ }
}
}
} catch (_: Exception) { /* Ignore directory listing failure */ }
} }
private fun startForegroundService() { private fun startForegroundService() {
@@ -249,15 +93,20 @@ class DictionaryForegroundService : Service() {
} }
private fun stopForTimeout(startId: Int) { private fun stopForTimeout(startId: Int) {
cancelAllWork()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelfResult(startId)
}
private fun cancelAllWork() {
serviceScope.coroutineContext[Job]?.cancelChildren() serviceScope.coroutineContext[Job]?.cancelChildren()
pipelineCoordinator.cancelPipeline()
if (localDictionaryRepository.isIndexingInProgress()) { if (localDictionaryRepository.isIndexingInProgress()) {
localDictionaryRepository.cancelIndexing() localDictionaryRepository.cancelIndexing()
} }
downloadManager.cancelDownload() downloadManager.cancelDownload()
dictionaryImportManager.cancelImport() dictionaryImportManager.cancelImport()
notificationHelper.cancelNotification() presenter.cancelProgress()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelfResult(startId)
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -275,21 +124,16 @@ class DictionaryForegroundService : Service() {
ACTION_IMPORT -> { ACTION_IMPORT -> {
startForegroundService() startForegroundService()
} }
ACTION_FINISH -> {
stopForegroundService(removeNotification = false)
}
ACTION_STOP -> { ACTION_STOP -> {
try { try {
startForegroundService() startForegroundService()
} catch (_: Exception) { } catch (_: Exception) {
// Service may already be stopped // Service may already be stopped
} }
serviceScope.coroutineContext[Job]?.cancelChildren() cancelAllWork()
if (localDictionaryRepository.isIndexingInProgress()) {
localDictionaryRepository.cancelIndexing()
}
downloadManager.cancelDownload()
dictionaryImportManager.cancelImport()
notificationHelper.cancelNotification()
stopForegroundService() stopForegroundService()
} }
} }
@@ -3,6 +3,7 @@ package com.example.research.feature.import
import android.app.Application import android.app.Application
import android.net.Uri import android.net.Uri
import com.example.research.R import com.example.research.R
import com.example.research.common.progress.ImportFlowOperations
import com.example.research.common.util.SafeFileName import com.example.research.common.util.SafeFileName
import com.example.research.core.performance.ReSearchTrace import com.example.research.core.performance.ReSearchTrace
import com.example.research.ui.settings.ImportState import com.example.research.ui.settings.ImportState
@@ -14,16 +15,19 @@ import java.util.Collections
class DictionaryImportManager( class DictionaryImportManager(
private val application: Application private val application: Application
) { ) : ImportFlowOperations {
private val mutableImportState = MutableStateFlow<ImportState>(ImportState.Idle) private val mutableImportState = MutableStateFlow<ImportState>(ImportState.Idle)
val importState: StateFlow<ImportState> = mutableImportState.asStateFlow() val importState: StateFlow<ImportState> = mutableImportState.asStateFlow()
private val managerScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val managerScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var importJob: Job? = null private var importJob: Job? = null
private val importedFiles = Collections.synchronizedList(mutableListOf<File>()) private val importedFiles = Collections.synchronizedList(mutableListOf<File>())
var onFlowStarted: (() -> Unit)? = null
var onTerminal: ((ImportState) -> Unit)? = null
fun importDictionaries(uris: List<Uri>) { fun importDictionaries(uris: List<Uri>) {
importJob?.cancel() importJob?.cancel()
onFlowStarted?.invoke()
importJob = managerScope.launch { importJob = managerScope.launch {
mutableImportState.value = ImportState.Idle mutableImportState.value = ImportState.Idle
performImport(uris) performImport(uris)
@@ -36,6 +40,8 @@ class DictionaryImportManager(
val dictionariesDir = File(context.getExternalFilesDir(null), "dictionaries") val dictionariesDir = File(context.getExternalFilesDir(null), "dictionaries")
val totalFiles = uris.size.coerceAtLeast(1) val totalFiles = uris.size.coerceAtLeast(1)
importedFiles.clear() importedFiles.clear()
val skippedNames = mutableListOf<String>()
val deferredErrors = mutableListOf<String>()
if (!dictionariesDir.exists()) { if (!dictionariesDir.exists()) {
dictionariesDir.mkdirs() dictionariesDir.mkdirs()
@@ -46,9 +52,7 @@ class DictionaryImportManager(
currentCoroutineContext().ensureActive() currentCoroutineContext().ensureActive()
val fileName = getFileName(uri) ?: continue val fileName = getFileName(uri) ?: continue
if (SafeFileName.validate(fileName) == null) { if (SafeFileName.validate(fileName) == null) {
mutableImportState.value = ImportState.Error( deferredErrors += context.getString(R.string.import_invalid_file_name, fileName)
context.getString(R.string.import_invalid_file_name, fileName)
)
continue continue
} }
val lowerFileName = fileName.lowercase() val lowerFileName = fileName.lowercase()
@@ -65,9 +69,7 @@ class DictionaryImportManager(
val destFile = File(dictionariesDir, fileName) val destFile = File(dictionariesDir, fileName)
if (destFile.exists()) { if (destFile.exists()) {
mutableImportState.value = ImportState.Error( skippedNames += fileName
context.getString(R.string.import_file_exists, fileName)
)
continue continue
} }
@@ -115,16 +117,30 @@ class DictionaryImportManager(
currentCoroutineContext().ensureActive() currentCoroutineContext().ensureActive()
mutableImportState.value = ImportState.Success val terminalState = resolveImportOutcome(
importedCount = importedFiles.size,
skippedNames = skippedNames,
deferredErrors = deferredErrors,
).toImportState(
existsMessage = { names ->
context.getString(R.string.import_file_exists, names.joinToString(separator = ", "))
},
invalidMessage = { message -> message },
nothingImportedMessage = { context.getString(R.string.import_nothing_imported) },
)
mutableImportState.value = terminalState
onTerminal?.invoke(terminalState)
} catch (e: CancellationException) { } catch (e: CancellationException) {
cleanupImportedFiles() cleanupImportedFiles()
mutableImportState.value = ImportState.Idle mutableImportState.value = ImportState.Idle
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
mutableImportState.value = ImportState.Error( val errorState = ImportState.Error(
context.getString(R.string.import_error, e.message ?: "Unknown error") context.getString(R.string.import_error, e.message ?: "Unknown error")
) )
mutableImportState.value = errorState
onTerminal?.invoke(errorState)
} }
} }
@@ -154,18 +170,18 @@ class DictionaryImportManager(
return size return size
} }
fun clearImportState() { override fun clearImportState() {
mutableImportState.value = ImportState.Idle mutableImportState.value = ImportState.Idle
} }
fun updateExtractionProgress(progress: Float) { override fun updateExtractionProgress(progress: Float) {
mutableImportState.update { current -> mutableImportState.update { current ->
if (current is ImportState.Idle || current is ImportState.Error) current if (current is ImportState.Idle || current is ImportState.Error) current
else ImportState.Extracting(progress.coerceIn(0f, 1f)) else ImportState.Extracting(progress.coerceIn(0f, 1f))
} }
} }
fun markImportPipelineSuccess() { override fun markImportPipelineSuccess() {
mutableImportState.update { current -> mutableImportState.update { current ->
if (current is ImportState.Idle || current is ImportState.Error) current if (current is ImportState.Idle || current is ImportState.Error) current
else ImportState.Success else ImportState.Success
@@ -179,7 +195,7 @@ class DictionaryImportManager(
mutableImportState.value = ImportState.Idle mutableImportState.value = ImportState.Idle
} }
fun getAndClearImportedFiles(): List<File> { override fun getAndClearImportedFiles(): List<File> {
val files = importedFiles.toList() val files = importedFiles.toList()
importedFiles.clear() importedFiles.clear()
return files return files
@@ -0,0 +1,32 @@
package com.example.research.feature.import
import com.example.research.ui.settings.ImportState
internal sealed interface ImportOutcome {
data object Imported : ImportOutcome
data class AlreadyExists(val names: List<String>) : ImportOutcome
data class InvalidFile(val message: String) : ImportOutcome
data object NothingImported : ImportOutcome
}
internal fun resolveImportOutcome(
importedCount: Int,
skippedNames: List<String>,
deferredErrors: List<String>,
): ImportOutcome = when {
importedCount > 0 -> ImportOutcome.Imported
skippedNames.isNotEmpty() -> ImportOutcome.AlreadyExists(skippedNames)
deferredErrors.isNotEmpty() -> ImportOutcome.InvalidFile(deferredErrors.first())
else -> ImportOutcome.NothingImported
}
internal fun ImportOutcome.toImportState(
existsMessage: (List<String>) -> String,
invalidMessage: (String) -> String,
nothingImportedMessage: () -> String,
): ImportState = when (this) {
ImportOutcome.Imported -> ImportState.Success
is ImportOutcome.AlreadyExists -> ImportState.Error(existsMessage(names))
is ImportOutcome.InvalidFile -> ImportState.Error(invalidMessage(message))
ImportOutcome.NothingImported -> ImportState.Error(nothingImportedMessage())
}
@@ -78,6 +78,4 @@ internal object AboutLibrariesParser {
context.resources.openRawResource(resourceId) context.resources.openRawResource(resourceId)
.bufferedReader() .bufferedReader()
.use { reader -> json.decodeFromString(reader.readText()) } .use { reader -> json.decodeFromString(reader.readText()) }
fun decode(source: String): AboutLibrariesData = json.decodeFromString(source)
} }
@@ -1,6 +1,5 @@
package com.example.research.ui.about package com.example.research.ui.about
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
@@ -27,6 +26,7 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -43,6 +43,8 @@ import androidx.compose.ui.unit.dp
import com.example.research.R import com.example.research.R
import com.example.research.common.ui.components.OutlinedChoiceButton import com.example.research.common.ui.components.OutlinedChoiceButton
import com.example.research.ui.theme.AppWindowInsets import com.example.research.ui.theme.AppWindowInsets
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
@@ -57,8 +59,10 @@ fun AboutScreen(
} }
val versionName = packageInfo?.versionName ?: stringResource(R.string.version_unknown) val versionName = packageInfo?.versionName ?: stringResource(R.string.version_unknown)
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val librariesData = remember(context) { val librariesData by produceState<AboutLibrariesData?>(initialValue = null, context) {
AboutLibrariesParser.read(context, R.raw.aboutlibraries) value = withContext(Dispatchers.IO) {
AboutLibrariesParser.read(context, R.raw.aboutlibraries)
}
} }
var expandedLibraryId by remember { mutableStateOf<String?>(null) } var expandedLibraryId by remember { mutableStateOf<String?>(null) }
var dialogLicense by remember { mutableStateOf<AboutLicense?>(null) } var dialogLicense by remember { mutableStateOf<AboutLicense?>(null) }
@@ -93,12 +97,12 @@ fun AboutScreen(
AboutHeader(versionName = versionName) AboutHeader(versionName = versionName)
} }
items( items(
items = librariesData.libraries, items = librariesData?.libraries.orEmpty(),
key = AboutLibrary::uniqueId, key = AboutLibrary::uniqueId,
) { library -> ) { library ->
AboutLibraryRow( AboutLibraryRow(
library = library, library = library,
licenses = librariesData.licenses, licenses = librariesData?.licenses.orEmpty(),
expanded = expandedLibraryId == library.uniqueId, expanded = expandedLibraryId == library.uniqueId,
onToggle = { onToggle = {
expandedLibraryId = if (expandedLibraryId == library.uniqueId) { expandedLibraryId = if (expandedLibraryId == library.uniqueId) {
@@ -191,8 +195,7 @@ private fun AboutLibraryRow(
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.padding(horizontal = 16.dp, vertical = 12.dp) .padding(horizontal = 16.dp, vertical = 12.dp),
.animateContentSize(),
verticalArrangement = Arrangement.spacedBy(5.dp) verticalArrangement = Arrangement.spacedBy(5.dp)
) { ) {
Row( Row(
@@ -41,6 +41,7 @@ fun SearchResultsList(
) { ) {
items( items(
count = results.itemCount, count = results.itemCount,
key = results.itemKey { "${it.dictionaryPath}:${it.offset.value}:${it.word}" },
contentType = results.itemContentType { "search_result" } contentType = results.itemContentType { "search_result" }
) { index -> ) { index ->
results[index]?.let { entry -> results[index]?.let { entry ->
@@ -2,22 +2,18 @@ package com.example.research.ui.navigation
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.* import androidx.compose.runtime.saveable.*
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND
import com.example.research.feature.search.SearchAction import com.example.research.feature.search.SearchAction
import com.example.research.feature.search.SearchViewModel import com.example.research.feature.search.SearchViewModel
import com.example.research.feature.download.model.DownloadState
import com.example.research.ui.about.AboutScreen import com.example.research.ui.about.AboutScreen
import com.example.research.ui.article.ArticleRoute import com.example.research.ui.article.ArticleRoute
import com.example.research.ui.main.MainRoute import com.example.research.ui.main.MainRoute
import com.example.research.ui.settings.ImportState
import com.example.research.ui.settings.SettingsRoute import com.example.research.ui.settings.SettingsRoute
import com.example.research.ui.settings.SettingsViewModel import com.example.research.ui.settings.SettingsViewModel
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.milliseconds
private data class NavigationState( private data class NavigationState(
val shouldShowSettings: Boolean, val shouldShowSettings: Boolean,
@@ -33,7 +29,8 @@ enum class Screen {
@Composable @Composable
fun AppNavigation( fun AppNavigation(
searchViewModel: SearchViewModel, searchViewModel: SearchViewModel,
settingsViewModel: SettingsViewModel settingsViewModel: SettingsViewModel,
seedNoDictionaries: Boolean = false
) { ) {
val screenStack = rememberSaveable( val screenStack = rememberSaveable(
saver = listSaver( saver = listSaver(
@@ -53,39 +50,15 @@ fun AppNavigation(
val currentScreen = screenStack.lastOrNull() ?: Screen.Home val currentScreen = screenStack.lastOrNull() ?: Screen.Home
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle() val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
val progressSnapshot by settingsViewModel.progressSnapshot.collectAsStateWithLifecycle()
val isPipelineActiveByState = remember(
progressSnapshot,
settingsState.indexingProgress.isIndexing,
settingsState.downloadState,
settingsState.importState,
) {
progressSnapshot != null ||
settingsState.indexingProgress.isIndexing ||
settingsState.downloadState is DownloadState.Loading ||
settingsState.downloadState is DownloadState.Extracting ||
settingsState.downloadState is DownloadState.Success ||
settingsState.importState is ImportState.Importing ||
settingsState.importState is ImportState.Extracting ||
settingsState.importState is ImportState.Success
}
var stickyPipelineActive by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(isPipelineActiveByState) {
if (isPipelineActiveByState) {
stickyPipelineActive = true
} else {
delay(1500.milliseconds)
stickyPipelineActive = false
}
}
val navState by remember { val navState by remember {
derivedStateOf { derivedStateOf {
val isOperationActive = stickyPipelineActive val isOperationActive = !settingsState.pipelineIdle
val hasConfirmedNoDictionaries = val hasConfirmedNoDictionaries =
settingsState.hasCompletedStartupScan && settingsState.dictionaries.isEmpty() settingsState.hasCompletedStartupScan && settingsState.dictionaries.isEmpty()
val hasSeededNoDictionaries = seedNoDictionaries && !settingsState.hasCompletedStartupScan
NavigationState( NavigationState(
shouldShowSettings = hasConfirmedNoDictionaries || isOperationActive, shouldShowSettings = hasConfirmedNoDictionaries || hasSeededNoDictionaries || isOperationActive,
showBackButtonInSettings = settingsState.dictionaries.isNotEmpty() && !isOperationActive, showBackButtonInSettings = settingsState.dictionaries.isNotEmpty() && !isOperationActive,
isOperationActive = isOperationActive, isOperationActive = isOperationActive,
) )
@@ -95,7 +68,7 @@ fun AppNavigation(
val shouldShowSettings = navState.shouldShowSettings val shouldShowSettings = navState.shouldShowSettings
val showBackButtonInSettings = navState.showBackButtonInSettings val showBackButtonInSettings = navState.showBackButtonInSettings
val isOperationActive = navState.isOperationActive val isOperationActive = navState.isOperationActive
val isWideScreen = currentWindowAdaptiveInfo() val isWideScreen = currentWindowAdaptiveInfoV2()
.windowSizeClass .windowSizeClass
.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND) .isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)
@@ -1,5 +1,8 @@
package com.example.research.ui.settings package com.example.research.ui.settings
import androidx.compose.runtime.Immutable
@Immutable
sealed interface ImportState { sealed interface ImportState {
data object Idle : ImportState data object Idle : ImportState
data class Importing(val progress: Float) : ImportState data class Importing(val progress: Float) : ImportState
@@ -7,3 +10,6 @@ sealed interface ImportState {
data object Success : ImportState data object Success : ImportState
data class Error(val message: String) : ImportState data class Error(val message: String) : ImportState
} }
val ImportState.isActive: Boolean
get() = this is ImportState.Importing || this is ImportState.Extracting || this is ImportState.Success
@@ -1,6 +1,7 @@
package com.example.research.ui.settings package com.example.research.ui.settings
import android.net.Uri import android.net.Uri
import androidx.compose.runtime.Immutable
import com.example.research.core.domain.model.AppTheme import com.example.research.core.domain.model.AppTheme
import com.example.research.core.domain.model.Dictionary import com.example.research.core.domain.model.Dictionary
import com.example.research.core.domain.model.DictionarySource import com.example.research.core.domain.model.DictionarySource
@@ -8,6 +9,7 @@ import com.example.research.core.domain.model.IndexingProgress
import com.example.research.DictionaryStatus import com.example.research.DictionaryStatus
import com.example.research.feature.download.model.DownloadState import com.example.research.feature.download.model.DownloadState
@Immutable
data class SettingsUiState( data class SettingsUiState(
val theme: AppTheme = AppTheme.SYSTEM, val theme: AppTheme = AppTheme.SYSTEM,
val language: String = "system", val language: String = "system",
@@ -21,6 +23,7 @@ data class SettingsUiState(
val importState: ImportState = ImportState.Idle, val importState: ImportState = ImportState.Idle,
val dictionarySources: List<DictionarySource> = emptyList(), val dictionarySources: List<DictionarySource> = emptyList(),
val hasCompletedStartupScan: Boolean = false, val hasCompletedStartupScan: Boolean = false,
val pipelineIdle: Boolean = true,
val appVersion: String = "1.0", val appVersion: String = "1.0",
) )
@@ -19,6 +19,7 @@ import com.example.research.data.local.preferences.PreferencesManager
import com.example.research.data.repository.LocalDictionaryRepository import com.example.research.data.repository.LocalDictionaryRepository
import com.example.research.feature.download.DownloadManager import com.example.research.feature.download.DownloadManager
import com.example.research.feature.download.model.DownloadState import com.example.research.feature.download.model.DownloadState
import com.example.research.feature.download.model.isActive
import com.example.research.feature.download.repository.DictionaryRepository import com.example.research.feature.download.repository.DictionaryRepository
import com.example.research.feature.import.DictionaryImportManager import com.example.research.feature.import.DictionaryImportManager
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -89,9 +90,20 @@ class SettingsViewModel(
init { init {
setupStateObservation() setupStateObservation()
observeDictionariesForSeedFlag()
initialize() initialize()
} }
private fun observeDictionariesForSeedFlag() {
viewModelScope.launch {
localDictionaryRepository.dictionaries.collect { dictionaries ->
if (hasCompletedStartupScan.value) {
preferencesManager.setHadNoDictionaries(dictionaries.isEmpty())
}
}
}
}
private fun setupStateObservation() { private fun setupStateObservation() {
viewModelScope.launch { viewModelScope.launch {
var wasIndexing = false var wasIndexing = false
@@ -214,6 +226,9 @@ class SettingsViewModel(
importState = operationState.importState, importState = operationState.importState,
dictionaryStatus = operationState.dictionaryStatus, dictionaryStatus = operationState.dictionaryStatus,
hasCompletedStartupScan = operationState.hasCompletedStartupScan, hasCompletedStartupScan = operationState.hasCompletedStartupScan,
pipelineIdle = !isIndexing &&
!operationState.downloadState.isActive &&
!operationState.importState.isActive,
isThemeExpanded = operationState.isThemeExpanded, isThemeExpanded = operationState.isThemeExpanded,
isLanguageExpanded = operationState.isLanguageExpanded, isLanguageExpanded = operationState.isLanguageExpanded,
isDictionariesExpanded = operationState.isDictionariesExpanded, isDictionariesExpanded = operationState.isDictionariesExpanded,
@@ -245,6 +260,9 @@ class SettingsViewModel(
android.util.Log.w("SettingsViewModel", "Failed to load language preference: ${e.message}") android.util.Log.w("SettingsViewModel", "Failed to load language preference: ${e.message}")
} }
hasCompletedStartupScan.value = true hasCompletedStartupScan.value = true
preferencesManager.setHadNoDictionaries(
localDictionaryRepository.dictionaries.value.isEmpty()
)
} }
} }
} }
@@ -28,6 +28,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
@@ -83,7 +84,7 @@ fun DictionaryListItem(
onToggle: () -> Unit, onToggle: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
canDelete: Boolean = true isDeleteBlocked: () -> Boolean = { false }
) { ) {
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
val density = LocalDensity.current val density = LocalDensity.current
@@ -95,8 +96,9 @@ fun DictionaryListItem(
val offsetAnim = remember { Animatable(0f) } val offsetAnim = remember { Animatable(0f) }
var rawOffset by remember { mutableFloatStateOf(0f) } var rawOffset by remember { mutableFloatStateOf(0f) }
val isDeleteRevealed by remember(canDelete) { val isDeleteBlockedCurrent by rememberUpdatedState(isDeleteBlocked)
derivedStateOf { canDelete && rawOffset <= -swipeThresholdPx } val isDeleteRevealed by remember {
derivedStateOf { !isDeleteBlockedCurrent() && rawOffset <= -swipeThresholdPx }
} }
LaunchedEffect(isDeleteRevealed) { LaunchedEffect(isDeleteRevealed) {
@@ -105,11 +107,14 @@ fun DictionaryListItem(
} }
} }
LaunchedEffect(canDelete) { LaunchedEffect(Unit) {
if (!canDelete) { snapshotFlow { isDeleteBlockedCurrent() }
rawOffset = 0f .collect { blocked ->
offsetAnim.snapTo(0f) if (blocked) {
} offsetAnim.snapTo(0f)
rawOffset = 0f
}
}
} }
val currentOnDelete by rememberUpdatedState(onDelete) val currentOnDelete by rememberUpdatedState(onDelete)
@@ -142,16 +147,18 @@ fun DictionaryListItem(
.height(DictionaryItemHeight) .height(DictionaryItemHeight)
.background(MaterialTheme.colorScheme.surfaceVariant) .background(MaterialTheme.colorScheme.surfaceVariant)
.graphicsLayer { translationX = offsetAnim.value } .graphicsLayer { translationX = offsetAnim.value }
.pointerInput(canDelete, maxSwipePx, swipeThresholdPx) { .pointerInput(maxSwipePx, swipeThresholdPx) {
if (!canDelete) return@pointerInput
detectHorizontalDragGestures( detectHorizontalDragGestures(
onDragStart = { onDragStart = {
if (isDeleteBlockedCurrent()) return@detectHorizontalDragGestures
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}, },
onDragEnd = { onDragEnd = {
if (isDeleteBlockedCurrent()) return@detectHorizontalDragGestures
val target = if (rawOffset <= -swipeThresholdPx) -maxSwipePx else 0f val target = if (rawOffset <= -swipeThresholdPx) -maxSwipePx else 0f
rawOffset = target rawOffset = target
scope.launch { scope.launch {
if (isDeleteBlockedCurrent()) return@launch
offsetAnim.animateTo( offsetAnim.animateTo(
target, target,
animationSpec = spring(stiffness = Spring.StiffnessMedium) animationSpec = spring(stiffness = Spring.StiffnessMedium)
@@ -159,9 +166,11 @@ fun DictionaryListItem(
} }
}, },
onDragCancel = { onDragCancel = {
if (isDeleteBlockedCurrent()) return@detectHorizontalDragGestures
val target = if (rawOffset <= -swipeThresholdPx) -maxSwipePx else 0f val target = if (rawOffset <= -swipeThresholdPx) -maxSwipePx else 0f
rawOffset = target rawOffset = target
scope.launch { scope.launch {
if (isDeleteBlockedCurrent()) return@launch
offsetAnim.animateTo( offsetAnim.animateTo(
target, target,
animationSpec = spring(stiffness = Spring.StiffnessMedium) animationSpec = spring(stiffness = Spring.StiffnessMedium)
@@ -169,9 +178,13 @@ fun DictionaryListItem(
} }
}, },
onHorizontalDrag = { _, dragAmount -> onHorizontalDrag = { _, dragAmount ->
if (isDeleteBlockedCurrent()) return@detectHorizontalDragGestures
val newOffset = (rawOffset + dragAmount).coerceIn(-maxSwipePx, 0f) val newOffset = (rawOffset + dragAmount).coerceIn(-maxSwipePx, 0f)
rawOffset = newOffset rawOffset = newOffset
scope.launch { offsetAnim.snapTo(newOffset) } scope.launch {
if (isDeleteBlockedCurrent()) return@launch
offsetAnim.snapTo(newOffset)
}
} }
) )
}, },
@@ -263,8 +276,10 @@ fun DictionaryListItem(
onClick = { onClick = {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
currentOnDelete() currentOnDelete()
scope.launch { offsetAnim.snapTo(0f) } scope.launch {
rawOffset = 0f offsetAnim.snapTo(0f)
rawOffset = 0f
}
}, },
modifier = Modifier modifier = Modifier
.align(Alignment.CenterEnd) .align(Alignment.CenterEnd)
@@ -11,8 +11,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
@@ -21,6 +23,7 @@ import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.invisibleToUser
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -75,6 +78,13 @@ fun DictionaryManagement(
derivedStateOf { importState is ImportState.Importing || importState is ImportState.Extracting } derivedStateOf { importState is ImportState.Importing || importState is ImportState.Extracting }
} }
val hasEnabledSources by remember(sources) { derivedStateOf { sources.any { it.isEnabled } } } val hasEnabledSources by remember(sources) { derivedStateOf { sources.any { it.isEnabled } } }
val isBlockingMutations = isInProgress
// Invariant: the mutation-block flag is read only at state-read/gesture time (derivedStateOf,
// snapshotFlow, pointerInput callbacks), never at row composition time. A composition-time read
// (e.g. Modifier.alpha(if (isDeleteBlocked()) ...)) would invalidate every row on each flip.
val isMutationsBlockedState = rememberUpdatedState(isBlockingMutations)
val isDeleteBlocked = remember { { isMutationsBlockedState.value } }
SectionCard( SectionCard(
modifier = modifier modifier = modifier
@@ -125,13 +135,13 @@ fun DictionaryManagement(
// Disable accessibility on background content during import to improve performance. // Disable accessibility on background content during import to improve performance.
// The progress dialog remains accessible for cancellation. // The progress dialog remains accessible for cancellation.
val backgroundModifier = if (isInProgress) { val backgroundModifier = if (isBlockingMutations) {
Modifier.semantics(mergeDescendants = true) { } Modifier.semantics { invisibleToUser() }
} else { } else {
Modifier Modifier
} }
if (dictionaries.isEmpty() && !isInProgress) { if (dictionaries.isEmpty() && !isBlockingMutations) {
Column( Column(
modifier = backgroundModifier modifier = backgroundModifier
.fillMaxWidth() .fillMaxWidth()
@@ -153,7 +163,7 @@ fun DictionaryManagement(
) )
} }
} else { } else {
if (dictionaryStatus is DictionaryStatus.NeedsUpdate && !isInProgress && hasEnabledSources) { if (dictionaryStatus is DictionaryStatus.NeedsUpdate && !isBlockingMutations && hasEnabledSources) {
DictionaryUpdateCard( DictionaryUpdateCard(
onStartDownload = onStartDownload, onStartDownload = onStartDownload,
modifier = backgroundModifier modifier = backgroundModifier
@@ -167,10 +177,10 @@ fun DictionaryManagement(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.then(backgroundModifier), .then(backgroundModifier),
canDelete = !isInProgress isDeleteBlocked = isDeleteBlocked
) )
if (!isInProgress) { if (!isBlockingMutations) {
DictionaryActionButtons( DictionaryActionButtons(
onAddSource = { onAddSource = {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
@@ -248,19 +258,21 @@ private fun DictionaryListSection(
onToggleDictionary: (String) -> Unit, onToggleDictionary: (String) -> Unit,
onDeleteDictionary: (Dictionary) -> Unit, onDeleteDictionary: (Dictionary) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
canDelete: Boolean = true isDeleteBlocked: () -> Boolean = { false }
) { ) {
Column( Column(
modifier = modifier, modifier = modifier,
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
dictionaries.forEach { dictionary -> dictionaries.forEach { dictionary ->
DictionaryListItem( key(dictionary.path) {
dictionary = dictionary, DictionaryListItem(
onToggle = { onToggleDictionary(dictionary.path) }, dictionary = dictionary,
onDelete = { onDeleteDictionary(dictionary) }, onToggle = { onToggleDictionary(dictionary.path) },
canDelete = canDelete onDelete = { onDeleteDictionary(dictionary) },
) isDeleteBlocked = isDeleteBlocked
)
}
} }
} }
} }
@@ -41,6 +41,11 @@ fun DictionaryProgressSection(
var displayedPercent by remember { mutableIntStateOf(percent) } var displayedPercent by remember { mutableIntStateOf(percent) }
var displayedTestTag by remember { mutableStateOf(testTag) } var displayedTestTag by remember { mutableStateOf(testTag) }
LaunchedEffect(visible) {
if (visible) {
monotonicTargetProgress = targetProgress
}
}
LaunchedEffect(visible, targetProgress) { LaunchedEffect(visible, targetProgress) {
if (visible) { if (visible) {
monotonicTargetProgress = maxOf(monotonicTargetProgress, targetProgress) monotonicTargetProgress = maxOf(monotonicTargetProgress, targetProgress)
+2 -1
View File
@@ -68,8 +68,9 @@
<string name="dictionary_management_title">Словари</string> <string name="dictionary_management_title">Словари</string>
<string name="import_dictionary_button">Выбрать файлы</string> <string name="import_dictionary_button">Выбрать файлы</string>
<string name="import_error">Не удалось импортировать словарь: %1$s</string> <string name="import_error">Не удалось импортировать словарь: %1$s</string>
<string name="import_file_exists">Файл уже существует: %1$s</string> <string name="import_file_exists">Словарь уже добавлен: %1$s</string>
<string name="import_invalid_file_name">Недопустимое имя файла: %1$s</string> <string name="import_invalid_file_name">Недопустимое имя файла: %1$s</string>
<string name="import_nothing_imported">В выбранном нет поддерживаемых файлов словарей</string>
<string name="dictionary_source_url_hint">URL</string> <string name="dictionary_source_url_hint">URL</string>
<string name="dictionary_source_add_button">Добавить источник</string> <string name="dictionary_source_add_button">Добавить источник</string>
<string name="dictionary_source_duplicate">Этот URL уже существует</string> <string name="dictionary_source_duplicate">Этот URL уже существует</string>
+2 -1
View File
@@ -66,8 +66,9 @@
<string name="dictionary_management_title">Dictionaries</string> <string name="dictionary_management_title">Dictionaries</string>
<string name="import_dictionary_button">Select files</string> <string name="import_dictionary_button">Select files</string>
<string name="import_error">Failed to import dictionary: %1$s</string> <string name="import_error">Failed to import dictionary: %1$s</string>
<string name="import_file_exists">File already exists: %1$s</string> <string name="import_file_exists">Dictionary already added: %1$s</string>
<string name="import_invalid_file_name">Invalid file name: %1$s</string> <string name="import_invalid_file_name">Invalid file name: %1$s</string>
<string name="import_nothing_imported">No supported dictionary files were found in the selection</string>
<string name="dictionary_source_url_hint">URL</string> <string name="dictionary_source_url_hint">URL</string>
<string name="dictionary_source_add_button">Add source</string> <string name="dictionary_source_add_button">Add source</string>
<string name="dictionary_source_duplicate">This URL already exists</string> <string name="dictionary_source_duplicate">This URL already exists</string>
+1 -1
View File
@@ -1,5 +1,5 @@
[versions] [versions]
aboutlibraries = "15.0.4" aboutlibraries = "15.2.0"
activity_compose = "1.13.0" activity_compose = "1.13.0"
agp = "9.3.1" agp = "9.3.1"
compose_bom = "2026.08.00" compose_bom = "2026.08.00"