From 5ea85e0b90c59934396cdc5421820ce2fbaa3111 Mon Sep 17 00:00:00 2001 From: 283375 Date: Tue, 30 Jun 2026 02:30:44 +0800 Subject: [PATCH 01/12] progress total value limit --- core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/Progress.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/Progress.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/Progress.kt index 5ad035b5..72371b1c 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/Progress.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/Progress.kt @@ -14,7 +14,7 @@ data class Progress( (current.toFloat() / total).coerceIn(0f, 1f) } - fun increment() = copy(current = current + 1) + fun increment() = copy(current = (current + 1).coerceAtMost(total)) companion object { val INDETERMINATE = Progress() From 0db763000b58146f9d8680d1c713366e47e84cbb Mon Sep 17 00:00:00 2001 From: 283375 Date: Tue, 30 Jun 2026 03:04:51 +0800 Subject: [PATCH 02/12] image hashes database build progress --- .../jobs/ImageHashesDatabaseBuilderJob.kt | 9 +++------ .../OcrDependencyImageHashesDatabaseStatusViewer.kt | 3 ++- .../dependencies/OcrDependenciesScreenViewModel.kt | 12 ++++-------- .../core/ocr/ImageHashesDatabaseBuilder.kt | 8 +++++--- 4 files changed, 14 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/ImageHashesDatabaseBuilderJob.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/ImageHashesDatabaseBuilderJob.kt index e13dc346..4e825e6c 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/ImageHashesDatabaseBuilderJob.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/ImageHashesDatabaseBuilderJob.kt @@ -17,6 +17,7 @@ import kotlinx.io.files.SystemFileSystem import xyz.sevive.arcaeaoffline.core.ocr.ImageHashesDatabaseBuilder import xyz.sevive.arcaeaoffline.data.OcrDependencyPaths import xyz.sevive.arcaeaoffline.helpers.ArcaeaPackageHelper +import xyz.sevive.arcaeaoffline.helpers.toWorkData class ImageHashesDatabaseBuilderJob( context: Context, @@ -27,8 +28,6 @@ class ImageHashesDatabaseBuilderJob( companion object { const val NAME = "ImageHashesDatabaseBuilderJob" private const val LOG_TAG = "ImageHashesDbBuilderJob" - const val KEY_PROGRESS = "progress" - const val KEY_PROGRESS_TOTAL = "progressTotal" } override suspend fun doWork(): Result { @@ -52,10 +51,8 @@ class ImageHashesDatabaseBuilderJob( val builder = ImageHashesDatabaseBuilder(conn) collectScope.launch { - builder.buildProgress.collectLatest { - setProgress( - workDataOf(KEY_PROGRESS to it?.first, KEY_PROGRESS_TOTAL to it?.second), - ) + builder.buildProgress.collectLatest { progress -> + progress?.let { setProgress(it.toWorkData()) } } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ocr/OcrDependencyImageHashesDatabaseStatusViewer.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ocr/OcrDependencyImageHashesDatabaseStatusViewer.kt index 659df437..eda183bc 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ocr/OcrDependencyImageHashesDatabaseStatusViewer.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ocr/OcrDependencyImageHashesDatabaseStatusViewer.kt @@ -7,11 +7,12 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import xyz.sevive.arcaeaoffline.R +import xyz.sevive.arcaeaoffline.core.Progress import xyz.sevive.arcaeaoffline.helpers.ImageHashesDatabaseStatusDetail import xyz.sevive.arcaeaoffline.ui.components.LinearProgressIndicatorWrapper data class OcrDependencyImageHashesDatabaseStatusUiState( - val progress: Pair? = null, + val progress: Progress? = null, val statusDetail: ImageHashesDatabaseStatusDetail = ImageHashesDatabaseStatusDetail(), ) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/dependencies/OcrDependenciesScreenViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/dependencies/OcrDependenciesScreenViewModel.kt index b6aa4be6..78e0e95e 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/dependencies/OcrDependenciesScreenViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/dependencies/OcrDependenciesScreenViewModel.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.launch import kotlinx.io.buffered import kotlinx.io.files.SystemFileSystem import org.opencv.ml.KNearest +import xyz.sevive.arcaeaoffline.core.Progress import xyz.sevive.arcaeaoffline.core.ocr.ImageHashesDatabase import xyz.sevive.arcaeaoffline.data.OcrDependencyPaths import xyz.sevive.arcaeaoffline.helpers.ArcaeaResourcesStateHolder @@ -30,6 +31,7 @@ import xyz.sevive.arcaeaoffline.helpers.OcrDependencyLoader import xyz.sevive.arcaeaoffline.helpers.OcrDependencyStatusBuilder import xyz.sevive.arcaeaoffline.helpers.context.copyToCache import xyz.sevive.arcaeaoffline.helpers.context.getFileSize +import xyz.sevive.arcaeaoffline.helpers.fromWorkInfo import xyz.sevive.arcaeaoffline.jobs.ImageHashesDatabaseBuilderJob import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyCrnnModelStatusUiState import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyImageHashesDatabaseStatusUiState @@ -69,14 +71,8 @@ class OcrDependenciesScreenViewModel( private val imagesHashesDatabaseBuildProgress = imageHashesDatabaseBuilderJobInfo - .map { - if (it == null) return@map null - - val progress = it.progress.getInt(ImageHashesDatabaseBuilderJob.KEY_PROGRESS, -1) - val total = it.progress.getInt(ImageHashesDatabaseBuilderJob.KEY_PROGRESS_TOTAL, -1) - - if (progress == -1) null else progress to total - }.stateIn(viewModelScope, sharingStarted, null) + .map { Progress.fromWorkInfo(it) } + .stateIn(viewModelScope, sharingStarted, null) private val imagesHashesDatabaseStatusDetail = MutableStateFlow(ImageHashesDatabaseStatusDetail()) diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/ImageHashesDatabaseBuilder.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/ImageHashesDatabaseBuilder.kt index 4edbb077..17b9c2be 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/ImageHashesDatabaseBuilder.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/ImageHashesDatabaseBuilder.kt @@ -5,8 +5,10 @@ import androidx.sqlite.execSQL import co.touchlab.kermit.Logger import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.io.files.Path import org.opencv.core.Mat +import xyz.sevive.arcaeaoffline.core.Progress import kotlin.time.Clock import kotlin.time.Instant @@ -26,17 +28,17 @@ class ImageHashesDatabaseBuilder( val inputToGrayscaleImage: (T) -> Mat, ) - private val _buildProgress = MutableStateFlow?>(null) + private val _buildProgress = MutableStateFlow(null) val buildProgress = _buildProgress.asStateFlow() private val tasks = mutableListOf>() private fun initBuildProgress() { - _buildProgress.value = 0 to tasks.size + _buildProgress.update { Progress(current = 0, total = tasks.size) } } private fun increaseBuildProgress() { - _buildProgress.value = _buildProgress.value?.let { it.copy(first = it.first + 1) } + _buildProgress.update { it?.increment() } } private fun resetBuildProgress() { From 67e460dbc72a80f5ef37ad6ee8b7579ccb13b038 Mon Sep 17 00:00:00 2001 From: 283375 Date: Tue, 30 Jun 2026 03:08:31 +0800 Subject: [PATCH 03/12] r30 update progress --- .../sevive/arcaeaoffline/jobs/R30UpdateJob.kt | 104 +++++++++--------- .../database/r30list/DatabaseR30ListScreen.kt | 18 +-- .../r30list/DatabaseR30ListViewModel.kt | 15 +-- .../repositories/R30EntryRepository.kt | 12 -- 4 files changed, 60 insertions(+), 89 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/R30UpdateJob.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/R30UpdateJob.kt index c49c320f..03e0acc5 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/R30UpdateJob.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/R30UpdateJob.kt @@ -5,17 +5,17 @@ import androidx.room.immediateTransaction import androidx.room.useWriterConnection import androidx.work.CoroutineWorker import androidx.work.WorkerParameters -import androidx.work.workDataOf import co.touchlab.kermit.Logger import io.sentry.Sentry -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import xyz.sevive.arcaeaoffline.core.Progress import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultClearType import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultModifier import xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase @@ -28,6 +28,7 @@ import xyz.sevive.arcaeaoffline.core.database.repositories.PropertyRepository import xyz.sevive.arcaeaoffline.core.database.repositories.R30EntryCombined import xyz.sevive.arcaeaoffline.core.database.repositories.R30EntryRepository import xyz.sevive.arcaeaoffline.core.database.repositories.SongRepository +import xyz.sevive.arcaeaoffline.helpers.toWorkData import kotlin.time.Clock private fun PlayResult.matchesR30Condition(): Boolean { @@ -59,9 +60,6 @@ class R30UpdateJob( const val WORK_NAME = "R30UpdateJob" const val DATA_RUN_MODE = "run_mode" - - const val KEY_PROGRESS = "progress" - const val KEY_PROGRESS_TOTAL = "progress_total" } enum class RunMode( @@ -85,62 +83,62 @@ class R30UpdateJob( } ?: RunMode.NORMAL, ) - private val progress = MutableStateFlow(0) - private val progressTotal = MutableStateFlow(-1) - private val progressListenScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private val progressFlow = MutableStateFlow(Progress.INDETERMINATE) override suspend fun doWork(): Result { - progressListenScope.launch { - combine(progress, progressTotal) { p, t -> p to t }.collectLatest { - setProgress( - workDataOf(KEY_PROGRESS to it.first, KEY_PROGRESS_TOTAL to it.second), - ) - } - } - val workOptions = getWorkOptions() try { - val r30LastUpdatedAt = propertyRepo.r30LastUpdatedAt() - - var r30EntryCombinedList = - when (workOptions.runMode) { - RunMode.REBUILD -> emptyList() - else -> r30EntryRepo.findAllCombined().firstOrNull() ?: emptyList() + return coroutineScope { + val progressPublishJob = + launch { + progressFlow.collectLatest { setProgress(it.toWorkData()) } + } + + val r30LastUpdatedAt = propertyRepo.r30LastUpdatedAt() + + var r30EntryCombinedList = + when (workOptions.runMode) { + RunMode.REBUILD -> emptyList() + else -> r30EntryRepo.findAllCombined().firstOrNull() ?: emptyList() + } + + val playResults = + when (workOptions.runMode) { + RunMode.REBUILD -> playResultRepo.findAll().firstOrNull() + else -> r30LastUpdatedAt?.let { playResultRepo.findLaterThan(it).firstOrNull() } + } ?: emptyList() + val deletedSongIds = + songRepo.findDeletedInGame().firstOrNull()?.map { it.id } ?: emptyList() + val newPlayResults = + playResults + .filter { it.date != null && it.songId !in deletedSongIds } + .sortedBy { it.date } + + progressFlow.update { Progress(current = 0, total = newPlayResults.size) } + logger.d { "Updating r30 list with ${newPlayResults.size} new play results" } + newPlayResults.forEach { + r30EntryCombinedList = updateR30List(it, r30EntryCombinedList) + progressFlow.update { progress -> progress.increment() } } - val playResults = - when (workOptions.runMode) { - RunMode.REBUILD -> playResultRepo.findAll().firstOrNull() - else -> r30LastUpdatedAt?.let { playResultRepo.findLaterThan(it).firstOrNull() } - } ?: emptyList() - val deletedSongIds = - songRepo.findDeletedInGame().firstOrNull()?.map { it.id } ?: emptyList() - val newPlayResults = - playResults - .filter { it.date != null && it.songId !in deletedSongIds } - .sortedBy { it.date } - - progressTotal.value = newPlayResults.size - logger.d { "Updating r30 list with ${newPlayResults.size} new play results" } - newPlayResults.forEach { - r30EntryCombinedList = updateR30List(it, r30EntryCombinedList) - progress.value += 1 - } - - // Room3 possibly has a convenient extension function for this - // see https://issuetracker.google.com/issues/416306996 - db.useWriterConnection { transactor -> - transactor.immediateTransaction { - r30EntryRepo.deleteAll() - r30EntryRepo.insertBatch(*r30EntryCombinedList.map { it.entry }.toTypedArray()) + // Room3 possibly has a convenient extension function for this + // see https://issuetracker.google.com/issues/416306996 + db.useWriterConnection { transactor -> + transactor.immediateTransaction { + r30EntryRepo.deleteAll() + r30EntryRepo.insertBatch(*r30EntryCombinedList.map { it.entry }.toTypedArray()) - propertyRepo.setR30LastUpdatedAt(Clock.System.now()) + propertyRepo.setR30LastUpdatedAt(Clock.System.now()) + } } - } - return Result.success() + progressPublishJob.cancelAndJoin() + Result.success() + } } catch (e: Throwable) { + if (e is CancellationException) throw e + logger.e(e) { "Error updating r30" } Sentry.captureException(e) return Result.failure() diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/r30list/DatabaseR30ListScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/r30list/DatabaseR30ListScreen.kt index b0d6ae18..73ad7c2d 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/r30list/DatabaseR30ListScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/r30list/DatabaseR30ListScreen.kt @@ -43,15 +43,6 @@ import xyz.sevive.arcaeaoffline.ui.components.dialogs.DialogDismissTextButton import xyz.sevive.arcaeaoffline.ui.navigation.DatabaseSubScreen import xyz.sevive.arcaeaoffline.ui.screens.EmptyScreen -@Composable -private fun DatabaseR30UpdateProgress( - current: Int, - total: Int, - modifier: Modifier = Modifier, -) { - LinearProgressIndicatorWrapper(current = current, total = total, modifier) -} - @Composable private fun DatabaseR30RebuildConfirmDialog( onDismiss: () -> Unit, @@ -83,7 +74,7 @@ private fun DatabaseR30RebuildConfirmDialog( @Composable internal fun DatabaseR30ListScreen(viewModel: DatabaseR30ListViewModel = koinViewModel()) { val updateProgress by viewModel.updateProgress.collectAsStateWithLifecycle() - val isUpdating by remember { derivedStateOf { updateProgress.second > -1 } } + val isUpdating by remember { derivedStateOf { updateProgress != null } } val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiItems = uiState.listItems @@ -136,10 +127,9 @@ internal fun DatabaseR30ListScreen(viewModel: DatabaseR30ListViewModel = koinVie ) { if (isUpdating) { Box(Modifier.fillMaxSize()) { - DatabaseR30UpdateProgress( - current = updateProgress.first, - total = updateProgress.second, - modifier = Modifier.align(Alignment.Center), + LinearProgressIndicatorWrapper( + progress = updateProgress, + Modifier.align(Alignment.Center), ) } } else if (uiState.isLoading) { diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/r30list/DatabaseR30ListViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/r30list/DatabaseR30ListViewModel.kt index 99d0acb3..4fbb6c20 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/r30list/DatabaseR30ListViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/r30list/DatabaseR30ListViewModel.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transform +import xyz.sevive.arcaeaoffline.core.Progress import xyz.sevive.arcaeaoffline.core.database.entities.Chart import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult import xyz.sevive.arcaeaoffline.core.database.entities.R30Entry @@ -22,6 +23,7 @@ import xyz.sevive.arcaeaoffline.core.database.repositories.DifficultyRepository import xyz.sevive.arcaeaoffline.core.database.repositories.PropertyRepository import xyz.sevive.arcaeaoffline.core.database.repositories.R30EntryRepository import xyz.sevive.arcaeaoffline.core.database.repositories.SongRepository +import xyz.sevive.arcaeaoffline.helpers.fromWorkInfo import xyz.sevive.arcaeaoffline.jobs.R30UpdateJob import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant @@ -96,18 +98,11 @@ class DatabaseR30ListViewModel( val updateProgress = workManager .getWorkInfosForUniqueWorkFlow(R30UpdateJob.WORK_NAME) - .mapLatest { workInfos -> - val workInfo = workInfos.getOrNull(0) ?: return@mapLatest 0 to -1 - - workInfo.progress.getInt(R30UpdateJob.KEY_PROGRESS, 0) to - workInfo.progress.getInt( - R30UpdateJob.KEY_PROGRESS_TOTAL, - -1, - ) - }.stateIn( + .mapLatest { workInfos -> Progress.fromWorkInfo(workInfos.getOrNull(0)) } + .stateIn( viewModelScope, SharingStarted.WhileSubscribed(5.seconds.inWholeMilliseconds), - 0 to -1, + null, ) private fun enqueueWork(runMode: R30UpdateJob.RunMode) { diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/R30EntryRepository.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/R30EntryRepository.kt index 90207669..d9a637cd 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/R30EntryRepository.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/R30EntryRepository.kt @@ -1,9 +1,6 @@ package xyz.sevive.arcaeaoffline.core.database.repositories import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.firstOrNull import xyz.sevive.arcaeaoffline.core.database.daos.R30EntryDao @@ -41,9 +38,6 @@ data class R30EntryCombined( } interface R30EntryRepository { - val updating: StateFlow - val updateProgress: StateFlow> - fun findAll(): Flow> fun findAllCombined(): Flow> @@ -62,12 +56,6 @@ class R30EntryRepositoryImpl( const val LOG_TAG = "R30EntryRepoImpl" } - private val _updating = MutableStateFlow(false) - override val updating = _updating.asStateFlow() - - private val _updateProgress = MutableStateFlow(0 to -1) - override val updateProgress: StateFlow> = _updateProgress.asStateFlow() - override fun findAll(): Flow> = dao.findAll() override suspend fun insertBatch(vararg items: R30Entry) = dao.insertBatch(*items) From bd1b96635c9c3048c3fd0dc0e4ddab3ceb74bbc3 Mon Sep 17 00:00:00 2001 From: 283375 Date: Tue, 30 Jun 2026 03:09:19 +0800 Subject: [PATCH 04/12] remove deprecated components --- .../LinearProgressIndicatorWrapper.kt | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/LinearProgressIndicatorWrapper.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/LinearProgressIndicatorWrapper.kt index 108e269a..39a1d6d9 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/LinearProgressIndicatorWrapper.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/LinearProgressIndicatorWrapper.kt @@ -99,22 +99,3 @@ fun LinearProgressIndicatorWrapper( formatter = formatter, ) } - -@Deprecated("Migrate to new Progress data class") -@Composable -fun LinearProgressIndicatorWrapper( - progress: Pair?, - modifier: Modifier = Modifier, - indeterminateLabel: String? = LinearProgressIndicatorWrapperDefaults.indeterminateLabel, - determinateLabel: String? = LinearProgressIndicatorWrapperDefaults.determinateLabel, - formatter: PercentageFormatter = LinearProgressIndicatorWrapperDefaults.formatter, -) { - LinearProgressIndicatorWrapper( - current = progress?.first ?: 0, - total = progress?.second ?: -1, - modifier = modifier, - indeterminateLabel = indeterminateLabel, - determinateLabel = determinateLabel, - formatter = formatter, - ) -} From 7a77e8d5fe9b7443a55cb699ea6a86c4f4112672 Mon Sep 17 00:00:00 2001 From: 283375 Date: Tue, 30 Jun 2026 03:16:49 +0800 Subject: [PATCH 05/12] return null when work data doesn't actually hold progress values --- .../helpers/ProgressWorkManager.kt | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ProgressWorkManager.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ProgressWorkManager.kt index 8e6d4e3e..a550527c 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ProgressWorkManager.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ProgressWorkManager.kt @@ -14,11 +14,18 @@ fun Progress.toWorkData(): Data = .putInt(KEY_TOTAL, total) .build() -fun Progress.Companion.fromWorkData(data: Data): Progress = - Progress( - current = data.getInt(KEY_CURRENT, 0), - total = data.getInt(KEY_TOTAL, -1), - ) +fun Progress.Companion.fromWorkData(data: Data): Progress? { + // -1 is used for indeterminate progress + // so we use -75 here to indicate no value fetched from work data + val current = data.getInt(KEY_CURRENT, -75) + val total = data.getInt(KEY_TOTAL, -75) + + return if (current == -75 && total == -75) { + null + } else { + Progress(current = current, total = total) + } +} fun Progress.Companion.fromWorkInfo(workInfo: WorkInfo?): Progress? = when (workInfo?.state) { From 07f1bb6f07386ecbf90880e9042313fc4107ff47 Mon Sep 17 00:00:00 2001 From: 283375 Date: Tue, 30 Jun 2026 03:20:30 +0800 Subject: [PATCH 06/12] ui improvements --- .../ui/screens/database/r30list/DatabaseR30ListScreen.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/r30list/DatabaseR30ListScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/r30list/DatabaseR30ListScreen.kt index 73ad7c2d..d0179510 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/r30list/DatabaseR30ListScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/r30list/DatabaseR30ListScreen.kt @@ -3,9 +3,11 @@ package xyz.sevive.arcaeaoffline.ui.screens.database.r30list import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons @@ -129,7 +131,7 @@ internal fun DatabaseR30ListScreen(viewModel: DatabaseR30ListViewModel = koinVie Box(Modifier.fillMaxSize()) { LinearProgressIndicatorWrapper( progress = updateProgress, - Modifier.align(Alignment.Center), + Modifier.align(Alignment.Center).width(IntrinsicSize.Min), ) } } else if (uiState.isLoading) { From 6c1aacf9b67b22a2bcabf6636ef7cfe75919acee Mon Sep 17 00:00:00 2001 From: 283375 Date: Tue, 30 Jun 2026 03:47:18 +0800 Subject: [PATCH 07/12] R30UpdateJob naming and general refactors --- .../sevive/arcaeaoffline/jobs/R30UpdateJob.kt | 44 ++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/R30UpdateJob.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/R30UpdateJob.kt index 03e0acc5..653331dd 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/R30UpdateJob.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/R30UpdateJob.kt @@ -10,6 +10,7 @@ import io.sentry.Sentry import kotlinx.coroutines.CancellationException import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.firstOrNull @@ -29,18 +30,19 @@ import xyz.sevive.arcaeaoffline.core.database.repositories.R30EntryCombined import xyz.sevive.arcaeaoffline.core.database.repositories.R30EntryRepository import xyz.sevive.arcaeaoffline.core.database.repositories.SongRepository import xyz.sevive.arcaeaoffline.helpers.toWorkData +import xyz.sevive.arcaeaoffline.jobs.R30UpdateJob.RunMode.NORMAL import kotlin.time.Clock -private fun PlayResult.matchesR30Condition(): Boolean { +private fun PlayResult.triggersConditionalWrite(): Boolean { // score >= EX - if (score >= 9800000) return true + if (score >= 9_800_000) return true // is hard lost if (clearType == ArcaeaPlayResultClearType.TRACK_LOST && modifier == ArcaeaPlayResultModifier.HARD) return true return false } -private fun List.minPlayRatingItem(): R30EntryCombined? = +private fun List.minByPlayRating(): R30EntryCombined? = this.minByOrNull { entry -> entry.chartInfo?.let { entry.playResult.playRating(it) } ?: Double.MAX_VALUE } @@ -62,25 +64,34 @@ class R30UpdateJob( const val DATA_RUN_MODE = "run_mode" } + private val logger = Logger.withTag(LOG_TAG) + enum class RunMode( val value: Int, ) { NORMAL(0), REBUILD(1), + ; + + companion object { + fun fromInt(value: Int) = entries.firstOrNull { it.value == value } + } } private data class WorkOptions( val runMode: RunMode, ) - private val logger = Logger.withTag(LOG_TAG) + private fun parseRunMode(): RunMode { + val runModeInput = inputData.getInt(DATA_RUN_MODE, -1) + val result = RunMode.fromInt(runModeInput) + if (result == null) logger.w { "Invalid RunMode $runModeInput, falling back to $NORMAL" } + return result ?: NORMAL + } private fun getWorkOptions(): WorkOptions = WorkOptions( - runMode = - RunMode.entries.firstOrNull { - it.value == inputData.getInt(DATA_RUN_MODE, 0) - } ?: RunMode.NORMAL, + runMode = parseRunMode(), ) private val progressFlow = MutableStateFlow(Progress.INDETERMINATE) @@ -118,6 +129,7 @@ class R30UpdateJob( progressFlow.update { Progress(current = 0, total = newPlayResults.size) } logger.d { "Updating r30 list with ${newPlayResults.size} new play results" } newPlayResults.forEach { + ensureActive() r30EntryCombinedList = updateR30List(it, r30EntryCombinedList) progressFlow.update { progress -> progress.increment() } } @@ -136,7 +148,7 @@ class R30UpdateJob( progressPublishJob.cancelAndJoin() Result.success() } - } catch (e: Throwable) { + } catch (e: Exception) { if (e is CancellationException) throw e logger.e(e) { "Error updating r30" } @@ -146,7 +158,7 @@ class R30UpdateJob( } /** - * Wrapper of [updateR30ListByDate] and [updateR30ListByPotential] that automatically + * Wrapper of [updateR30ListByDirectWrite] and [updateR30ListByConditionalWrite] that automatically * choose one of them depending on the [playResult]'s state. */ private suspend fun updateR30List( @@ -160,14 +172,14 @@ class R30UpdateJob( } val newR30Entries = - if (playResult.matchesR30Condition()) { + if (playResult.triggersConditionalWrite()) { // now check if the play result play rating is higher than the lowest play rating r30 entry // if any chart info is missing, return the old r30 entries directly val chartInfo = chartInfoRepo.find(playResult).firstOrNull() ?: return oldR30List - updateR30ListByPotential(playResult, chartInfo, oldR30List) + updateR30ListByConditionalWrite(playResult, chartInfo, oldR30List) } else { // otherwise, just update the entries by date - updateR30ListByDate(playResult, oldR30List) + updateR30ListByDirectWrite(playResult, oldR30List) } // ensure the new r30 should have at least 10 unique charts @@ -193,14 +205,14 @@ class R30UpdateJob( * @param oldR30List Old R30 entries * @return The new R30 entries */ - private fun updateR30ListByPotential( + private fun updateR30ListByConditionalWrite( playResult: PlayResult, chartInfo: ChartInfo, oldR30List: List, ): List { // try getting the min play rating item in old list // otherwise leave the old list untouched - val minRatingEntry = oldR30List.minPlayRatingItem() ?: return oldR30List + val minRatingEntry = oldR30List.minByPlayRating() ?: return oldR30List val minRatingEntryRating = minRatingEntry.playRating() ?: return oldR30List if (playResult.playRating(chartInfo) < minRatingEntryRating) return oldR30List @@ -220,7 +232,7 @@ class R30UpdateJob( * @param oldR30List Old R30 entries * @return The new R30 entries */ - private suspend fun updateR30ListByDate( + private suspend fun updateR30ListByDirectWrite( playResult: PlayResult, oldR30List: List, ): List { From 34663022b93516f0aa3caeb27520c2f5a4e94f3c Mon Sep 17 00:00:00 2001 From: 283375 Date: Tue, 30 Jun 2026 03:50:48 +0800 Subject: [PATCH 08/12] RunMode parsing --- .../jobs/OcrQueueProcessingJob.kt | 11 ++++++++-- .../sevive/arcaeaoffline/jobs/R30UpdateJob.kt | 21 ++++++------------- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueProcessingJob.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueProcessingJob.kt index 214a52a3..6860ea42 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueProcessingJob.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueProcessingJob.kt @@ -66,7 +66,7 @@ class OcrQueueProcessingJob( ; companion object { - fun fromInt(value: Int) = entries.firstOrNull { it.value == value } ?: NORMAL + fun fromInt(value: Int) = entries.firstOrNull { it.value == value } } } @@ -75,9 +75,16 @@ class OcrQueueProcessingJob( val parallelCount: Int, ) + private fun parseRunMode(): RunMode { + val runModeInput = inputData.getInt(DATA_RUN_MODE, -1) + val result = RunMode.fromInt(runModeInput) + if (result == null) logger.w { "Invalid RunMode $runModeInput, falling back to ${RunMode.NORMAL}" } + return result ?: RunMode.NORMAL + } + private fun getWorkOptions(): WorkOptions = WorkOptions( - runMode = RunMode.fromInt(inputData.getInt(DATA_RUN_MODE, RunMode.NORMAL.value)), + runMode = parseRunMode(), parallelCount = inputData .getInt( diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/R30UpdateJob.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/R30UpdateJob.kt index 653331dd..857b84be 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/R30UpdateJob.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/R30UpdateJob.kt @@ -30,7 +30,6 @@ import xyz.sevive.arcaeaoffline.core.database.repositories.R30EntryCombined import xyz.sevive.arcaeaoffline.core.database.repositories.R30EntryRepository import xyz.sevive.arcaeaoffline.core.database.repositories.SongRepository import xyz.sevive.arcaeaoffline.helpers.toWorkData -import xyz.sevive.arcaeaoffline.jobs.R30UpdateJob.RunMode.NORMAL import kotlin.time.Clock private fun PlayResult.triggersConditionalWrite(): Boolean { @@ -70,8 +69,7 @@ class R30UpdateJob( val value: Int, ) { NORMAL(0), - REBUILD(1), - ; + REBUILD(1), ; companion object { fun fromInt(value: Int) = entries.firstOrNull { it.value == value } @@ -85,8 +83,8 @@ class R30UpdateJob( private fun parseRunMode(): RunMode { val runModeInput = inputData.getInt(DATA_RUN_MODE, -1) val result = RunMode.fromInt(runModeInput) - if (result == null) logger.w { "Invalid RunMode $runModeInput, falling back to $NORMAL" } - return result ?: NORMAL + if (result == null) logger.w { "Invalid RunMode $runModeInput, falling back to ${RunMode.NORMAL}" } + return result ?: RunMode.NORMAL } private fun getWorkOptions(): WorkOptions = @@ -119,12 +117,8 @@ class R30UpdateJob( RunMode.REBUILD -> playResultRepo.findAll().firstOrNull() else -> r30LastUpdatedAt?.let { playResultRepo.findLaterThan(it).firstOrNull() } } ?: emptyList() - val deletedSongIds = - songRepo.findDeletedInGame().firstOrNull()?.map { it.id } ?: emptyList() - val newPlayResults = - playResults - .filter { it.date != null && it.songId !in deletedSongIds } - .sortedBy { it.date } + val deletedSongIds = songRepo.findDeletedInGame().firstOrNull()?.map { it.id } ?: emptyList() + val newPlayResults = playResults.filter { it.date != null && it.songId !in deletedSongIds }.sortedBy { it.date } progressFlow.update { Progress(current = 0, total = newPlayResults.size) } logger.d { "Updating r30 list with ${newPlayResults.size} new play results" } @@ -184,10 +178,7 @@ class R30UpdateJob( // ensure the new r30 should have at least 10 unique charts // otherwise keep the entries unmodified - val uniqueChartsCount = - newR30Entries - .distinctBy { "${it.playResult.songId}|${it.playResult.ratingClass.value}" } - .count() + val uniqueChartsCount = newR30Entries.distinctBy { "${it.playResult.songId}|${it.playResult.ratingClass.value}" }.count() return if (uniqueChartsCount < 10) { oldR30List } else { From 97e7daeed7be70c3c2465f8a4b4d0e6e0cbadd2a Mon Sep 17 00:00:00 2001 From: 283375 Date: Tue, 30 Jun 2026 03:59:44 +0800 Subject: [PATCH 09/12] ImageHashesDatabaseBuilderJob progress refactor --- .../jobs/ImageHashesDatabaseBuilderJob.kt | 69 ++++++++++++------- 1 file changed, 44 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/ImageHashesDatabaseBuilderJob.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/ImageHashesDatabaseBuilderJob.kt index 4e825e6c..d1256fe1 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/ImageHashesDatabaseBuilderJob.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/ImageHashesDatabaseBuilderJob.kt @@ -6,18 +6,23 @@ import androidx.sqlite.driver.bundled.SQLITE_OPEN_CREATE import androidx.sqlite.driver.bundled.SQLITE_OPEN_READWRITE import androidx.work.CoroutineWorker import androidx.work.WorkerParameters -import androidx.work.workDataOf import co.touchlab.kermit.Logger -import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.sample import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.io.files.SystemFileSystem +import xyz.sevive.arcaeaoffline.core.Progress import xyz.sevive.arcaeaoffline.core.ocr.ImageHashesDatabaseBuilder import xyz.sevive.arcaeaoffline.data.OcrDependencyPaths import xyz.sevive.arcaeaoffline.helpers.ArcaeaPackageHelper import xyz.sevive.arcaeaoffline.helpers.toWorkData +import kotlin.time.Duration.Companion.milliseconds class ImageHashesDatabaseBuilderJob( context: Context, @@ -30,39 +35,53 @@ class ImageHashesDatabaseBuilderJob( private const val LOG_TAG = "ImageHashesDbBuilderJob" } + private val progressFlow = MutableStateFlow(Progress.INDETERMINATE) + override suspend fun doWork(): Result { val ocrDependencyPaths = OcrDependencyPaths() val path = ocrDependencyPaths.imageHashesDatabaseFile val arcaeaPackageHelper = ArcaeaPackageHelper(applicationContext) - val collectScope = CoroutineScope(Dispatchers.Default) - try { - withContext(Dispatchers.IO) { - if (SystemFileSystem.exists(path)) SystemFileSystem.delete(path) - SystemFileSystem.createDirectories(path.parent ?: error("$path has no parent")) - } - - BundledSQLiteDriver() - .open( - path.toString(), - SQLITE_OPEN_READWRITE.or(SQLITE_OPEN_CREATE), - ).use { conn -> - val builder = ImageHashesDatabaseBuilder(conn) - - collectScope.launch { - builder.buildProgress.collectLatest { progress -> - progress?.let { setProgress(it.toWorkData()) } - } + return coroutineScope { + @OptIn(FlowPreview::class) + val progressPublishJob = + launch { + progressFlow + .sample(100.milliseconds) + .collect { setProgress(it.toWorkData()) } } - arcaeaPackageHelper.fillHashesDatabaseBuilderTasks(builder) - builder.build(hashSize = 16, highFreqFactor = 4) - arcaeaPackageHelper.buildHashesDatabaseCleanUp() + withContext(Dispatchers.IO) { + if (SystemFileSystem.exists(path)) SystemFileSystem.delete(path) + SystemFileSystem.createDirectories(path.parent ?: error("$path has no parent")) } - return Result.success() + BundledSQLiteDriver() + .open( + path.toString(), + SQLITE_OPEN_READWRITE.or(SQLITE_OPEN_CREATE), + ).use { conn -> + val builder = ImageHashesDatabaseBuilder(conn) + + val progressUpdateJob = + launch { + builder.buildProgress.collect { progressFlow.value = it ?: Progress.INDETERMINATE } + } + + arcaeaPackageHelper.fillHashesDatabaseBuilderTasks(builder) + builder.build(hashSize = 16, highFreqFactor = 4) + arcaeaPackageHelper.buildHashesDatabaseCleanUp() + + progressUpdateJob.cancelAndJoin() + } + + progressPublishJob.cancelAndJoin() + Result.success() + } } catch (e: Exception) { + if (e is CancellationException) throw e + logger.e(e) { "Error building image hashes database" } return Result.failure() } From 0c8e14df33319919af35f4168b3ce9df91429f4e Mon Sep 17 00:00:00 2001 From: 283375 Date: Tue, 30 Jun 2026 04:10:51 +0800 Subject: [PATCH 10/12] fix incorrect default value --- app/src/main/java/xyz/sevive/arcaeaoffline/jobs/R30UpdateJob.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/R30UpdateJob.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/R30UpdateJob.kt index 857b84be..e633413a 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/R30UpdateJob.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/R30UpdateJob.kt @@ -81,7 +81,7 @@ class R30UpdateJob( ) private fun parseRunMode(): RunMode { - val runModeInput = inputData.getInt(DATA_RUN_MODE, -1) + val runModeInput = inputData.getInt(DATA_RUN_MODE, 0) val result = RunMode.fromInt(runModeInput) if (result == null) logger.w { "Invalid RunMode $runModeInput, falling back to ${RunMode.NORMAL}" } return result ?: RunMode.NORMAL From eb3c64f8d04f17149f8f0f5441e2b77693e9fda9 Mon Sep 17 00:00:00 2001 From: 283375 Date: Tue, 30 Jun 2026 04:14:25 +0800 Subject: [PATCH 11/12] `Progress.fromWorkData` null value handling --- .../helpers/ProgressWorkManager.kt | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ProgressWorkManager.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ProgressWorkManager.kt index a550527c..48399d93 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ProgressWorkManager.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ProgressWorkManager.kt @@ -15,16 +15,16 @@ fun Progress.toWorkData(): Data = .build() fun Progress.Companion.fromWorkData(data: Data): Progress? { - // -1 is used for indeterminate progress - // so we use -75 here to indicate no value fetched from work data - val current = data.getInt(KEY_CURRENT, -75) - val total = data.getInt(KEY_TOTAL, -75) - - return if (current == -75 && total == -75) { - null - } else { - Progress(current = current, total = total) + with(data.keyValueMap) { + if (isEmpty()) return null + if (!containsKey(KEY_CURRENT)) return null + if (!containsKey(KEY_TOTAL)) return null } + + return Progress( + current = data.getInt(KEY_CURRENT, Progress.INDETERMINATE.current), + total = data.getInt(KEY_TOTAL, Progress.INDETERMINATE.total), + ) } fun Progress.Companion.fromWorkInfo(workInfo: WorkInfo?): Progress? = From fc5fd7c26654bb9d87a9b114d5f1621cb47087a7 Mon Sep 17 00:00:00 2001 From: 283375 Date: Tue, 30 Jun 2026 04:18:03 +0800 Subject: [PATCH 12/12] fix incorrect default value --- .../java/xyz/sevive/arcaeaoffline/jobs/OcrQueueProcessingJob.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueProcessingJob.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueProcessingJob.kt index 6860ea42..042a6de3 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueProcessingJob.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueProcessingJob.kt @@ -76,7 +76,7 @@ class OcrQueueProcessingJob( ) private fun parseRunMode(): RunMode { - val runModeInput = inputData.getInt(DATA_RUN_MODE, -1) + val runModeInput = inputData.getInt(DATA_RUN_MODE, 0) val result = RunMode.fromInt(runModeInput) if (result == null) logger.w { "Invalid RunMode $runModeInput, falling back to ${RunMode.NORMAL}" } return result ?: RunMode.NORMAL