From 20119e725d0d3b3b609ffcbae3f0bd6a5a8d5877 Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 24 Jun 2026 20:53:52 +0800 Subject: [PATCH 01/17] replace `java.util.UUID` with `kotlin.uuid.Uuid` --- .../database/entities/OcrHistory.kt | 4 +-- .../database/b30list/DatabaseB30ListItem.kt | 4 +-- .../DatabaseDeduplicatorGroupListItem.kt | 6 ++--- .../DatabaseDeduplicatorViewModel.kt | 20 +++++--------- .../manage/DatabaseManageViewModel.kt | 4 +-- .../ui/screens/database/manage/ImportLog.kt | 4 +-- .../ui/screens/database/manage/ImportLogUi.kt | 4 +-- .../DatabasePlayResultDeleteConfirmDialog.kt | 4 +-- .../DatabasePlayResultListViewModel.kt | 6 ++--- build.gradle.kts | 6 +++++ .../core/database/ArcaeaOfflineDatabase.kt | 4 +-- .../converters/UUIDByteArrayConverters.kt | 26 ------------------- .../converters/UuidByteArrayConverters.kt | 12 +++++++++ .../core/database/daos/PlayResultDao.kt | 6 ++--- .../core/database/entities/PlayResult.kt | 4 +-- .../core/database/entities/R30Entry.kt | 4 +-- .../core/database/entities/Relationships.kt | 4 +-- .../extensions/PlayResultSerializer.kt | 4 +-- .../ArcaeaOfflineDEFv2Definitions.kt | 25 ++---------------- .../core/database/migrations/Migration_6_7.kt | 15 +++-------- .../repositories/PlayResultBestRepository.kt | 2 +- .../PlayResultCalculatedRepository.kt | 12 ++++----- .../repositories/PlayResultRepository.kt | 10 +++---- .../core/ocr/device/DeviceOcr.kt | 4 +-- 24 files changed, 76 insertions(+), 118 deletions(-) delete mode 100644 core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/converters/UUIDByteArrayConverters.kt create mode 100644 core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/converters/UuidByteArrayConverters.kt diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/database/entities/OcrHistory.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/database/entities/OcrHistory.kt index 2dbf64ad..f7f4f1d4 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/database/entities/OcrHistory.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/database/entities/OcrHistory.kt @@ -8,8 +8,8 @@ import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultModifier import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult import xyz.sevive.arcaeaoffline.helpers.formatAsLocalizedDateTime -import java.util.UUID import kotlin.time.Instant +import kotlin.uuid.Uuid @Entity(tableName = "ocr_history") data class OcrHistory( @@ -32,7 +32,7 @@ data class OcrHistory( return PlayResult( id = 0, - uuid = UUID.randomUUID(), + uuid = Uuid.generateV4(), songId = songId, ratingClass = ratingClass, score = score, diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/b30list/DatabaseB30ListItem.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/b30list/DatabaseB30ListItem.kt index d8868f9f..120270a4 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/b30list/DatabaseB30ListItem.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/b30list/DatabaseB30ListItem.kt @@ -29,8 +29,8 @@ import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult import xyz.sevive.arcaeaoffline.core.database.entities.PlayResultCalculated import xyz.sevive.arcaeaoffline.ui.components.ArcaeaPlayResultCard import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme -import java.util.UUID import kotlin.time.Instant +import kotlin.uuid.Uuid @Composable internal fun DatabaseB30ListItem( @@ -99,7 +99,7 @@ private fun DatabaseB30ListItemPreview() { playResult = PlayResult( id = 1, - uuid = UUID.randomUUID(), + uuid = Uuid.generateV4(), songId = "test", ratingClass = ArcaeaRatingClass.FUTURE, score = 9950000, diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/deduplicator/DatabaseDeduplicatorGroupListItem.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/deduplicator/DatabaseDeduplicatorGroupListItem.kt index 6dfd0f86..6ddb2536 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/deduplicator/DatabaseDeduplicatorGroupListItem.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/deduplicator/DatabaseDeduplicatorGroupListItem.kt @@ -31,13 +31,13 @@ import xyz.sevive.arcaeaoffline.ui.components.ArcaeaChartCard import xyz.sevive.arcaeaoffline.ui.components.ArcaeaPlayResultCard import xyz.sevive.arcaeaoffline.ui.components.ArcaeaPlayResultEditorDialog import xyz.sevive.arcaeaoffline.ui.components.ListGroupHeader -import java.util.UUID +import kotlin.uuid.Uuid @Composable internal fun DatabaseDeduplicatorGroupListItem( item: DatabaseDeduplicatorViewModel.GroupListUiItem, - selectedUuids: Set, - onPlayResultSelectedChange: (UUID, Boolean) -> Unit, + selectedUuids: Set, + onPlayResultSelectedChange: (Uuid, Boolean) -> Unit, onMergeConfirm: (PlayResult) -> Unit, ) { var mergedPlayResult by remember { mutableStateOf(null) } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/deduplicator/DatabaseDeduplicatorViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/deduplicator/DatabaseDeduplicatorViewModel.kt index ed673953..c56f63fd 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/deduplicator/DatabaseDeduplicatorViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/deduplicator/DatabaseDeduplicatorViewModel.kt @@ -22,8 +22,8 @@ import xyz.sevive.arcaeaoffline.core.database.repositories.DifficultyRepository import xyz.sevive.arcaeaoffline.core.database.repositories.PlayResultRepository import xyz.sevive.arcaeaoffline.core.database.repositories.SongRepository import xyz.sevive.arcaeaoffline.ui.helpers.UiDisplayChartCacheHolder -import java.util.UUID import kotlin.time.Duration.Companion.seconds +import kotlin.uuid.Uuid class DatabaseDeduplicatorViewModel( private val playResultRepo: PlayResultRepository, @@ -69,13 +69,9 @@ class DatabaseDeduplicatorViewModel( private val groups = MutableStateFlow(mapOf>()) private suspend fun buildDuplicateGroupsTask(values: Set): Map> { - val playResults = - playResultRepo.findAll().firstOrNull() ?: return emptyMap() + val playResults = playResultRepo.findAll().firstOrNull() ?: return emptyMap() - return playResults - .sortedBy { it.id } - .groupBy { it.groupByKey(values) } - .filter { it.value.size >= 2 } + return playResults.sortedBy { it.id }.groupBy { it.groupByKey(values) }.filter { it.value.size >= 2 } } private var buildDuplicateGroupsJob: Job? = null @@ -92,14 +88,14 @@ class DatabaseDeduplicatorViewModel( // #endregion // #region Selections - private val _selectedUuids = MutableStateFlow(setOf()) + private val _selectedUuids = MutableStateFlow(setOf()) val selectedUuids = _selectedUuids.asStateFlow() private val autoSelectSemaphore = Semaphore(1) private val autoSelectRunning = MutableStateFlow(false) fun setPlayResultSelected( - uuid: UUID, + uuid: Uuid, selected: Boolean, ) { if (selected) _selectedUuids.value += uuid else _selectedUuids.value -= uuid @@ -136,11 +132,9 @@ class DatabaseDeduplicatorViewModel( } } - fun deleteSelectedItemsInDatabase(uuids: Set) { + fun deleteSelectedItemsInDatabase(uuids: Set) { viewModelScope.launch(Dispatchers.IO) { - val playResults = - playResultRepo.findAllByUUID(uuids.toList()).firstOrNull() - ?: emptyList() + val playResults = playResultRepo.findAllByUuid(uuids.toList()).firstOrNull() ?: emptyList() playResultRepo.deleteBatch(*playResults.toTypedArray()) clearSelectedItems() diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageViewModel.kt index 9815fe03..66d54efe 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageViewModel.kt @@ -46,9 +46,9 @@ import java.io.InputStream import java.io.OutputStream import java.nio.charset.Charset import java.nio.charset.StandardCharsets -import java.util.UUID import java.util.zip.ZipInputStream import kotlin.time.Duration.Companion.seconds +import kotlin.uuid.Uuid class DatabaseManageViewModel( private val packRepo: PackRepository, @@ -80,7 +80,7 @@ class DatabaseManageViewModel( ) data class Task( - val uuid: UUID = UUID.randomUUID(), + val uuid: Uuid = Uuid.generateV4(), val action: suspend CoroutineScope.() -> Unit, ) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/ImportLog.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/ImportLog.kt index 530af4c7..85fe1d6d 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/ImportLog.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/ImportLog.kt @@ -6,9 +6,9 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import java.util.UUID import kotlin.time.Clock import kotlin.time.Instant +import kotlin.uuid.Uuid internal sealed interface ImportLogEvent { data class Plural( @@ -26,7 +26,7 @@ internal sealed interface ImportLogEvent { } internal data class ImportLogObject( - val uuid: UUID = UUID.randomUUID(), + val uuid: Uuid = Uuid.generateV4(), val timestamp: Instant, val tag: String? = null, val event: ImportLogEvent, diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/ImportLogUi.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/ImportLogUi.kt index 488141a9..417ce911 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/ImportLogUi.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/ImportLogUi.kt @@ -50,8 +50,8 @@ import xyz.sevive.arcaeaoffline.helpers.formatAsLocalizedTime import xyz.sevive.arcaeaoffline.helpers.secondaryItemAlpha import xyz.sevive.arcaeaoffline.ui.screens.EmptyScreen import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme -import java.util.UUID import kotlin.time.Instant +import kotlin.uuid.Uuid @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @Composable @@ -168,7 +168,7 @@ private fun ImportLogObjectUiPreview() { val log = remember { ImportLogObject( - uuid = UUID.randomUUID(), + uuid = Uuid.generateV4(), timestamp = Instant.fromEpochMilliseconds(0), tag = "P-VIEW", event = ImportLogEvent.Raw("Wow this is a log wow"), diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playresultlist/DatabasePlayResultDeleteConfirmDialog.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playresultlist/DatabasePlayResultDeleteConfirmDialog.kt index c0861ede..62f7d0c2 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playresultlist/DatabasePlayResultDeleteConfirmDialog.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playresultlist/DatabasePlayResultDeleteConfirmDialog.kt @@ -16,13 +16,13 @@ import xyz.sevive.arcaeaoffline.R import xyz.sevive.arcaeaoffline.ui.components.dialogs.DialogConfirmButton import xyz.sevive.arcaeaoffline.ui.components.dialogs.DialogConfirmButtonDefaults import xyz.sevive.arcaeaoffline.ui.components.dialogs.DialogDismissTextButton -import java.util.UUID +import kotlin.uuid.Uuid @Composable internal fun DatabasePlayResultDeleteConfirmDialog( onDismissRequest: () -> Unit, onConfirm: () -> Unit, - selectedItemUuids: List, + selectedItemUuids: List, modifier: Modifier = Modifier, ) { val selectedItemsCount = selectedItemUuids.size diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playresultlist/DatabasePlayResultListViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playresultlist/DatabasePlayResultListViewModel.kt index 0700599a..fd394353 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playresultlist/DatabasePlayResultListViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playresultlist/DatabasePlayResultListViewModel.kt @@ -28,8 +28,8 @@ import xyz.sevive.arcaeaoffline.core.database.repositories.SongRepository import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidator import xyz.sevive.arcaeaoffline.ui.helpers.ArcaeaFormatters import xyz.sevive.arcaeaoffline.ui.helpers.UiDisplayChartCacheHolder -import java.util.UUID import kotlin.time.Duration.Companion.seconds +import kotlin.uuid.Uuid class DatabasePlayResultListViewModel( private val playResultRepo: PlayResultRepository, @@ -81,7 +81,7 @@ class DatabasePlayResultListViewModel( private val isLoading = MutableStateFlow(false) private val sortOrder = MutableStateFlow(SortOrder.ASC) private val sortByValue = MutableStateFlow(SortByValue.ID) - val selectedItemUuids = MutableStateFlow(emptyList()) + val selectedItemUuids = MutableStateFlow(emptyList()) @OptIn(ExperimentalCoroutinesApi::class) val rawListItems = @@ -175,7 +175,7 @@ class DatabasePlayResultListViewModel( viewModelScope.launch(Dispatchers.IO) { val playResults = playResultRepo - .findAllByUUID(selectedItemUuids.value) + .findAllByUuid(selectedItemUuids.value) .firstOrNull() ?: emptyList() playResultRepo.deleteBatch(*playResults.toTypedArray()) diff --git a/build.gradle.kts b/build.gradle.kts index be9ab52b..a0944c1e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -24,4 +24,10 @@ plugins { subprojects { apply(plugin = "org.jmailen.kotlinter") + + tasks.withType>().configureEach { + compilerOptions { + optIn.add("kotlin.uuid.ExperimentalUuidApi") + } + } } diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/ArcaeaOfflineDatabase.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/ArcaeaOfflineDatabase.kt index 373fe7ae..56070683 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/ArcaeaOfflineDatabase.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/ArcaeaOfflineDatabase.kt @@ -13,7 +13,7 @@ import xyz.sevive.arcaeaoffline.core.database.converters.ArcaeaPlayResultClearTy import xyz.sevive.arcaeaoffline.core.database.converters.ArcaeaPlayResultModifierConverters import xyz.sevive.arcaeaoffline.core.database.converters.ArcaeaRatingClassConverters import xyz.sevive.arcaeaoffline.core.database.converters.InstantConverters -import xyz.sevive.arcaeaoffline.core.database.converters.UUIDByteArrayConverters +import xyz.sevive.arcaeaoffline.core.database.converters.UuidByteArrayConverters import xyz.sevive.arcaeaoffline.core.database.daos.ChartDao import xyz.sevive.arcaeaoffline.core.database.daos.ChartInfoDao import xyz.sevive.arcaeaoffline.core.database.daos.DifficultyDao @@ -72,7 +72,7 @@ import xyz.sevive.arcaeaoffline.core.database.migrations.Migration_7_8 ) @TypeConverters( InstantConverters::class, - UUIDByteArrayConverters::class, + UuidByteArrayConverters::class, ArcaeaRatingClassConverters::class, ArcaeaPlayResultClearTypeConverters::class, ArcaeaPlayResultModifierConverters::class, diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/converters/UUIDByteArrayConverters.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/converters/UUIDByteArrayConverters.kt deleted file mode 100644 index 226234bd..00000000 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/converters/UUIDByteArrayConverters.kt +++ /dev/null @@ -1,26 +0,0 @@ -package xyz.sevive.arcaeaoffline.core.database.converters - -import androidx.room.TypeConverter -import java.nio.ByteBuffer -import java.util.UUID - -/** - * @see Convert Between Byte Array and UUID in Java - */ -object UUIDByteArrayConverters { - @TypeConverter - fun fromDatabaseValue(value: ByteArray?): UUID? = - value?.let { - val byteBuffer = ByteBuffer.wrap(it) - UUID(byteBuffer.getLong(), byteBuffer.getLong()) - } - - @TypeConverter - fun toDatabaseValue(uuid: UUID?): ByteArray? = - uuid?.let { - val bb = ByteBuffer.wrap(ByteArray(16)) - bb.putLong(it.mostSignificantBits) - bb.putLong(it.leastSignificantBits) - bb.array() - } -} diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/converters/UuidByteArrayConverters.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/converters/UuidByteArrayConverters.kt new file mode 100644 index 00000000..bc3c0009 --- /dev/null +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/converters/UuidByteArrayConverters.kt @@ -0,0 +1,12 @@ +package xyz.sevive.arcaeaoffline.core.database.converters + +import androidx.room.TypeConverter +import kotlin.uuid.Uuid + +object UuidByteArrayConverters { + @TypeConverter + fun fromDatabaseValue(value: ByteArray?): Uuid? = value?.let { Uuid.fromByteArray(it) } + + @TypeConverter + fun toDatabaseValue(uuid: Uuid?): ByteArray? = uuid?.toByteArray() +} diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/daos/PlayResultDao.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/daos/PlayResultDao.kt index 6826dc76..4fb17cb5 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/daos/PlayResultDao.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/daos/PlayResultDao.kt @@ -7,8 +7,8 @@ import androidx.room.Upsert import kotlinx.coroutines.flow.Flow import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult -import java.util.UUID import kotlin.time.Instant +import kotlin.uuid.Uuid @Dao interface PlayResultDao { @@ -19,7 +19,7 @@ interface PlayResultDao { ): Flow @Query("SELECT * FROM play_results WHERE uuid = :uuid") - fun findByUUID(uuid: UUID): Flow + fun findByUuid(uuid: Uuid): Flow @Query("SELECT * FROM play_results WHERE date > :date") fun findLaterThan(date: Instant): Flow> @@ -37,7 +37,7 @@ interface PlayResultDao { ): Flow> @Query("SELECT * FROM play_results WHERE uuid IN (:uuids)") - fun findAllByUUID(uuids: List): Flow> + fun findAllByUuid(uuids: List): Flow> @Query("SELECT COUNT(*) FROM play_results") fun count(): Flow diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/PlayResult.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/PlayResult.kt index bad58a88..7967b5ed 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/PlayResult.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/PlayResult.kt @@ -10,8 +10,8 @@ import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultClearType import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultModifier import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass import xyz.sevive.arcaeaoffline.core.database.extensions.PlayResultSerializer -import java.util.UUID import kotlin.time.Instant +import kotlin.uuid.Uuid @Serializable(with = PlayResultSerializer::class) @Entity( @@ -23,7 +23,7 @@ import kotlin.time.Instant ) data class PlayResult( @PrimaryKey(autoGenerate = true) val id: Long = 0, - val uuid: UUID = UUID.randomUUID(), + val uuid: Uuid = Uuid.generateV4(), @ColumnInfo(name = "song_id") val songId: String, @ColumnInfo(name = "rating_class") val ratingClass: ArcaeaRatingClass, val score: Int, diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/R30Entry.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/R30Entry.kt index b96820da..612b164d 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/R30Entry.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/R30Entry.kt @@ -4,7 +4,7 @@ import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey -import java.util.UUID +import kotlin.uuid.Uuid @Entity( tableName = "r30_entries", @@ -22,7 +22,7 @@ import java.util.UUID ) data class R30Entry( @PrimaryKey(autoGenerate = true) val id: Long = 0, - val uuid: UUID, + val uuid: Uuid, ) { companion object } diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/Relationships.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/Relationships.kt index d5bc6372..71ac1df8 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/Relationships.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/Relationships.kt @@ -2,7 +2,7 @@ package xyz.sevive.arcaeaoffline.core.database.entities import androidx.room.ColumnInfo import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass -import java.util.UUID +import kotlin.uuid.Uuid data class PlayResultWithChart( val playResult: PlayResult, @@ -15,7 +15,7 @@ data class PlayResultBestWithChart( ) data class MinimumPlayResultPotentialFields( - val uuid: UUID, + val uuid: Uuid, @ColumnInfo(name = "song_id") val songId: String, @ColumnInfo(name = "rating_class") val ratingClass: ArcaeaRatingClass, val score: Int, diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/extensions/PlayResultSerializer.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/extensions/PlayResultSerializer.kt index 2cbc67ea..1b2cd2f0 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/extensions/PlayResultSerializer.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/extensions/PlayResultSerializer.kt @@ -13,8 +13,8 @@ import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultClearType import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultModifier import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult -import java.util.UUID import kotlin.time.Instant +import kotlin.uuid.Uuid object PlayResultSerializer : KSerializer { override val descriptor: SerialDescriptor = @@ -64,7 +64,7 @@ object PlayResultSerializer : KSerializer { } 1 -> { - cls = cls.copy(uuid = UUID.fromString(decodeStringElement(descriptor, 1))) + cls = cls.copy(uuid = Uuid.parse(decodeStringElement(descriptor, 1))) } 2 -> { diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/externals/ArcaeaOfflineDEFv2Definitions.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/externals/ArcaeaOfflineDEFv2Definitions.kt index 967fd9ef..4d5d2c70 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/externals/ArcaeaOfflineDEFv2Definitions.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/externals/ArcaeaOfflineDEFv2Definitions.kt @@ -2,35 +2,14 @@ package xyz.sevive.arcaeaoffline.core.database.externals import kotlinx.serialization.EncodeDefault import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -import java.util.UUID - -/** - * @see SO answer, CC BY-SA 4.0 - */ -object UUIDSerializer : KSerializer { - override val descriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING) - - override fun deserialize(decoder: Decoder): UUID = UUID.fromString(decoder.decodeString()) - - override fun serialize( - encoder: Encoder, - value: UUID, - ) { - encoder.encodeString(value.toString()) - } -} +import kotlin.uuid.Uuid @Serializable data class ArcaeaOfflineDEFv2PlayResultItem( val id: Long, - @Serializable(UUIDSerializer::class) val uuid: UUID, + val uuid: Uuid, val songId: String, val ratingClass: Int, val score: Int, diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/migrations/Migration_6_7.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/migrations/Migration_6_7.kt index c8a6c5eb..4cf007a8 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/migrations/Migration_6_7.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/migrations/Migration_6_7.kt @@ -9,8 +9,7 @@ import xyz.sevive.arcaeaoffline.core.database.extensions.bindTextOrNull import xyz.sevive.arcaeaoffline.core.database.extensions.getIntOrNull import xyz.sevive.arcaeaoffline.core.database.extensions.getLongOrNull import xyz.sevive.arcaeaoffline.core.database.extensions.getTextOrNull -import java.nio.ByteBuffer -import java.util.UUID +import kotlin.uuid.Uuid private const val CREATE_TABLE_PLAY_RESULTS_7 = "CREATE TABLE play_results (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `uuid` BLOB NOT NULL, `song_id` TEXT NOT NULL, `rating_class` TEXT NOT NULL, `score` INTEGER NOT NULL, `pure` INTEGER, `far` INTEGER, `lost` INTEGER, `date` INTEGER, `max_recall` INTEGER, `modifier` TEXT, `clear_type` TEXT, `comment` TEXT)" @@ -22,7 +21,7 @@ private const val CREATE_VIEW_PLAY_RESULTS_BEST_7 = "CREATE VIEW `play_results_best` AS SELECT\n prc.id, prc.uuid, prc.song_id, prc.rating_class, prc.score,\n prc.pure, prc.shiny_pure, prc.far, prc.lost,\n prc.date, prc.max_recall, prc.modifier, prc.clear_type,\n MAX(prc.potential) AS potential,\n prc.comment\n FROM play_results_calculated prc\n GROUP BY prc.song_id, prc.rating_class\n ORDER BY prc.potential DESC" private data class PlayResultIntermediate( - val uuid: UUID, + val uuid: Uuid, val id: Int, val songId: String, val ratingClass: String, @@ -36,13 +35,7 @@ private data class PlayResultIntermediate( val clearType: String?, val comment: String?, ) { - val uuidByteArray: ByteArray = - uuid.let { - val bb = ByteBuffer.wrap(ByteArray(16)) - bb.putLong(it.mostSignificantBits) - bb.putLong(it.leastSignificantBits) - bb.array() - } + val uuidByteArray: ByteArray = uuid.toByteArray() } object Migration_6_7 : Migration(6, 7) { @@ -56,7 +49,7 @@ object Migration_6_7 : Migration(6, 7) { while (it.step()) { playResults.add( PlayResultIntermediate( - uuid = UUID.randomUUID(), // the only real logic bro + uuid = Uuid.generateV4(), // the only real logic bro id = it.getInt(0), songId = it.getText(1), ratingClass = it.getText(2), diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PlayResultBestRepository.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PlayResultBestRepository.kt index 57d2f209..87ee2f66 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PlayResultBestRepository.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PlayResultBestRepository.kt @@ -51,7 +51,7 @@ class PlayResultBestRepositoryImpl( .take(limit) .map { it.first.uuid } - playResultCalculatedRepo.findAllByUUID(topUuids).map { list -> + playResultCalculatedRepo.findAllByUuid(topUuids).map { list -> // Re-sort because the DB might return them in a different order list.sortedByDescending { it.playRating } } diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PlayResultCalculatedRepository.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PlayResultCalculatedRepository.kt index 5f4b8097..c8425845 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PlayResultCalculatedRepository.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PlayResultCalculatedRepository.kt @@ -10,12 +10,12 @@ import xyz.sevive.arcaeaoffline.core.database.daos.PlayResultDao import xyz.sevive.arcaeaoffline.core.database.daos.SongDao import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult import xyz.sevive.arcaeaoffline.core.database.entities.PlayResultCalculated -import java.util.UUID +import kotlin.uuid.Uuid interface PlayResultCalculatedRepository { - fun find(uuid: UUID): Flow + fun find(uuid: Uuid): Flow - fun findAllByUUID(uuids: List): Flow> + fun findAllByUuid(uuids: List): Flow> fun findAllBySongIdAndRatingClass( songId: String, @@ -45,10 +45,10 @@ class PlayResultCalculatedRepositoryImpl( private suspend fun mapPlayResults(playResults: List) = playResults.mapNotNull { calculatePlayResult(it) } - override fun find(uuid: UUID): Flow = playResultDao.findByUUID(uuid).mapLatest { calculatePlayResult(it) } + override fun find(uuid: Uuid): Flow = playResultDao.findByUuid(uuid).mapLatest { calculatePlayResult(it) } - override fun findAllByUUID(uuids: List): Flow> = - playResultDao.findAllByUUID(uuids).mapLatest { mapPlayResults(it) } + override fun findAllByUuid(uuids: List): Flow> = + playResultDao.findAllByUuid(uuids).mapLatest { mapPlayResults(it) } override fun findAllBySongIdAndRatingClass( songId: String, diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PlayResultRepository.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PlayResultRepository.kt index 45eeaae5..eb169547 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PlayResultRepository.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PlayResultRepository.kt @@ -4,8 +4,8 @@ import kotlinx.coroutines.flow.Flow import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass import xyz.sevive.arcaeaoffline.core.database.daos.PlayResultDao import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult -import java.util.UUID import kotlin.time.Instant +import kotlin.uuid.Uuid interface PlayResultRepository { fun find( @@ -13,7 +13,7 @@ interface PlayResultRepository { ratingClass: ArcaeaRatingClass, ): Flow - fun findByUUID(uuid: UUID): Flow + fun findByUuid(uuid: Uuid): Flow fun findLaterThan(date: Instant): Flow> @@ -21,7 +21,7 @@ interface PlayResultRepository { fun findAllBySongId(songId: String): Flow> - fun findAllByUUID(uuids: List): Flow> + fun findAllByUuid(uuids: List): Flow> fun count(): Flow @@ -42,7 +42,7 @@ class PlayResultRepositoryImpl( ratingClass: ArcaeaRatingClass, ): Flow = dao.find(songId, ratingClass) - override fun findByUUID(uuid: UUID): Flow = dao.findByUUID(uuid) + override fun findByUuid(uuid: Uuid): Flow = dao.findByUuid(uuid) override fun findLaterThan(date: Instant): Flow> = dao.findLaterThan(date) @@ -50,7 +50,7 @@ class PlayResultRepositoryImpl( override fun findAllBySongId(songId: String): Flow> = dao.findAllBySongId(songId) - override fun findAllByUUID(uuids: List): Flow> = dao.findAllByUUID(uuids) + override fun findAllByUuid(uuids: List): Flow> = dao.findAllByUuid(uuids) override fun count(): Flow = dao.count() diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcr.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcr.kt index 8fe5c0d9..4fdb776d 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcr.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcr.kt @@ -23,8 +23,8 @@ import xyz.sevive.arcaeaoffline.core.ocr.ocrDigitSamplesKnn import xyz.sevive.arcaeaoffline.core.ocr.ocrDigitsByContourKnn import xyz.sevive.arcaeaoffline.core.ocr.preprocessHog import xyz.sevive.arcaeaoffline.core.ocr.resizeFillSquare -import java.util.UUID import kotlin.time.Instant +import kotlin.uuid.Uuid @Serializable data class DeviceOcrResult( @@ -73,7 +73,7 @@ fun DeviceOcrResult.toPlayResult( return PlayResult( id = 0, - uuid = UUID.randomUUID(), + uuid = Uuid.generateV4(), songId = this.songId, ratingClass = this.ratingClass, score = this.score, From 5854b504954ad06cc44315f096d60cb70a82528a Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 24 Jun 2026 21:10:23 +0800 Subject: [PATCH 02/17] remove java regex --- .../arcaeaoffline/core/database/entities/Pack.kt | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/Pack.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/Pack.kt index 9d2b55e4..fa58cd56 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/Pack.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/Pack.kt @@ -1,12 +1,9 @@ package xyz.sevive.arcaeaoffline.core.database.entities import androidx.room.Entity -import androidx.room.Ignore import androidx.room.PrimaryKey -import java.util.regex.Matcher -import java.util.regex.Pattern -val appendPackRegexPattern: Pattern = Pattern.compile("""(.*)_append_.*${'$'}""") +val appendPackRegexPattern = """(.*)_append_.*$""".toRegex() @Entity(tableName = "packs") data class Pack( @@ -14,15 +11,7 @@ data class Pack( val name: String, val description: String? = null, ) { - @Ignore - private val appendPackRegexMatcher: Matcher = appendPackRegexPattern.matcher(this.id) - - fun basePackId(): String? = - if (appendPackRegexMatcher.matches()) { - appendPackRegexMatcher.group(1) - } else { - null - } + fun basePackId(): String? = appendPackRegexPattern.matchEntire(this.id)?.groupValues?.getOrNull(1) fun isAppendPack(): Boolean = basePackId() != null } From 76a30017cc5c0ac89579a4993a737cb522568f4e Mon Sep 17 00:00:00 2001 From: 283375 Date: Thu, 25 Jun 2026 00:36:18 +0800 Subject: [PATCH 03/17] refactor ocr queue, remove the java Exception storing, move validation logics out of database scope --- .../2.json | 127 ++++++++++ .../database/OcrQueueDatabase.kt | 6 +- ...eaPlayResultValidatorWarningsConverters.kt | 21 -- .../converters/ExceptionConverters.kt | 32 --- .../database/daos/OcrQueueTaskDao.kt | 6 - .../database/entities/OcrQueueTask.kt | 22 +- .../repositories/OcrQueueTaskRepository.kt | 131 +++++----- .../jobs/OcrQueueEnqueueCheckerJob.kt | 4 +- .../sevive/arcaeaoffline/jobs/OcrQueueJob.kt | 45 ++-- .../ui/screens/ocr/queue/OcrQueueScreen.kt | 2 +- .../ocr/queue/OcrQueueScreenViewModel.kt | 229 +++++++++--------- .../tasklist/OcrQueueTaskListItemHeader.kt | 15 +- .../tasklist/OcrQueueTaskListItemResult.kt | 8 +- 13 files changed, 337 insertions(+), 311 deletions(-) create mode 100644 app/schemas/xyz.sevive.arcaeaoffline.database.OcrQueueDatabase/2.json delete mode 100644 app/src/main/java/xyz/sevive/arcaeaoffline/database/converters/ArcaeaPlayResultValidatorWarningsConverters.kt delete mode 100644 app/src/main/java/xyz/sevive/arcaeaoffline/database/converters/ExceptionConverters.kt diff --git a/app/schemas/xyz.sevive.arcaeaoffline.database.OcrQueueDatabase/2.json b/app/schemas/xyz.sevive.arcaeaoffline.database.OcrQueueDatabase/2.json new file mode 100644 index 00000000..a3fe02cb --- /dev/null +++ b/app/schemas/xyz.sevive.arcaeaoffline.database.OcrQueueDatabase/2.json @@ -0,0 +1,127 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "0c99dbc82218fdcf51038eb465481c2b", + "entities": [ + { + "tableName": "ocr_queue_tasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `inserted_at` INTEGER NOT NULL, `file_uri` TEXT NOT NULL, `status` INTEGER NOT NULL, `result` TEXT, `play_result` TEXT, `error_type` TEXT, `error_message` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "insertedAt", + "columnName": "inserted_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fileUri", + "columnName": "file_uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "result", + "columnName": "result", + "affinity": "TEXT" + }, + { + "fieldPath": "playResult", + "columnName": "play_result", + "affinity": "TEXT" + }, + { + "fieldPath": "errorType", + "columnName": "error_type", + "affinity": "TEXT" + }, + { + "fieldPath": "errorMessage", + "columnName": "error_message", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_ocr_queue_tasks_file_uri", + "unique": true, + "columnNames": [ + "file_uri" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ocr_queue_tasks_file_uri` ON `${TABLE_NAME}` (`file_uri`)" + } + ] + }, + { + "tableName": "ocr_queue_enqueue_buffer", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `uri` TEXT NOT NULL, `checked` INTEGER NOT NULL, `should_insert` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "checked", + "columnName": "checked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shouldInsert", + "columnName": "should_insert", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_ocr_queue_enqueue_buffer_uri", + "unique": true, + "columnNames": [ + "uri" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ocr_queue_enqueue_buffer_uri` ON `${TABLE_NAME}` (`uri`)" + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0c99dbc82218fdcf51038eb465481c2b')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/database/OcrQueueDatabase.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/database/OcrQueueDatabase.kt index 8fea5bbc..2354c577 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/database/OcrQueueDatabase.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/database/OcrQueueDatabase.kt @@ -8,9 +8,7 @@ import androidx.room.TypeConverters import androidx.sqlite.driver.bundled.BundledSQLiteDriver import kotlinx.coroutines.Dispatchers import xyz.sevive.arcaeaoffline.core.database.converters.InstantConverters -import xyz.sevive.arcaeaoffline.database.converters.ArcaeaPlayResultValidatorWarningsConverters import xyz.sevive.arcaeaoffline.database.converters.DeviceOcrResultConverters -import xyz.sevive.arcaeaoffline.database.converters.ExceptionConverters import xyz.sevive.arcaeaoffline.database.converters.OcrQueueTaskStatusConverters import xyz.sevive.arcaeaoffline.database.converters.PlayResultConverters import xyz.sevive.arcaeaoffline.database.converters.UriConverters @@ -21,7 +19,7 @@ import xyz.sevive.arcaeaoffline.database.entities.OcrQueueTask @Database( entities = [OcrQueueTask::class, OcrQueueEnqueueBuffer::class], - version = 1, + version = 2, exportSchema = true, ) @TypeConverters( @@ -30,8 +28,6 @@ import xyz.sevive.arcaeaoffline.database.entities.OcrQueueTask OcrQueueTaskStatusConverters::class, DeviceOcrResultConverters::class, PlayResultConverters::class, - ArcaeaPlayResultValidatorWarningsConverters::class, - ExceptionConverters::class, ) abstract class OcrQueueDatabase : RoomDatabase() { abstract fun ocrQueueTaskDao(): OcrQueueTaskDao diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/database/converters/ArcaeaPlayResultValidatorWarningsConverters.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/database/converters/ArcaeaPlayResultValidatorWarningsConverters.kt deleted file mode 100644 index bb7b4e7a..00000000 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/database/converters/ArcaeaPlayResultValidatorWarningsConverters.kt +++ /dev/null @@ -1,21 +0,0 @@ -package xyz.sevive.arcaeaoffline.database.converters - -import androidx.room.TypeConverter -import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidator -import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidatorWarning - -object ArcaeaPlayResultValidatorWarningsConverters { - @TypeConverter - fun fromDatabaseValue(value: String?): List? = - value - ?.split(",") - ?.mapNotNull { ArcaeaPlayResultValidator.WARNINGS.find { w -> w.id == it } } - - @TypeConverter - fun toDatabaseValue(value: List?): String? = - when { - value == null -> null - value.isEmpty() -> null - else -> value.joinToString(",") { it.id } - } -} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/database/converters/ExceptionConverters.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/database/converters/ExceptionConverters.kt deleted file mode 100644 index ddd108f2..00000000 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/database/converters/ExceptionConverters.kt +++ /dev/null @@ -1,32 +0,0 @@ -package xyz.sevive.arcaeaoffline.database.converters - -import androidx.room.TypeConverter -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.io.ObjectInputStream -import java.io.ObjectOutputStream - -object ExceptionConverters { - @TypeConverter - fun fromDatabaseValue(value: ByteArray?): Exception? = - value?.let { - runCatching { - val objectInputStream = ObjectInputStream(ByteArrayInputStream(it)) - - objectInputStream.readObject() as Exception - }.getOrNull() - } - - @TypeConverter - fun toDatabaseValue(value: Exception?): ByteArray? = - value?.let { - runCatching { - val byteArrayOutputStream = ByteArrayOutputStream() - val objectOutputStream = ObjectOutputStream(byteArrayOutputStream) - objectOutputStream.writeObject(it) - objectOutputStream.flush() - - byteArrayOutputStream.toByteArray() - }.getOrNull() - } -} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/database/daos/OcrQueueTaskDao.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/database/daos/OcrQueueTaskDao.kt index 1b61f7c8..5a752d08 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/database/daos/OcrQueueTaskDao.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/database/daos/OcrQueueTaskDao.kt @@ -21,18 +21,12 @@ interface OcrQueueTaskDao { @Query("SELECT * FROM ocr_queue_tasks WHERE status IN (:statuses)") fun findByStatus(statuses: List): Flow> - @Query("SELECT * FROM ocr_queue_tasks WHERE status = 3 AND warnings IS NOT NULL") - fun findDoneWithWarning(): Flow> - @Query("SELECT COUNT(*) FROM ocr_queue_tasks") fun count(): Flow @Query("SELECT COUNT(*) FROM ocr_queue_tasks WHERE status IN (:statuses)") fun countByStatus(statuses: List): Flow - @Query("SELECT COUNT(*) FROM ocr_queue_tasks WHERE status = 3 AND warnings IS NOT NULL") - fun countDoneWithWarning(): Flow - @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insert(item: OcrQueueTask): Long diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/database/entities/OcrQueueTask.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/database/entities/OcrQueueTask.kt index 6ce85d94..1aceed2f 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/database/entities/OcrQueueTask.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/database/entities/OcrQueueTask.kt @@ -1,17 +1,12 @@ package xyz.sevive.arcaeaoffline.database.entities -import android.content.Context import android.net.Uri import androidx.room.ColumnInfo import androidx.room.Entity -import androidx.room.Ignore import androidx.room.Index import androidx.room.PrimaryKey import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult import xyz.sevive.arcaeaoffline.core.ocr.device.DeviceOcrResult -import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidatorWarning -import xyz.sevive.arcaeaoffline.helpers.context.getFilename -import kotlin.time.Clock import kotlin.time.Instant enum class OcrQueueTaskStatus( @@ -31,20 +26,9 @@ data class OcrQueueTask( @PrimaryKey(autoGenerate = true) val id: Long = 0, @ColumnInfo(name = "inserted_at") val insertedAt: Instant, @ColumnInfo(name = "file_uri") val fileUri: Uri, - @ColumnInfo(name = "file_name") val fileName: String? = null, @ColumnInfo(name = "status") val status: OcrQueueTaskStatus = OcrQueueTaskStatus.IDLE, @ColumnInfo(name = "result") val result: DeviceOcrResult? = null, @ColumnInfo(name = "play_result") val playResult: PlayResult? = null, - @ColumnInfo(name = "warnings") val warnings: List? = null, - @ColumnInfo(name = "exception") val exception: Exception? = null, -) { - constructor(uri: Uri, context: Context? = null) : this( - fileUri = uri, - insertedAt = Clock.System.now(), - fileName = context?.getFilename(uri), - ) - - @Ignore - val canSaveSilently = - status == OcrQueueTaskStatus.DONE && playResult != null && warnings.isNullOrEmpty() -} + @ColumnInfo(name = "error_type") val errorType: String? = null, + @ColumnInfo(name = "error_message") val errorMessage: String? = null, +) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/database/repositories/OcrQueueTaskRepository.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/database/repositories/OcrQueueTaskRepository.kt index d34f6991..32b404ee 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/database/repositories/OcrQueueTaskRepository.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/database/repositories/OcrQueueTaskRepository.kt @@ -1,17 +1,18 @@ package xyz.sevive.arcaeaoffline.database.repositories -import android.content.Context import android.net.Uri +import androidx.room.Transactor +import androidx.room.useWriterConnection import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull +import xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase import xyz.sevive.arcaeaoffline.core.database.entities.Chart import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult -import xyz.sevive.arcaeaoffline.core.database.repositories.ChartInfoRepository import xyz.sevive.arcaeaoffline.core.database.repositories.PlayResultRepository -import xyz.sevive.arcaeaoffline.database.daos.OcrQueueTaskDao +import xyz.sevive.arcaeaoffline.database.OcrQueueDatabase import xyz.sevive.arcaeaoffline.database.entities.OcrQueueTask import xyz.sevive.arcaeaoffline.database.entities.OcrQueueTaskStatus -import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidator +import kotlin.time.Clock interface OcrQueueTaskRepository { fun findAll(): Flow> @@ -22,24 +23,13 @@ interface OcrQueueTaskRepository { fun findByStatus(vararg status: OcrQueueTaskStatus) = findByStatus(status.asList()) - fun findDoneWithWarning(): Flow> - fun count(): Flow fun countByStatus(statuses: List): Flow - fun countByStatus(vararg status: OcrQueueTaskStatus) = countByStatus(status.asList()) - - fun countDoneWithWarning(): Flow - suspend fun insert(item: OcrQueueTask): Long - suspend fun insertBatch(items: List): List - - suspend fun insertBatch( - uris: List, - context: Context? = null, - ): List + suspend fun insertBatch(uris: List): List suspend fun update(item: OcrQueueTask): Int @@ -51,7 +41,6 @@ interface OcrQueueTaskRepository { suspend fun updatePlayResult( id: Long, playResult: PlayResult, - chartInfoRepository: ChartInfoRepository?, ): Int? suspend fun delete(item: OcrQueueTask): Int @@ -62,52 +51,36 @@ interface OcrQueueTaskRepository { suspend fun deleteAll() - suspend fun save( - item: OcrQueueTask, - playResultRepository: PlayResultRepository, - ) - - suspend fun save( - itemId: Long, - playResultRepository: PlayResultRepository, - ) { - val item = findById(itemId).firstOrNull() ?: return - save(item, playResultRepository) - } + suspend fun save(item: OcrQueueTask) - /** - * Save all tasks that - * - Is [OcrQueueTaskStatus.DONE] - * - Has no validator warnings - */ - suspend fun saveAll(playResultRepository: PlayResultRepository) + suspend fun save(itemId: Long) + + suspend fun saveBatch(items: List) } class OcrQueueTaskRepositoryImpl( - private val dao: OcrQueueTaskDao, + private val ocrQueueDatabase: OcrQueueDatabase, + private val arcaeaOfflineDatabase: ArcaeaOfflineDatabase, + private val playResultRepository: PlayResultRepository, ) : OcrQueueTaskRepository { + private val dao = ocrQueueDatabase.ocrQueueTaskDao() + override fun findAll(): Flow> = dao.findAll() override fun findById(id: Long): Flow = dao.findById(id) override fun findByStatus(statuses: List): Flow> = dao.findByStatus(statuses) - override fun findDoneWithWarning(): Flow> = dao.findDoneWithWarning() - override fun count(): Flow = dao.count() override fun countByStatus(statuses: List): Flow = dao.countByStatus(statuses) - override fun countDoneWithWarning(): Flow = dao.countDoneWithWarning() - override suspend fun insert(item: OcrQueueTask): Long = dao.insert(item) - override suspend fun insertBatch(items: List): List = dao.insertBatch(items) - - override suspend fun insertBatch( - uris: List, - context: Context?, - ): List = insertBatch(uris.map { OcrQueueTask(it, context) }) + override suspend fun insertBatch(uris: List): List { + val now = Clock.System.now() + return dao.insertBatch(uris.map { OcrQueueTask(fileUri = it, insertedAt = now) }) + } override suspend fun update(item: OcrQueueTask): Int = dao.update(item) @@ -118,35 +91,23 @@ class OcrQueueTaskRepositoryImpl( var item = findById(id).firstOrNull() ?: return null if (item.status == OcrQueueTaskStatus.ERROR) { - item = item.copy(status = OcrQueueTaskStatus.DONE, exception = null) + item = item.copy(status = OcrQueueTaskStatus.DONE, errorType = null, errorMessage = null) } val newPlayResult = item.playResult?.copy(songId = chart.songId, ratingClass = chart.ratingClass) ?: PlayResult(songId = chart.songId, ratingClass = chart.ratingClass, score = 0) - val warnings = ArcaeaPlayResultValidator.validate(newPlayResult, chart) return update( - item.copy(playResult = newPlayResult, warnings = warnings), + item.copy(playResult = newPlayResult), ) } override suspend fun updatePlayResult( id: Long, playResult: PlayResult, - chartInfoRepository: ChartInfoRepository?, ): Int? { - var item = findById(id).firstOrNull()?.copy(playResult = playResult) ?: return null - - chartInfoRepository?.let { - chartInfoRepository.find(playResult).firstOrNull()?.let { chartInfo -> - item = - item.copy( - warnings = ArcaeaPlayResultValidator.validate(playResult, chartInfo), - ) - } - } - + val item = findById(id).firstOrNull()?.copy(playResult = playResult) ?: return null return update(item) } @@ -158,21 +119,43 @@ class OcrQueueTaskRepositoryImpl( override suspend fun deleteAll() = dao.deleteAll() - override suspend fun save( - item: OcrQueueTask, - playResultRepository: PlayResultRepository, - ) { - if (!item.canSaveSilently) return - - playResultRepository.upsert(item.playResult!!) - delete(item) + override suspend fun save(item: OcrQueueTask) { + if (item.playResult == null) return + + ocrQueueDatabase.useWriterConnection { transactor -> + arcaeaOfflineDatabase.useWriterConnection { aoTransactor -> + transactor.withTransaction(Transactor.SQLiteTransactionType.IMMEDIATE) { + aoTransactor.withTransaction(Transactor.SQLiteTransactionType.IMMEDIATE) { + playResultRepository.upsert(item.playResult) + delete(item) + } + } + } + } } - override suspend fun saveAll(playResultRepository: PlayResultRepository) { - val tasks = findAll().firstOrNull() ?: return - val tasksToSave = tasks.filter { it.canSaveSilently } + override suspend fun save(itemId: Long) { + dao.findById(itemId).firstOrNull()?.let { save(it) } + } - playResultRepository.upsertBatch(*tasksToSave.map { it.playResult!! }.toTypedArray()) - deleteBatch(tasksToSave) + override suspend fun saveBatch(items: List) { + ocrQueueDatabase.useWriterConnection { transactor -> + arcaeaOfflineDatabase.useWriterConnection { aoTransactor -> + transactor.withTransaction(Transactor.SQLiteTransactionType.IMMEDIATE) { + aoTransactor.withTransaction(Transactor.SQLiteTransactionType.IMMEDIATE) { + val itemsFiltered = + items + .mapNotNull { task -> + task.playResult?.let { task to it } + }.toMap() + val playResultsToSave = itemsFiltered.values.toTypedArray() + val tasksToDelete = itemsFiltered.keys.toList() + + playResultRepository.upsertBatch(*playResultsToSave) + deleteBatch(tasksToDelete) + } + } + } + } } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueEnqueueCheckerJob.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueEnqueueCheckerJob.kt index 638760be..e0079a83 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueEnqueueCheckerJob.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueEnqueueCheckerJob.kt @@ -83,7 +83,7 @@ class OcrQueueEnqueueCheckerJob( logger.i { "Cleaning up" } val urisToEnqueue = repo.findShouldInsertUris().firstOrNull() ?: emptyList() - taskRepo.insertBatch(urisToEnqueue, applicationContext) + taskRepo.insertBatch(urisToEnqueue) repo.deleteChecked() } @@ -184,7 +184,7 @@ class OcrQueueEnqueueCheckerJob( } return Result.success() - } catch (e: CancellationException) { + } catch (_: CancellationException) { logger.i { "CancellationException caught" } withContext(NonCancellable) { cleanup() diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueJob.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueJob.kt index 2dc0f2d8..7532afbd 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueJob.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueJob.kt @@ -46,6 +46,13 @@ import xyz.sevive.arcaeaoffline.helpers.DeviceOcrHelper import xyz.sevive.arcaeaoffline.helpers.OcrDependencyLoader import kotlin.time.Clock +private fun OcrQueueTask.copyWithException(exception: Exception) = + this.copy( + status = OcrQueueTaskStatus.ERROR, + errorType = exception::class.qualifiedName, + errorMessage = exception.message, + ) + class OcrQueueJob( context: Context, params: WorkerParameters, @@ -148,8 +155,7 @@ class OcrQueueJob( ) { NORMAL(0), ALL(1), - SMART_FIX(2), - ; + SMART_FIX(2), ; companion object { fun fromInt(value: Int) = entries.firstOrNull { it.value == value } ?: NORMAL @@ -185,7 +191,7 @@ class OcrQueueJob( channelScope .async { ocrQueueTaskRepo.findByStatus(OcrQueueTaskStatus.PROCESSING).firstOrNull()?.forEach { - ocrQueueTaskRepo.update(it.copy(status = OcrQueueTaskStatus.ERROR, exception = e)) + ocrQueueTaskRepo.update(it.copyWithException(e)) } }.await() } @@ -205,10 +211,8 @@ class OcrQueueJob( } private suspend fun processTask( - scope: CoroutineScope, task: OcrQueueTask, taskRepo: OcrQueueTaskRepository, - chartInfoRepo: ChartInfoRepository, ortSession: OrtSession, kNearestModel: KNearest, imageHashesDatabase: ImageHashesDatabase, @@ -241,30 +245,19 @@ class OcrQueueJob( fallbackDate = Clock.System.now(), ) - val warnings = - scope - .async { - val chartInfo = chartInfoRepo.find(playResult).firstOrNull() - ArcaeaPlayResultValidator.validate(playResult, chartInfo) - }.await() - task = task.copy( status = OcrQueueTaskStatus.DONE, result = ocrResult, playResult = playResult, - warnings = warnings, - exception = null, + errorType = null, + errorMessage = null, ) - } catch (e: CancellationException) { + } catch (_: CancellationException) { logger.i { "Job (${task.id}) CancellationException caught" } shouldUpdateTask = false } catch (e: Exception) { - task = - task.copy( - status = OcrQueueTaskStatus.ERROR, - exception = e, - ) + task = task.copyWithException(e) logger.e(e) { "Error occurred at task ${task.id} ${task.fileUri}" } Sentry.captureException(e) @@ -290,10 +283,8 @@ class OcrQueueJob( launch { channel.consumeEach { processTask( - scope = channelScope, task = it, taskRepo = ocrQueueTaskRepo, - chartInfoRepo = chartInfoRepo, ortSession = ortSession, kNearestModel = kNearestModel, imageHashesDatabase = imageHashesDatabase, @@ -335,14 +326,20 @@ class OcrQueueJob( val newPlayResult = task.playResult.copy(songId = songId) if (ArcaeaPlayResultValidator.validate(newPlayResult, chartInfo).isEmpty()) { - ocrQueueTaskRepo.update(task.copy(playResult = newPlayResult, warnings = null)) + ocrQueueTaskRepo.update(task.copy(playResult = newPlayResult)) return } } } private suspend fun runSmartFix() { - val taskWithWarnings = ocrQueueTaskRepo.findDoneWithWarning().firstOrNull() ?: return + val taskWithWarnings = + ocrQueueTaskRepo.findByStatus(OcrQueueTaskStatus.DONE).firstOrNull().orEmpty().filter { task -> + task.playResult?.let { playResult -> + val chartInfo = chartInfoRepo.find(playResult).firstOrNull() ?: return@let false + ArcaeaPlayResultValidator.validate(playResult, chartInfo).isNotEmpty() + } == true + } taskWithWarnings.forEach { tryFixTask(it) } } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/OcrQueueScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/OcrQueueScreen.kt index 755f5871..d4420bde 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/OcrQueueScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/OcrQueueScreen.kt @@ -35,7 +35,7 @@ fun OcrQueueScreen(viewModel: OcrQueueScreenViewModel = koinViewModel()) { val queueStatus by viewModel.queueStatusUiState.collectAsStateWithLifecycle() val queueTaskCounts by viewModel.queueTaskCounts.collectAsStateWithLifecycle() - val taskUiItems by viewModel.taskUiItems.collectAsStateWithLifecycle() + val taskUiItems by viewModel.currentScreenUiItems.collectAsStateWithLifecycle() val isTaskUiItemsLoading by viewModel.isTaskUiItemsLoading.collectAsStateWithLifecycle() val category by viewModel.currentScreenCategory.collectAsStateWithLifecycle() diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/OcrQueueScreenViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/OcrQueueScreenViewModel.kt index 779dd187..7e04f89f 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/OcrQueueScreenViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/OcrQueueScreenViewModel.kt @@ -1,7 +1,6 @@ package xyz.sevive.arcaeaoffline.ui.screens.ocr.queue import android.content.Context -import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.work.ExistingWorkPolicy @@ -15,8 +14,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn @@ -24,26 +22,22 @@ import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch import xyz.sevive.arcaeaoffline.core.database.entities.Chart import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult -import xyz.sevive.arcaeaoffline.core.database.repositories.ChartInfoRepository import xyz.sevive.arcaeaoffline.core.database.repositories.ChartRepository import xyz.sevive.arcaeaoffline.core.database.repositories.DifficultyRepository -import xyz.sevive.arcaeaoffline.core.database.repositories.PlayResultRepository import xyz.sevive.arcaeaoffline.core.database.repositories.SongRepository -import xyz.sevive.arcaeaoffline.core.ocr.device.DeviceOcrResult import xyz.sevive.arcaeaoffline.database.entities.OcrQueueTask import xyz.sevive.arcaeaoffline.database.entities.OcrQueueTaskStatus import xyz.sevive.arcaeaoffline.database.repositories.OcrQueueTaskRepositoryImpl import xyz.sevive.arcaeaoffline.datastore.OcrQueuePreferencesRepository import xyz.sevive.arcaeaoffline.datastore.OcrQueuePreferencesSerializer -import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidatorWarning +import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidator import xyz.sevive.arcaeaoffline.jobs.OcrQueueJob import xyz.sevive.arcaeaoffline.ui.helpers.UiDisplayChartCacheHolder import kotlin.time.Duration.Companion.seconds +@OptIn(ExperimentalCoroutinesApi::class) class OcrQueueScreenViewModel( context: Context, - private val chartInfoRepo: ChartInfoRepository, - private val playResultRepo: PlayResultRepository, private val songRepo: SongRepository, private val difficultyRepo: DifficultyRepository, private val chartRepo: ChartRepository, @@ -64,31 +58,23 @@ class OcrQueueScreenViewModel( ) data class TaskUiItem( - val id: Long, - val fileUri: Uri, - val status: OcrQueueTaskStatus, - val ocrResult: DeviceOcrResult? = null, - val playResult: PlayResult? = null, + val dbItem: OcrQueueTask, val chart: Chart? = null, - val exception: Exception? = null, - val warnings: List = emptyList(), ) { + // TODO: Legacy UI compatibility, consider removing these + val id = dbItem.id + val fileUri = dbItem.fileUri + val status = dbItem.status + val playResult = dbItem.playResult + val errorType = dbItem.errorType + val errorMessage = dbItem.errorMessage + + val warnings = playResult?.let { ArcaeaPlayResultValidator.validate(playResult = it, chart = chart) } + val hasWarnings = warnings?.isNotEmpty() == true + val canEditChart = status in listOf(OcrQueueTaskStatus.DONE, OcrQueueTaskStatus.ERROR) val canEditPlayResult = status == OcrQueueTaskStatus.DONE - - constructor( - dbItem: OcrQueueTask, - chart: Chart? = null, - ) : this( - id = dbItem.id, - fileUri = dbItem.fileUri, - status = dbItem.status, - ocrResult = dbItem.result, - playResult = dbItem.playResult, - chart = chart, - exception = dbItem.exception, - warnings = dbItem.warnings ?: emptyList(), - ) + val canSave = status == OcrQueueTaskStatus.DONE && warnings != null && !hasWarnings } data class QueueTaskCounts( @@ -104,30 +90,47 @@ class OcrQueueScreenViewModel( val isRunning: Boolean = true, ) + internal val currentScreenCategory = MutableStateFlow(OcrQueueScreenCategory.NULL) + + internal fun setCurrentScreenCategory(category: OcrQueueScreenCategory) { + currentScreenCategory.value = category + } + + private val _isTaskUiItemsLoading = MutableStateFlow(true) + val isTaskUiItemsLoading = _isTaskUiItemsLoading.asStateFlow() + + private suspend fun mapDbItemsToUiItems(dbItems: List): List { + val chartCacheHolder = UiDisplayChartCacheHolder() + chartCacheHolder.updateCache(dbItems.mapNotNull { it.playResult }, songRepo, difficultyRepo, chartRepo) + + return dbItems.map { + if (it.playResult == null) { + TaskUiItem(dbItem = it) + } else { + TaskUiItem(dbItem = it, chart = chartCacheHolder.get(it.playResult)) + } + } + } + + val uiItems = ocrQueueTaskRepo.findAll().mapLatest(::mapDbItemsToUiItems) + val queueTaskCounts = - combine( - ocrQueueTaskRepo.count(), - ocrQueueTaskRepo.countByStatus(OcrQueueTaskStatus.IDLE), - ocrQueueTaskRepo.countByStatus(OcrQueueTaskStatus.PROCESSING), - ocrQueueTaskRepo.countByStatus(OcrQueueTaskStatus.DONE), - ocrQueueTaskRepo.countDoneWithWarning(), - ocrQueueTaskRepo.countByStatus(OcrQueueTaskStatus.ERROR), - ) { - QueueTaskCounts( - total = it[0], - idle = it[1], - processing = it[2], - done = it[3], - doneWithWarning = it[4], - error = it[5], + uiItems + .mapLatest { tasks -> + QueueTaskCounts( + total = tasks.size, + idle = tasks.count { it.status == OcrQueueTaskStatus.IDLE }, + processing = tasks.count { it.status == OcrQueueTaskStatus.PROCESSING }, + done = tasks.count { it.status == OcrQueueTaskStatus.DONE }, + doneWithWarning = tasks.count { it.status == OcrQueueTaskStatus.DONE && it.hasWarnings }, + error = tasks.count { it.status == OcrQueueTaskStatus.ERROR }, + ) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5.seconds.inWholeMilliseconds), + QueueTaskCounts(), ) - }.stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(5.seconds.inWholeMilliseconds), - QueueTaskCounts(), - ) - @OptIn(ExperimentalCoroutinesApi::class) val queueStatusUiState = workManager .getWorkInfosForUniqueWorkFlow(OcrQueueJob.WORK_NAME) @@ -143,6 +146,54 @@ class OcrQueueScreenViewModel( QueueStatusUiState(), ) + val currentScreenUiItems = + combine(currentScreenCategory, uiItems) { category, items -> category to items } + .transformLatest { (category, items) -> + _isTaskUiItemsLoading.value = true + try { + // clear previous items when category changed + emit(emptyList()) + + // filter from ui items + val uiItemsFiltered = + when (category) { + OcrQueueScreenCategory.IDLE -> { + items.filter { it.status == OcrQueueTaskStatus.IDLE } + } + + OcrQueueScreenCategory.PROCESSING -> { + items.filter { it.status == OcrQueueTaskStatus.PROCESSING } + } + + OcrQueueScreenCategory.DONE -> { + items.filter { it.status == OcrQueueTaskStatus.DONE } + } + + OcrQueueScreenCategory.DONE_WITH_WARNING -> { + items.filter { + it.status == OcrQueueTaskStatus.DONE && it.hasWarnings + } + } + + OcrQueueScreenCategory.ERROR -> { + items.filter { it.status == OcrQueueTaskStatus.ERROR } + } + + OcrQueueScreenCategory.NULL -> { + emptyList() + } + } + + emit(uiItemsFiltered) + } finally { + _isTaskUiItemsLoading.value = false + } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5.seconds.inWholeMilliseconds), + emptyList(), + ) + private val queueRunning = workManager .getWorkInfosForUniqueWorkFlow(OcrQueueJob.WORK_NAME) @@ -176,13 +227,15 @@ class OcrQueueScreenViewModel( playResult: PlayResult, ) { viewModelScope.launch(Dispatchers.IO) { - ocrQueueTaskRepo.updatePlayResult(taskId, playResult, chartInfoRepo) + ocrQueueTaskRepo.updatePlayResult(taskId, playResult) } } fun saveTaskPlayResult(taskId: Long) { viewModelScope.launch(Dispatchers.IO) { - ocrQueueTaskRepo.save(taskId, playResultRepo) + val task = uiItems.firstOrNull().orEmpty().firstOrNull { it.dbItem.id == taskId } + if (task == null) return@launch + ocrQueueTaskRepo.save(task.id) } } @@ -190,7 +243,13 @@ class OcrQueueScreenViewModel( if (queueRunning.value) return viewModelScope.launch(Dispatchers.IO) { - ocrQueueTaskRepo.saveAll(playResultRepo) + ocrQueueTaskRepo.saveBatch( + uiItems + .firstOrNull() + .orEmpty() + .filter { it.canSave } + .map { it.dbItem }, + ) } } @@ -217,66 +276,4 @@ class OcrQueueScreenViewModel( ) } } - - internal val currentScreenCategory = MutableStateFlow(OcrQueueScreenCategory.NULL) - - internal fun setCurrentScreenCategory(category: OcrQueueScreenCategory) { - currentScreenCategory.value = category - } - - private val _isTaskUiItemsLoading = MutableStateFlow(true) - val isTaskUiItemsLoading = _isTaskUiItemsLoading.asStateFlow() - - private suspend fun mapDbItemsToUiItems(dbItems: List): List { - val chartCacheHolder = UiDisplayChartCacheHolder() - chartCacheHolder.updateCache(dbItems.mapNotNull { it.playResult }, songRepo, difficultyRepo, chartRepo) - - return dbItems.map { - if (it.playResult == null) { - TaskUiItem(dbItem = it) - } else { - TaskUiItem(dbItem = it, chart = chartCacheHolder.get(it.playResult)) - } - } - } - - @OptIn(ExperimentalCoroutinesApi::class) - val taskUiItems = - currentScreenCategory - .transformLatest { - // clear previous items when category changed - emit(emptyList()) - - // map category to db items - emitAll( - when (it) { - OcrQueueScreenCategory.NULL -> { - emptyFlow() - } - - OcrQueueScreenCategory.DONE_WITH_WARNING -> { - ocrQueueTaskRepo.findDoneWithWarning() - } - - else -> { - val taskStatus = - when (it) { - OcrQueueScreenCategory.IDLE -> OcrQueueTaskStatus.IDLE - OcrQueueScreenCategory.PROCESSING -> OcrQueueTaskStatus.PROCESSING - OcrQueueScreenCategory.DONE -> OcrQueueTaskStatus.DONE - OcrQueueScreenCategory.ERROR -> OcrQueueTaskStatus.ERROR - } - ocrQueueTaskRepo.findByStatus(taskStatus) - } - }, - ) - }.transformLatest { - _isTaskUiItemsLoading.value = true - emit(mapDbItemsToUiItems(it)) - _isTaskUiItemsLoading.value = false - }.stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(5.seconds.inWholeMilliseconds), - emptyList(), - ) } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/tasklist/OcrQueueTaskListItemHeader.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/tasklist/OcrQueueTaskListItemHeader.kt index 2145ea2b..8a74b96d 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/tasklist/OcrQueueTaskListItemHeader.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/tasklist/OcrQueueTaskListItemHeader.kt @@ -40,11 +40,13 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.core.net.toUri import xyz.sevive.arcaeaoffline.R +import xyz.sevive.arcaeaoffline.database.entities.OcrQueueTask import xyz.sevive.arcaeaoffline.database.entities.OcrQueueTaskStatus import xyz.sevive.arcaeaoffline.helpers.context.getFilename import xyz.sevive.arcaeaoffline.ui.components.preferences.TextPreferencesWidget import xyz.sevive.arcaeaoffline.ui.screens.ocr.queue.OcrQueueScreenViewModel import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme +import kotlin.time.Clock @Composable private fun TaskDetailsDialog( @@ -195,13 +197,14 @@ private fun OcrQueueTaskListItemHeaderPreview() { OcrQueueTaskListItemHeader( uiItem = OcrQueueScreenViewModel.TaskUiItem( - id = 123, - fileUri = "file:///preview.png".toUri(), - status = OcrQueueTaskStatus.DONE, - ocrResult = null, - playResult = null, + dbItem = + OcrQueueTask( + id = 123, + fileUri = "file:///preview.png".toUri(), + status = OcrQueueTaskStatus.DONE, + insertedAt = Clock.System.now(), + ), chart = null, - exception = null, ), onShowImagePreview = {}, onSaveTask = {}, diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/tasklist/OcrQueueTaskListItemResult.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/tasklist/OcrQueueTaskListItemResult.kt index 96a093ec..9fd7864c 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/tasklist/OcrQueueTaskListItemResult.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/tasklist/OcrQueueTaskListItemResult.kt @@ -22,7 +22,7 @@ internal fun OcrQueueTaskListItemResult( uiItem.playResult?.let { ArcaeaPlayResultCard( playResult = uiItem.playResult, - warnings = uiItem.warnings, + warnings = uiItem.warnings.orEmpty(), chart = uiItem.chart, modifier = modifier, ) @@ -30,14 +30,12 @@ internal fun OcrQueueTaskListItemResult( } OcrQueueTaskStatus.ERROR -> { - val exception = uiItem.exception ?: Exception("uiItem exception is null!") - Text( buildAnnotatedString { withStyle(MaterialTheme.typography.bodySmall.toSpanStyle()) { - appendLine(exception::class.qualifiedName) + appendLine(uiItem.errorType ?: "No errorType") } - append(exception.message) + append(uiItem.errorMessage ?: "No errorMessage") }, color = MaterialTheme.colorScheme.error, modifier = modifier, From dce3af41d92c37fd521c128ef5511ace0098df1e Mon Sep 17 00:00:00 2001 From: 283375 Date: Thu, 25 Jun 2026 02:30:20 +0800 Subject: [PATCH 04/17] refactor OcrQueueJob --- .../sevive/arcaeaoffline/jobs/OcrQueueJob.kt | 305 +++++++----------- .../jobs/OcrQueueJobTaskExecutor.kt | 121 +++++++ 2 files changed, 237 insertions(+), 189 deletions(-) create mode 100644 app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueJobTaskExecutor.kt diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueJob.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueJob.kt index 7532afbd..c2a9c438 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueJob.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueJob.kt @@ -1,6 +1,5 @@ package xyz.sevive.arcaeaoffline.jobs -import ai.onnxruntime.OrtSession import android.app.Notification import android.content.Context import android.content.pm.ServiceInfo @@ -13,45 +12,25 @@ import androidx.work.WorkerParameters import co.touchlab.kermit.Logger import io.sentry.Sentry import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.async -import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.consumeEach +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import org.opencv.ml.KNearest import xyz.sevive.arcaeaoffline.R import xyz.sevive.arcaeaoffline.core.database.repositories.ChartInfoRepository -import xyz.sevive.arcaeaoffline.core.ocr.ImageHashesDatabase -import xyz.sevive.arcaeaoffline.core.ocr.device.DeviceOcrOnnxHelper import xyz.sevive.arcaeaoffline.data.notification.Notifications import xyz.sevive.arcaeaoffline.database.entities.OcrQueueTask import xyz.sevive.arcaeaoffline.database.entities.OcrQueueTaskStatus import xyz.sevive.arcaeaoffline.database.repositories.OcrQueueTaskRepository import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidator -import xyz.sevive.arcaeaoffline.helpers.DeviceOcrHelper -import xyz.sevive.arcaeaoffline.helpers.OcrDependencyLoader -import kotlin.time.Clock - -private fun OcrQueueTask.copyWithException(exception: Exception) = - this.copy( - status = OcrQueueTaskStatus.ERROR, - errorType = exception::class.qualifiedName, - errorMessage = exception.message, - ) class OcrQueueJob( context: Context, @@ -72,6 +51,24 @@ class OcrQueueJob( private val logger = Logger.withTag(LOG_TAG) + enum class RunMode( + val value: Int, + ) { + /** Only process [OcrQueueTaskStatus.IDLE] and [OcrQueueTaskStatus.ERROR] tasks. */ + NORMAL(0), + + /** Process all tasks, no matter what their status is. */ + ALL(1), + + /** Try fixing those [OcrQueueTaskStatus.DONE] tasks with warnings. */ + SMART_FIX(2), + ; + + companion object { + fun fromInt(value: Int) = entries.firstOrNull { it.value == value } ?: NORMAL + } + } + private data class WorkOptions( val runMode: RunMode, val parallelCount: Int, @@ -87,21 +84,15 @@ class OcrQueueJob( ), ) - private val workOptions = getWorkOptions() - private val notificationManager = NotificationManagerCompat.from(applicationContext) - private val channelScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - private val channel = Channel() - private val progressCurrent = MutableStateFlow(0) private val progressTotal = MutableStateFlow(-1) private val progressLock = Mutex() private val progress = combine(progressCurrent, progressTotal) { p, t -> if (t > -1) p to t else null - }.stateIn(channelScope, SharingStarted.WhileSubscribed(2500L), null) - private var progressListenJob: Job? = null + } private fun createNotification(progress: Pair?): Notification { val builder = @@ -129,8 +120,8 @@ class OcrQueueJob( return builder.build() } - private fun createForegroundInfo(): ForegroundInfo { - val notification = createNotification(progress.value) + private suspend fun createForegroundInfo(): ForegroundInfo { + val notification = createNotification(progress.firstOrNull() ?: (0 to -1)) return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { ForegroundInfo( @@ -145,58 +136,97 @@ class OcrQueueJob( override suspend fun getForegroundInfo(): ForegroundInfo = createForegroundInfo() - /** - * * [RunMode.NORMAL]: Only process [OcrQueueTaskStatus.IDLE] and [OcrQueueTaskStatus.ERROR] tasks. - * * [RunMode.ALL]: Process all tasks, no matter what their status is. - * * [RunMode.SMART_FIX]: Try fixing those [OcrQueueTaskStatus.DONE] tasks with warnings. - */ - enum class RunMode( - val value: Int, - ) { - NORMAL(0), - ALL(1), - SMART_FIX(2), ; + private suspend fun fetchTasks(runMode: RunMode): List = + when (runMode) { + RunMode.NORMAL -> { + ocrQueueTaskRepo + .findByStatus( + OcrQueueTaskStatus.IDLE, + OcrQueueTaskStatus.PROCESSING, + OcrQueueTaskStatus.ERROR, + ).firstOrNull() + .orEmpty() + } - companion object { - fun fromInt(value: Int) = entries.firstOrNull { it.value == value } ?: NORMAL + RunMode.ALL -> { + ocrQueueTaskRepo.findAll().firstOrNull().orEmpty() + } + + RunMode.SMART_FIX -> { + ocrQueueTaskRepo.findByStatus(OcrQueueTaskStatus.DONE).firstOrNull().orEmpty().filter { task -> + task.playResult?.let { playResult -> + val chartInfo = chartInfoRepo.find(playResult).firstOrNull() ?: return@let false + ArcaeaPlayResultValidator.validate(playResult, chartInfo).isNotEmpty() + } == true + } + } + } + + private fun createTaskExecutor(runMode: RunMode): OcrQueueJobTaskExecutor = + when (runMode) { + RunMode.SMART_FIX -> { + OcrQueueFixTaskExecutor( + chartInfoRepo, + ocrQueueTaskRepo, + ) + } + + RunMode.NORMAL, + RunMode.ALL, + -> { + OcrQueueOcrImageTaskExecutor( + applicationContext, + ocrQueueTaskRepo, + ) + } } - } @androidx.annotation.RequiresPermission(android.Manifest.permission.POST_NOTIFICATIONS) override suspend fun doWork(): Result { - setForeground(createForegroundInfo()) - val workOptions = getWorkOptions() + val tasks = fetchTasks(workOptions.runMode) - progressListenJob = - channelScope.launch { - progress.collectLatest { - notificationManager.notify(NOTIFICATION_ID, createNotification(it)) - } - } + if (tasks.isEmpty()) return Result.success() + + progressTotal.value = tasks.size + setForeground(getForegroundInfo()) try { - when (workOptions.runMode) { - RunMode.NORMAL -> runNormal() - RunMode.ALL -> runAll() - RunMode.SMART_FIX -> runSmartFix() + // coroutineScope inherits the worker's cancellation context natively + coroutineScope { + val progressJob = + launch { + progress.collectLatest { + notificationManager.notify(NOTIFICATION_ID, createNotification(it)) + } + } + + try { + createTaskExecutor(workOptions.runMode).use { executor -> + processTasks( + tasks, + executor, + workOptions.parallelCount, + ) + } + } finally { + progressJob.cancel() + } } return Result.success() } catch (e: CancellationException) { logger.i { "CancellationException caught" } - channelScope.coroutineContext.cancelChildren() + // Database cleaning up withContext(NonCancellable + Dispatchers.IO) { - channelScope - .async { - ocrQueueTaskRepo.findByStatus(OcrQueueTaskStatus.PROCESSING).firstOrNull()?.forEach { - ocrQueueTaskRepo.update(it.copyWithException(e)) - } - }.await() + val processingTasks = ocrQueueTaskRepo.findByStatus(OcrQueueTaskStatus.PROCESSING).firstOrNull() + processingTasks?.forEach { + ocrQueueTaskRepo.update(it.copyWithException(e)) + } } - return Result.failure() + throw e } catch (e: Throwable) { logger.e(e) { "Uncaught error during doWork()" } Sentry.configureScope { @@ -205,141 +235,38 @@ class OcrQueueJob( } return Result.failure() } finally { - progressListenJob?.cancel() notificationManager.cancel(NOTIFICATION_ID) - } - } - - private suspend fun processTask( - task: OcrQueueTask, - taskRepo: OcrQueueTaskRepository, - ortSession: OrtSession, - kNearestModel: KNearest, - imageHashesDatabase: ImageHashesDatabase, - ) { - if (isStopped) return - - @Suppress("NAME_SHADOWING") - var task = task.copy() - task = task.copy(status = OcrQueueTaskStatus.PROCESSING) - taskRepo.update(task) - logger.v { "Processing task ${task.id}" } - var shouldUpdateTask = true - - try { - val uri = task.fileUri - - val ocrResult = - DeviceOcrHelper.ocrImage( - uri, - kNearestModel, - imageHashesDatabase, - ortSession = ortSession, - ) - - val playResult = - DeviceOcrHelper.ocrResultToPlayResult( - uri, - applicationContext, - ocrResult, - fallbackDate = Clock.System.now(), - ) - task = - task.copy( - status = OcrQueueTaskStatus.DONE, - result = ocrResult, - playResult = playResult, - errorType = null, - errorMessage = null, - ) - } catch (_: CancellationException) { - logger.i { "Job (${task.id}) CancellationException caught" } - shouldUpdateTask = false - } catch (e: Exception) { - task = task.copyWithException(e) - - logger.e(e) { "Error occurred at task ${task.id} ${task.fileUri}" } - Sentry.captureException(e) + // Reset progress for future runs if this worker instance is reused + progressCurrent.value = 0 + progressTotal.value = -1 } - - if (shouldUpdateTask) taskRepo.update(task) } - private suspend fun processTasks(tasks: List) { - DeviceOcrOnnxHelper.createOrtSession(applicationContext).use { ortSession -> - OcrDependencyLoader.imageHashesSQLiteDatabase().use { sqliteDb -> - val kNearestModel = OcrDependencyLoader.kNearestModel() - val imageHashesDatabase = ImageHashesDatabase(sqliteDb) + private suspend fun processTasks( + tasks: List, + executor: OcrQueueJobTaskExecutor, + parallelCount: Int, + ) = coroutineScope { + val channel = Channel(Channel.UNLIMITED) - withContext(Dispatchers.IO) { - launch { - progressTotal.value = tasks.size - tasks.forEach { channel.send(it) } - channel.close() - } + launch { + tasks.forEach { channel.send(it) } + channel.close() + } - repeat(workOptions.parallelCount) { - launch { - channel.consumeEach { - processTask( - task = it, - taskRepo = ocrQueueTaskRepo, - ortSession = ortSession, - kNearestModel = kNearestModel, - imageHashesDatabase = imageHashesDatabase, - ) - progressLock.withLock { progressCurrent.value += 1 } - } + repeat(parallelCount) { + launch(Dispatchers.IO) { + for (task in channel) { + try { + executor.execute(task) + } finally { + progressLock.withLock { + progressCurrent.value += 1 } } } } } } - - private suspend fun runNormal() { - val tasks = - ocrQueueTaskRepo - .findByStatus( - OcrQueueTaskStatus.IDLE, - OcrQueueTaskStatus.PROCESSING, - OcrQueueTaskStatus.ERROR, - ).firstOrNull() ?: return - processTasks(tasks) - } - - private suspend fun runAll() { - val tasks = ocrQueueTaskRepo.findAll().firstOrNull() ?: return - processTasks(tasks) - } - - private suspend fun tryFixTask(task: OcrQueueTask) { - if (task.result == null || task.playResult == null) return - - // this is all possible songIds - val hashResultLabels = task.result.songIdResults.map { it.label } - val ratingClass = task.result.ratingClass - - for (songId in hashResultLabels) { - val chartInfo = chartInfoRepo.find(songId, ratingClass).firstOrNull() ?: continue - - val newPlayResult = task.playResult.copy(songId = songId) - if (ArcaeaPlayResultValidator.validate(newPlayResult, chartInfo).isEmpty()) { - ocrQueueTaskRepo.update(task.copy(playResult = newPlayResult)) - return - } - } - } - - private suspend fun runSmartFix() { - val taskWithWarnings = - ocrQueueTaskRepo.findByStatus(OcrQueueTaskStatus.DONE).firstOrNull().orEmpty().filter { task -> - task.playResult?.let { playResult -> - val chartInfo = chartInfoRepo.find(playResult).firstOrNull() ?: return@let false - ArcaeaPlayResultValidator.validate(playResult, chartInfo).isNotEmpty() - } == true - } - taskWithWarnings.forEach { tryFixTask(it) } - } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueJobTaskExecutor.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueJobTaskExecutor.kt new file mode 100644 index 00000000..eff8cdab --- /dev/null +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueJobTaskExecutor.kt @@ -0,0 +1,121 @@ +package xyz.sevive.arcaeaoffline.jobs + +import android.content.Context +import co.touchlab.kermit.Logger +import io.sentry.Sentry +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.firstOrNull +import xyz.sevive.arcaeaoffline.core.database.repositories.ChartInfoRepository +import xyz.sevive.arcaeaoffline.core.ocr.ImageHashesDatabase +import xyz.sevive.arcaeaoffline.core.ocr.device.DeviceOcrOnnxHelper +import xyz.sevive.arcaeaoffline.database.entities.OcrQueueTask +import xyz.sevive.arcaeaoffline.database.entities.OcrQueueTaskStatus +import xyz.sevive.arcaeaoffline.database.repositories.OcrQueueTaskRepository +import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidator +import xyz.sevive.arcaeaoffline.helpers.DeviceOcrHelper +import xyz.sevive.arcaeaoffline.helpers.OcrDependencyLoader +import kotlin.time.Clock + +internal fun OcrQueueTask.copyWithException(exception: Exception) = + this.copy( + status = OcrQueueTaskStatus.ERROR, + errorType = exception::class.qualifiedName, + errorMessage = exception.message, + ) + +interface OcrQueueJobTaskExecutor : AutoCloseable { + suspend fun execute(task: OcrQueueTask) +} + +class OcrQueueOcrImageTaskExecutor( + private val context: Context, + private val ocrQueueTaskRepo: OcrQueueTaskRepository, +) : OcrQueueJobTaskExecutor { + private val logger = Logger.withTag("OcrQueueJob.OcrQueueOcrImageTaskExecutor") + + private val ortSession = + DeviceOcrOnnxHelper.createOrtSession(context) + + private val imageHashesSQLiteDatabase = + OcrDependencyLoader.imageHashesSQLiteDatabase() + + private val imageHashesDatabase = ImageHashesDatabase(imageHashesSQLiteDatabase) + + private val kNearestModel = OcrDependencyLoader.kNearestModel() + + override fun close() { + ortSession.close() + imageHashesSQLiteDatabase.close() + } + + override suspend fun execute(task: OcrQueueTask) { + @Suppress("NAME_SHADOWING") + var task = task.copy(status = OcrQueueTaskStatus.PROCESSING) + ocrQueueTaskRepo.update(task) + logger.v { "Processing task ${task.id}" } + + try { + val uri = task.fileUri + + val ocrResult = + DeviceOcrHelper.ocrImage( + uri, + kNearestModel, + imageHashesDatabase, + ortSession = ortSession, + ) + + val playResult = + DeviceOcrHelper.ocrResultToPlayResult( + uri, + context, + ocrResult, + fallbackDate = Clock.System.now(), + ) + + task = + task.copy( + status = OcrQueueTaskStatus.DONE, + result = ocrResult, + playResult = playResult, + errorType = null, + errorMessage = null, + ) + + ocrQueueTaskRepo.update(task) + } catch (e: CancellationException) { + // Rethrow and let [doWork] handle the rest + throw e + } catch (e: Exception) { + task = task.copyWithException(e) + logger.e(e) { "Error occurred at task ${task.id} ${task.fileUri}" } + Sentry.captureException(e) + ocrQueueTaskRepo.update(task) + } + } +} + +class OcrQueueFixTaskExecutor( + private val chartInfoRepo: ChartInfoRepository, + private val ocrQueueTaskRepo: OcrQueueTaskRepository, +) : OcrQueueJobTaskExecutor { + override suspend fun execute(task: OcrQueueTask) { + if (task.result == null || task.playResult == null) return + + // this is all possible songIds + val hashResultLabels = task.result.songIdResults.map { it.label } + val ratingClass = task.result.ratingClass + + for (songId in hashResultLabels) { + val chartInfo = chartInfoRepo.find(songId, ratingClass).firstOrNull() ?: continue + + val newPlayResult = task.playResult.copy(songId = songId) + if (ArcaeaPlayResultValidator.validate(newPlayResult, chartInfo).isEmpty()) { + ocrQueueTaskRepo.update(task.copy(playResult = newPlayResult)) + return + } + } + } + + override fun close() {} +} From d16b77f83404d8070bdadd44c570c6b9b5c100de Mon Sep 17 00:00:00 2001 From: 283375 Date: Thu, 25 Jun 2026 02:48:24 +0800 Subject: [PATCH 05/17] simplify `OcrQueueScreenViewModel.TaskUiItem` --- .../ocr/queue/OcrQueueScreenViewModel.kt | 38 ++++++++----------- .../ocr/queue/tasklist/OcrQueueTaskList.kt | 10 ++--- .../queue/tasklist/OcrQueueTaskListItem.kt | 13 ++++--- .../tasklist/OcrQueueTaskListItemHeader.kt | 14 +++---- .../tasklist/OcrQueueTaskListItemResult.kt | 10 ++--- 5 files changed, 39 insertions(+), 46 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/OcrQueueScreenViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/OcrQueueScreenViewModel.kt index 7e04f89f..158d0b74 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/OcrQueueScreenViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/OcrQueueScreenViewModel.kt @@ -61,20 +61,12 @@ class OcrQueueScreenViewModel( val dbItem: OcrQueueTask, val chart: Chart? = null, ) { - // TODO: Legacy UI compatibility, consider removing these - val id = dbItem.id - val fileUri = dbItem.fileUri - val status = dbItem.status - val playResult = dbItem.playResult - val errorType = dbItem.errorType - val errorMessage = dbItem.errorMessage - - val warnings = playResult?.let { ArcaeaPlayResultValidator.validate(playResult = it, chart = chart) } + val warnings = dbItem.playResult?.let { ArcaeaPlayResultValidator.validate(playResult = it, chart = chart) } val hasWarnings = warnings?.isNotEmpty() == true - val canEditChart = status in listOf(OcrQueueTaskStatus.DONE, OcrQueueTaskStatus.ERROR) - val canEditPlayResult = status == OcrQueueTaskStatus.DONE - val canSave = status == OcrQueueTaskStatus.DONE && warnings != null && !hasWarnings + val canEditChart = dbItem.status in listOf(OcrQueueTaskStatus.DONE, OcrQueueTaskStatus.ERROR) + val canEditPlayResult = dbItem.status == OcrQueueTaskStatus.DONE + val canSave = dbItem.status == OcrQueueTaskStatus.DONE && warnings != null && !hasWarnings } data class QueueTaskCounts( @@ -119,11 +111,11 @@ class OcrQueueScreenViewModel( .mapLatest { tasks -> QueueTaskCounts( total = tasks.size, - idle = tasks.count { it.status == OcrQueueTaskStatus.IDLE }, - processing = tasks.count { it.status == OcrQueueTaskStatus.PROCESSING }, - done = tasks.count { it.status == OcrQueueTaskStatus.DONE }, - doneWithWarning = tasks.count { it.status == OcrQueueTaskStatus.DONE && it.hasWarnings }, - error = tasks.count { it.status == OcrQueueTaskStatus.ERROR }, + idle = tasks.count { it.dbItem.status == OcrQueueTaskStatus.IDLE }, + processing = tasks.count { it.dbItem.status == OcrQueueTaskStatus.PROCESSING }, + done = tasks.count { it.dbItem.status == OcrQueueTaskStatus.DONE }, + doneWithWarning = tasks.count { it.dbItem.status == OcrQueueTaskStatus.DONE && it.hasWarnings }, + error = tasks.count { it.dbItem.status == OcrQueueTaskStatus.ERROR }, ) }.stateIn( viewModelScope, @@ -158,25 +150,25 @@ class OcrQueueScreenViewModel( val uiItemsFiltered = when (category) { OcrQueueScreenCategory.IDLE -> { - items.filter { it.status == OcrQueueTaskStatus.IDLE } + items.filter { it.dbItem.status == OcrQueueTaskStatus.IDLE } } OcrQueueScreenCategory.PROCESSING -> { - items.filter { it.status == OcrQueueTaskStatus.PROCESSING } + items.filter { it.dbItem.status == OcrQueueTaskStatus.PROCESSING } } OcrQueueScreenCategory.DONE -> { - items.filter { it.status == OcrQueueTaskStatus.DONE } + items.filter { it.dbItem.status == OcrQueueTaskStatus.DONE } } OcrQueueScreenCategory.DONE_WITH_WARNING -> { items.filter { - it.status == OcrQueueTaskStatus.DONE && it.hasWarnings + it.dbItem.status == OcrQueueTaskStatus.DONE && it.hasWarnings } } OcrQueueScreenCategory.ERROR -> { - items.filter { it.status == OcrQueueTaskStatus.ERROR } + items.filter { it.dbItem.status == OcrQueueTaskStatus.ERROR } } OcrQueueScreenCategory.NULL -> { @@ -235,7 +227,7 @@ class OcrQueueScreenViewModel( viewModelScope.launch(Dispatchers.IO) { val task = uiItems.firstOrNull().orEmpty().firstOrNull { it.dbItem.id == taskId } if (task == null) return@launch - ocrQueueTaskRepo.save(task.id) + ocrQueueTaskRepo.save(task.dbItem.id) } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/tasklist/OcrQueueTaskList.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/tasklist/OcrQueueTaskList.kt index 129908bd..53c550ce 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/tasklist/OcrQueueTaskList.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/tasklist/OcrQueueTaskList.kt @@ -25,13 +25,13 @@ internal fun OcrQueueTaskList( modifier.padding(horizontal = dimensionResource(R.dimen.page_padding)), verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.list_padding)), ) { - items(uiItems, key = { it.id }) { + items(uiItems, key = { it.dbItem.id }) { OcrQueueTaskListItem( uiItem = it, - onDelete = { onDeleteTask(it.id) }, - onEditChart = { chart -> onEditChart(it.id, chart) }, - onEditPlayResult = { playResult -> onEditPlayResult(it.id, playResult) }, - onSaveTask = { onSaveTask(it.id) }, + onDelete = { onDeleteTask(it.dbItem.id) }, + onEditChart = { chart -> onEditChart(it.dbItem.id, chart) }, + onEditPlayResult = { playResult -> onEditPlayResult(it.dbItem.id, playResult) }, + onSaveTask = { onSaveTask(it.dbItem.id) }, ) } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/tasklist/OcrQueueTaskListItem.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/tasklist/OcrQueueTaskListItem.kt index d7e08ab1..39a03209 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/tasklist/OcrQueueTaskListItem.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/tasklist/OcrQueueTaskListItem.kt @@ -1,5 +1,6 @@ package xyz.sevive.arcaeaoffline.ui.screens.ocr.queue.tasklist +import android.net.Uri import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -29,17 +30,17 @@ import xyz.sevive.arcaeaoffline.ui.screens.ocr.queue.OcrQueueScreenViewModel @Composable private fun OcrQueueTaskListItemImagePreviewDialog( - uiItem: OcrQueueScreenViewModel.TaskUiItem, + fileUri: Uri, onDismissRequest: () -> Unit, ) { val context = LocalContext.current - val filename = context.getFilename(uiItem.fileUri) ?: "-" + val filename = context.getFilename(fileUri) ?: "-" var showFileUri by rememberSaveable { mutableStateOf(false) } - val displayText = if (showFileUri) uiItem.fileUri.toString() else filename + val displayText = if (showFileUri) fileUri.toString() else filename ImagePreviewDialog( - inputStream = PlatformFile(uiItem.fileUri).source().buffered().asInputStream(), + inputStream = PlatformFile(fileUri).source().buffered().asInputStream(), onDismiss = onDismissRequest, topBarContent = { Text( @@ -63,7 +64,7 @@ internal fun OcrQueueTaskListItem( var showImagePreview by rememberSaveable { mutableStateOf(false) } if (showImagePreview) { OcrQueueTaskListItemImagePreviewDialog( - uiItem, + uiItem.dbItem.fileUri, onDismissRequest = { showImagePreview = false }, ) } @@ -79,7 +80,7 @@ internal fun OcrQueueTaskListItem( } } - val playResult = uiItem.playResult + val playResult = uiItem.dbItem.playResult var showPlayResultEditor by rememberSaveable { mutableStateOf(false) } if (showPlayResultEditor && playResult != null) { ArcaeaPlayResultEditorDialog( diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/tasklist/OcrQueueTaskListItemHeader.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/tasklist/OcrQueueTaskListItemHeader.kt index 8a74b96d..022a52c9 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/tasklist/OcrQueueTaskListItemHeader.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/tasklist/OcrQueueTaskListItemHeader.kt @@ -50,7 +50,7 @@ import kotlin.time.Clock @Composable private fun TaskDetailsDialog( - uiItem: OcrQueueScreenViewModel.TaskUiItem, + dbItem: OcrQueueTask, onDismissRequest: () -> Unit, ) { AlertDialog( @@ -62,14 +62,14 @@ private fun TaskDetailsDialog( item { TextPreferencesWidget( title = "ID", - content = uiItem.id.toString(), + content = dbItem.id.toString(), ) } item { TextPreferencesWidget( title = "Uri", - content = uiItem.fileUri.toString(), + content = dbItem.fileUri.toString(), ) } } @@ -88,14 +88,14 @@ internal fun OcrQueueTaskListItemHeader( modifier: Modifier = Modifier, ) { val context = LocalContext.current - val filename = remember(uiItem.fileUri) { context.getFilename(uiItem.fileUri) ?: "-" } + val filename = remember(uiItem.dbItem.fileUri) { context.getFilename(uiItem.dbItem.fileUri) ?: "-" } var showEditPopup by rememberSaveable { mutableStateOf(false) } var showDetailsDialog by rememberSaveable { mutableStateOf(false) } if (showDetailsDialog) { TaskDetailsDialog( - uiItem = uiItem, + dbItem = uiItem.dbItem, onDismissRequest = { showDetailsDialog = false }, ) } @@ -106,7 +106,7 @@ internal fun OcrQueueTaskListItemHeader( verticalAlignment = Alignment.CenterVertically, ) { Box(Modifier.minimumInteractiveComponentSize()) { - OcrQueueTaskListItemStatus(uiItem.status) + OcrQueueTaskListItemStatus(uiItem.dbItem.status) } Text( @@ -176,7 +176,7 @@ internal fun OcrQueueTaskListItemHeader( IconButton( onClick = onSaveTask, - enabled = uiItem.status == OcrQueueTaskStatus.DONE, + enabled = uiItem.dbItem.status == OcrQueueTaskStatus.DONE, colors = IconButtonDefaults.iconButtonColors( contentColor = MaterialTheme.colorScheme.primary, diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/tasklist/OcrQueueTaskListItemResult.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/tasklist/OcrQueueTaskListItemResult.kt index 9fd7864c..44787fdd 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/tasklist/OcrQueueTaskListItemResult.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/tasklist/OcrQueueTaskListItemResult.kt @@ -17,11 +17,11 @@ internal fun OcrQueueTaskListItemResult( modifier: Modifier = Modifier, ) { Box { - when (uiItem.status) { + when (uiItem.dbItem.status) { OcrQueueTaskStatus.DONE -> { - uiItem.playResult?.let { + uiItem.dbItem.playResult?.let { playResult -> ArcaeaPlayResultCard( - playResult = uiItem.playResult, + playResult = playResult, warnings = uiItem.warnings.orEmpty(), chart = uiItem.chart, modifier = modifier, @@ -33,9 +33,9 @@ internal fun OcrQueueTaskListItemResult( Text( buildAnnotatedString { withStyle(MaterialTheme.typography.bodySmall.toSpanStyle()) { - appendLine(uiItem.errorType ?: "No errorType") + appendLine(uiItem.dbItem.errorType ?: "No errorType") } - append(uiItem.errorMessage ?: "No errorMessage") + append(uiItem.dbItem.errorMessage ?: "No errorMessage") }, color = MaterialTheme.colorScheme.error, modifier = modifier, From 2250eb803b039d4b2b770ddd61e42253dc2096cd Mon Sep 17 00:00:00 2001 From: 283375 Date: Thu, 25 Jun 2026 03:43:49 +0800 Subject: [PATCH 06/17] replace date time pickers with compose components --- .../datetimeeditor/AndroidViewWrappersTest.kt | 125 ----------------- .../datetimeeditor/AndroidViewWrappers.kt | 88 ------------ .../datetimeeditor/DateTimeEditDialog.kt | 128 +++++++++++++----- app/src/main/res/values-zh-rCN/strings.xml | 6 +- app/src/main/res/values/strings.xml | 6 +- 5 files changed, 97 insertions(+), 256 deletions(-) delete mode 100644 app/src/androidTest/java/xyz/sevive/arcaeaoffline/ui/common/datetimeeditor/AndroidViewWrappersTest.kt delete mode 100644 app/src/main/java/xyz/sevive/arcaeaoffline/ui/common/datetimeeditor/AndroidViewWrappers.kt diff --git a/app/src/androidTest/java/xyz/sevive/arcaeaoffline/ui/common/datetimeeditor/AndroidViewWrappersTest.kt b/app/src/androidTest/java/xyz/sevive/arcaeaoffline/ui/common/datetimeeditor/AndroidViewWrappersTest.kt deleted file mode 100644 index a0421eb3..00000000 --- a/app/src/androidTest/java/xyz/sevive/arcaeaoffline/ui/common/datetimeeditor/AndroidViewWrappersTest.kt +++ /dev/null @@ -1,125 +0,0 @@ -package xyz.sevive.arcaeaoffline.ui.common.datetimeeditor - -import android.os.Build -import android.widget.CalendarView -import android.widget.DatePicker -import android.widget.TimePicker -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.test.junit4.v2.createComposeRule -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.contrib.PickerActions -import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.filters.SdkSuppress -import kotlinx.datetime.LocalDate -import kotlinx.datetime.LocalTime -import kotlinx.datetime.TimeZone -import kotlinx.datetime.atStartOfDayIn -import kotlinx.datetime.number -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class AndroidViewWrappersTest { - @get:Rule - val composeTestRule = createComposeRule() - - @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) - @Test - fun datePicker_initializationAndSelectionAreCorrect() { - var capturedDate: LocalDate? = null - - composeTestRule.setContent { - var selectedDate by remember { mutableStateOf(LocalDate(2026, 6, 16)) } - - AndroidViewDatePickerDialog( - date = selectedDate, - onDateSelect = { - capturedDate = it - selectedDate = it - }, - ) - } - - // Assert initialization - onView(isAssignableFrom(DatePicker::class.java)).check { view, _ -> - val datePicker = view as DatePicker - assertEquals(2026, datePicker.year) - assertEquals(6 - 1, datePicker.month) - assertEquals(16, datePicker.dayOfMonth) - } - - // Simulate user interactions - // Note that Espresso has adjustments to the month (no minus 1 required) - onView(isAssignableFrom(DatePicker::class.java)) - .perform(PickerActions.setDate(2026, 2, 6)) - - // Assert selection - composeTestRule.runOnIdle { - assertNotNull(capturedDate) - assertEquals(2026, capturedDate?.year) - assertEquals(2, capturedDate?.month?.number) - assertEquals(6, capturedDate?.day) - } - } - - @Test - fun calendarView_initializationIsCorrect() { - val initialDate = LocalDate(2026, 6, 19) - - composeTestRule.setContent { - AndroidViewCalendar( - date = initialDate, - onDateSelect = {}, - ) - } - - onView(isAssignableFrom(CalendarView::class.java)).check { view, _ -> - val calendarView = view as CalendarView - val expectedMillis = initialDate.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() - - assertEquals(expectedMillis, calendarView.date) - } - } - - @Test - fun timePicker_initializationAndSelectionAreCorrect() { - var capturedTime: LocalTime? = null - - composeTestRule.setContent { - var selectedTime by remember { mutableStateOf(LocalTime(12, 34)) } - - AndroidViewTimePicker( - time = selectedTime, - onTimeSelect = { - capturedTime = it - selectedTime = it - }, - ) - } - - // Assert initialization - onView(isAssignableFrom(TimePicker::class.java)).check { view, _ -> - val timePicker = view as TimePicker - assertEquals(12, timePicker.hour) - assertEquals(34, timePicker.minute) - } - - // Simulate user interactions - onView(isAssignableFrom(TimePicker::class.java)) - .perform(PickerActions.setTime(21, 43)) - - // Assert selection - composeTestRule.runOnIdle { - assertNotNull(capturedTime) - assertEquals(21, capturedTime?.hour) - assertEquals(43, capturedTime?.minute) - } - } -} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/common/datetimeeditor/AndroidViewWrappers.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/common/datetimeeditor/AndroidViewWrappers.kt deleted file mode 100644 index 41edb3df..00000000 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/common/datetimeeditor/AndroidViewWrappers.kt +++ /dev/null @@ -1,88 +0,0 @@ -package xyz.sevive.arcaeaoffline.ui.common.datetimeeditor - -import android.os.Build -import android.widget.CalendarView -import android.widget.DatePicker -import android.widget.TimePicker -import androidx.annotation.RequiresApi -import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.viewinterop.AndroidView -import kotlinx.datetime.LocalDate -import kotlinx.datetime.LocalTime -import kotlinx.datetime.TimeZone -import kotlinx.datetime.atStartOfDayIn -import kotlinx.datetime.number - -@RequiresApi(Build.VERSION_CODES.O) -@Composable -internal fun AndroidViewDatePickerDialog( - date: LocalDate, - minDate: LocalDate? = null, - maxDate: LocalDate? = null, - onDateSelect: (LocalDate) -> Unit, -) { - AndroidView( - modifier = Modifier.wrapContentSize(), - factory = { context -> DatePicker(context) }, - update = { view -> - view.updateDate(date.year, date.month.number - 1, date.day) - minDate?.let { - view.minDate = it.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() - } - maxDate?.let { - view.maxDate = it.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() - } - - view.setOnDateChangedListener { _, year, monthOfYear, dayOfMonth -> - onDateSelect(LocalDate(year, monthOfYear + 1, dayOfMonth)) - } - }, - ) -} - -@Composable -internal fun AndroidViewCalendar( - date: LocalDate, - minDate: LocalDate? = null, - maxDate: LocalDate? = null, - onDateSelect: (LocalDate) -> Unit, -) { - AndroidView( - modifier = Modifier.wrapContentSize(), - factory = { context -> CalendarView(context) }, - update = { view -> - view.date = date.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() - minDate?.let { - view.minDate = it.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() - } - maxDate?.let { - view.maxDate = it.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() - } - - view.setOnDateChangeListener { _, year, month, dayOfMonth -> - onDateSelect(LocalDate(year, month + 1, dayOfMonth)) - } - }, - ) -} - -@Composable -internal fun AndroidViewTimePicker( - time: LocalTime, - onTimeSelect: (LocalTime) -> Unit, -) { - AndroidView( - modifier = Modifier.wrapContentSize(), - factory = { context -> TimePicker(context) }, - update = { view -> - view.hour = time.hour - view.minute = time.minute - - view.setOnTimeChangedListener { _, hourOfDay, minute -> - onTimeSelect(LocalTime(hourOfDay, minute)) - } - }, - ) -} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/common/datetimeeditor/DateTimeEditDialog.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/common/datetimeeditor/DateTimeEditDialog.kt index e605ff39..c00846cc 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/common/datetimeeditor/DateTimeEditDialog.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/common/datetimeeditor/DateTimeEditDialog.kt @@ -1,9 +1,8 @@ package xyz.sevive.arcaeaoffline.ui.common.datetimeeditor -import android.os.Build import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.selection.DisableSelection import androidx.compose.material.icons.Icons @@ -15,13 +14,22 @@ import androidx.compose.material.icons.filled.EditCalendar import androidx.compose.material.icons.filled.Remove import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SelectableDates import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextField +import androidx.compose.material3.TimePicker +import androidx.compose.material3.TimePickerDialog +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberTimePickerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -29,6 +37,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue @@ -38,12 +47,13 @@ import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atTime import kotlinx.datetime.format import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toJavaLocalTime import kotlinx.datetime.toLocalDateTime -import xyz.sevive.arcaeaoffline.ui.components.BasicAlertDialogSurface +import xyz.sevive.arcaeaoffline.R import xyz.sevive.arcaeaoffline.ui.components.dialogs.DialogConfirmButton import xyz.sevive.arcaeaoffline.ui.components.dialogs.DialogDismissTextButton import xyz.sevive.arcaeaoffline.ui.components.preferences.TextPreferencesWidget @@ -51,6 +61,7 @@ import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import kotlin.time.Clock +import kotlin.time.Instant @Composable private fun SecondEditor( @@ -87,6 +98,7 @@ private fun SecondEditor( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun DateTimeEditDialog( onDismissRequest: () -> Unit, @@ -97,38 +109,76 @@ internal fun DateTimeEditDialog( val dateFormatter = remember { DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG) } val timeFormatter = remember { DateTimeFormatter.ofLocalizedTime(FormatStyle.MEDIUM) } + val minDateMillis = remember(minDate) { minDate?.atStartOfDayIn(TimeZone.UTC)?.toEpochMilliseconds() } + val datePickerState = + rememberDatePickerState( + initialSelectedDate = dateTime.date.toJavaLocalDate(), + selectableDates = + object : SelectableDates { + override fun isSelectableDate(utcTimeMillis: Long): Boolean = minDateMillis?.let { utcTimeMillis >= it } ?: true + }, + ) + val timePickerState = + rememberTimePickerState( + initialHour = dateTime.time.hour, + initialMinute = dateTime.time.minute, + ) + var date by rememberSaveable { mutableStateOf(dateTime.date) } - var selectedTime by rememberSaveable { - mutableStateOf(LocalTime(dateTime.time.hour, dateTime.time.minute)) - } + var hour by rememberSaveable { mutableIntStateOf(dateTime.time.hour) } + var minute by rememberSaveable { mutableIntStateOf(dateTime.time.minute) } var second by rememberSaveable { mutableIntStateOf(dateTime.time.second) } - val time = remember(selectedTime, second) { LocalTime(selectedTime.hour, selectedTime.minute, second) } - val dateText = remember(date) { dateFormatter.format(date.toJavaLocalDate()) } - val timeText = remember(time) { timeFormatter.format(time.toJavaLocalTime()) } + val selectedDateTime by remember { + derivedStateOf { date.atTime(LocalTime(hour, minute, second)) } + } + val dateText by remember { + derivedStateOf { dateFormatter.format(selectedDateTime.date.toJavaLocalDate()) } + } + val timeText by remember { + derivedStateOf { timeFormatter.format(selectedDateTime.time.toJavaLocalTime()) } + } var showDateEditDialog by rememberSaveable { mutableStateOf(false) } var showTimeEditDialog by rememberSaveable { mutableStateOf(false) } if (showDateEditDialog) { - BasicAlertDialogSurface(onDismissRequest = { showDateEditDialog = false }) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - AndroidViewDatePickerDialog( - date = date, - minDate = minDate, - onDateSelect = { date = it }, - ) - } else { - AndroidViewCalendar( - date = date, - minDate = minDate, - onDateSelect = { date = it }, - ) - } + val onDatePickerDismiss = { showDateEditDialog = false } + DatePickerDialog( + onDismissRequest = onDatePickerDismiss, + confirmButton = { + DialogConfirmButton(onClick = { + datePickerState.selectedDateMillis?.let { + date = Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date + } + onDatePickerDismiss() + }) + }, + dismissButton = { + DialogDismissTextButton(onClick = onDatePickerDismiss) + }, + ) { + DatePicker(state = datePickerState) } } if (showTimeEditDialog) { - BasicAlertDialogSurface(onDismissRequest = { showTimeEditDialog = false }) { - AndroidViewTimePicker(time = selectedTime, onTimeSelect = { selectedTime = it }) + val onTimePickerDismiss = { showTimeEditDialog = false } + TimePickerDialog( + onDismissRequest = onTimePickerDismiss, + title = {}, + confirmButton = { + DialogConfirmButton(onClick = { + hour = timePickerState.hour + minute = timePickerState.minute + onTimePickerDismiss() + }) + }, + dismissButton = { + DialogDismissTextButton(onClick = onTimePickerDismiss) + }, + ) { + TimePicker( + state = timePickerState, + ) } } @@ -137,7 +187,7 @@ internal fun DateTimeEditDialog( confirmButton = { DialogConfirmButton( onClick = { - onDateTimeChange(date.atTime(time)) + onDateTimeChange(selectedDateTime) onDismissRequest() }, ) @@ -145,29 +195,33 @@ internal fun DateTimeEditDialog( dismissButton = { DialogDismissTextButton(onClick = onDismissRequest) }, icon = { Icon(Icons.Default.EditCalendar, contentDescription = null) }, text = { - Column { - TextPreferencesWidget( - onClick = { showDateEditDialog = true }, - leadingIcon = Icons.Default.CalendarMonth, - leadingIconTint = MaterialTheme.colorScheme.secondary, - trailingIcon = Icons.Default.Edit, - title = dateText, - ) + LazyColumn { + item { + TextPreferencesWidget( + onClick = { showDateEditDialog = true }, + leadingIcon = Icons.Default.CalendarMonth, + leadingIconTint = MaterialTheme.colorScheme.secondary, + trailingIcon = Icons.Default.Edit, + title = dateText, + content = stringResource(R.string.datetime_picker_date), + ) + } - Row { + item { TextPreferencesWidget( onClick = { showTimeEditDialog = true }, leadingIcon = Icons.Default.AccessTime, leadingIconTint = MaterialTheme.colorScheme.secondary, trailingIcon = Icons.Default.Edit, title = timeText, - modifier = Modifier.weight(2f), + content = stringResource(R.string.datetime_picker_time), ) + } + item { SecondEditor( second = second, onSecondChange = { second = it }, - modifier = Modifier.weight(1f), ) } } diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index cdf16acd..42725ab5 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -215,9 +215,9 @@ 置空 备注 - 日期 - 日期 - 时间 + 日期时间选择 + 日期 + 时间 单曲潜力值计算 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 804014bc..5b3e53b0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -246,9 +246,9 @@ CC BY-SA 3.0 NULL Comment - Date - Date - Time + Date Time Picker + Date + Time Play Rating Calculator From e0f11e5cd18bd348c5ace41892c8e37692dc8f8b Mon Sep 17 00:00:00 2001 From: 283375 Date: Thu, 25 Jun 2026 03:44:04 +0800 Subject: [PATCH 07/17] fix ArcaeaPlayResultEditor --- .../ui/components/ArcaeaPlayResultEditor.kt | 101 +++++++++++------- 1 file changed, 61 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaPlayResultEditor.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaPlayResultEditor.kt index 3f169a13..4ce25ed5 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaPlayResultEditor.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaPlayResultEditor.kt @@ -2,6 +2,7 @@ package xyz.sevive.arcaeaoffline.ui.components import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.AlertDialog import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Tab @@ -61,52 +62,72 @@ fun ArcaeaPlayResultEditorContent( } } - when (tabIndex) { - 0 -> { - OutlinedArcaeaScoreTextField( - playResultScoreTextFieldState, - Modifier.fillMaxWidth(), - ) + LazyColumn { + when (tabIndex) { + 0 -> { + item { + OutlinedArcaeaScoreTextField( + playResultScoreTextFieldState, + Modifier.fillMaxWidth(), + ) + } - PlayResultEditorPureField( - pure = playResult.pure, - onPureChange = { onPlayResultChange(playResult.copy(pure = it)) }, - ) - PlayResultEditorFarField( - far = playResult.far, - onFarChange = { onPlayResultChange(playResult.copy(far = it)) }, - ) - PlayResultEditorLostField( - lost = playResult.lost, - onLostChange = { onPlayResultChange(playResult.copy(lost = it)) }, - ) + item { + PlayResultEditorPureField( + pure = playResult.pure, + onPureChange = { onPlayResultChange(playResult.copy(pure = it)) }, + ) + } + item { + PlayResultEditorFarField( + far = playResult.far, + onFarChange = { onPlayResultChange(playResult.copy(far = it)) }, + ) + } + item { + PlayResultEditorLostField( + lost = playResult.lost, + onLostChange = { onPlayResultChange(playResult.copy(lost = it)) }, + ) + } - PlayResultEditorDateTimeField( - instant = playResult.date, - onInstantChange = { onPlayResultChange(playResult.copy(date = it)) }, - ) + item { + PlayResultEditorDateTimeField( + instant = playResult.date, + onInstantChange = { onPlayResultChange(playResult.copy(date = it)) }, + ) + } - PlayResultEditorMaxRecallField( - maxRecall = playResult.maxRecall, - onMaxRecallChange = { onPlayResultChange(playResult.copy(maxRecall = it)) }, - ) - } + item { + PlayResultEditorMaxRecallField( + maxRecall = playResult.maxRecall, + onMaxRecallChange = { onPlayResultChange(playResult.copy(maxRecall = it)) }, + ) + } + } - 1 -> { - PlayResultEditorModifierField( - arcaeaModifier = playResult.modifier, - onArcaeaModifierChange = { onPlayResultChange(playResult.copy(modifier = it)) }, - ) + 1 -> { + item { + PlayResultEditorModifierField( + arcaeaModifier = playResult.modifier, + onArcaeaModifierChange = { onPlayResultChange(playResult.copy(modifier = it)) }, + ) + } - PlayResultEditorClearTypeField( - clearType = playResult.clearType, - onClearTypeChange = { onPlayResultChange(playResult.copy(clearType = it)) }, - ) + item { + PlayResultEditorClearTypeField( + clearType = playResult.clearType, + onClearTypeChange = { onPlayResultChange(playResult.copy(clearType = it)) }, + ) + } - PlayResultEditorCommentField( - comment = playResult.comment, - onCommentChange = { onPlayResultChange(playResult.copy(comment = it)) }, - ) + item { + PlayResultEditorCommentField( + comment = playResult.comment, + onCommentChange = { onPlayResultChange(playResult.copy(comment = it)) }, + ) + } + } } } } From 53db8938dcf299726c7e665386751725795973d6 Mon Sep 17 00:00:00 2001 From: 283375 Date: Thu, 25 Jun 2026 05:22:41 +0800 Subject: [PATCH 08/17] use Apache Commons ZipFile for selected apk importing --- app/build.gradle.kts | 1 + .../manage/DatabaseManageViewModel.kt | 62 ++++++++----------- gradle/libs.versions.toml | 7 +-- 3 files changed, 30 insertions(+), 40 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 21ea4ea5..f7bedae7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -205,6 +205,7 @@ dependencies { implementation(libs.filekit.dialogs.compose) implementation(libs.opencv) + implementation(libs.apache.commons.compress) implementation(libs.onnxruntime.android) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageViewModel.kt index 66d54efe..a00f5c1a 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageViewModel.kt @@ -10,7 +10,6 @@ import androidx.sqlite.driver.bundled.SQLITE_OPEN_READONLY import co.touchlab.kermit.Logger import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.readBytes -import io.github.vinceglb.filekit.source import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -22,9 +21,8 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import kotlinx.io.asInputStream -import kotlinx.io.buffered import kotlinx.io.files.SystemFileSystem +import org.apache.commons.compress.archivers.zip.ZipFile import xyz.sevive.arcaeaoffline.R import xyz.sevive.arcaeaoffline.core.database.externals.exporters.ArcaeaOfflineDEFv2Exporter import xyz.sevive.arcaeaoffline.core.database.externals.importers.ArcaeaPacklistImporter @@ -42,11 +40,11 @@ import xyz.sevive.arcaeaoffline.core.database.repositories.SongRepository import xyz.sevive.arcaeaoffline.helpers.ArcaeaPackageHelper import xyz.sevive.arcaeaoffline.helpers.ArcaeaResourcesStateHolder import xyz.sevive.arcaeaoffline.helpers.context.copyToCache +import java.io.FileInputStream import java.io.InputStream import java.io.OutputStream import java.nio.charset.Charset import java.nio.charset.StandardCharsets -import java.util.zip.ZipInputStream import kotlin.time.Duration.Companion.seconds import kotlin.uuid.Uuid @@ -238,39 +236,20 @@ class DatabaseManageViewModel( } private suspend fun importArcaeaApkFromSelectedTask( - zipInputStream: ZipInputStream, + zipFile: ZipFile, supplementSonglistContent: String, ) { - importLogManager.append( - LOG_TAG_IMPORT_ARCAEA_APK, - ImportLogEvent.SimpleString(R.string.database_manage_import_reading_apk), - ) - - var entry = zipInputStream.nextEntry - - var packlistFound = false - var songlistFound = false - - while (entry != null) { - if (entry.name == ArcaeaPackageHelper.APK_PACKLIST_FILE_ENTRY_NAME) { - packlistFound = true - importPacklistTask(zipInputStream.readText()) + zipFile.getEntry(ArcaeaPackageHelper.APK_PACKLIST_FILE_ENTRY_NAME)?.let { packlistEntry -> + zipFile.getInputStream(packlistEntry).use { + importPacklistTask(it.readText()) } + } ?: importLogManager.append(LOG_TAG_IMPORT_ARCAEA_APK, ImportLogEvent.Raw("packlist not found!")) - if (entry.name == ArcaeaPackageHelper.APK_SONGLIST_FILE_ENTRY_NAME) { - songlistFound = true - importSonglistTask(zipInputStream.readText(), supplementSonglistContent) + zipFile.getEntry(ArcaeaPackageHelper.APK_SONGLIST_FILE_ENTRY_NAME)?.let { songlistEntry -> + zipFile.getInputStream(songlistEntry).use { + importSonglistTask(it.readText(), supplementSonglistContent) } - - entry = zipInputStream.nextEntry - } - - if (!packlistFound) { - importLogManager.append(LOG_TAG_IMPORT_ARCAEA_APK, ImportLogEvent.Raw("packlist not found!")) - } - if (!songlistFound) { - importLogManager.append(LOG_TAG_IMPORT_ARCAEA_APK, ImportLogEvent.Raw("songlist not found!")) - } + } ?: importLogManager.append(LOG_TAG_IMPORT_ARCAEA_APK, ImportLogEvent.Raw("songlist not found!")) } fun importArcaeaApkFromSelected( @@ -280,10 +259,21 @@ class DatabaseManageViewModel( viewModelScope.launch(Dispatchers.IO) { sendTask { val supplementSonglistContent = context.assets.open("songlist.json").use { it.readText() } - PlatformFile(uri).source().buffered().asInputStream().use { inputStream -> - ZipInputStream(inputStream).use { zis -> - // TODO: change to work manager task, weird `java.io.IOException: Stream closed` inspected - importArcaeaApkFromSelectedTask(zis, supplementSonglistContent) + + importLogManager.append( + LOG_TAG_IMPORT_ARCAEA_APK, + ImportLogEvent.SimpleString(R.string.database_manage_import_reading_apk), + ) + + context.contentResolver.openFileDescriptor(uri, "r").use { pfd -> + val fd = pfd?.fileDescriptor ?: return@sendTask + + FileInputStream(fd).use { fis -> + val fileChannel = fis.channel + + ZipFile.builder().setSeekableByteChannel(fileChannel).get().use { zipFile -> + importArcaeaApkFromSelectedTask(zipFile, supplementSonglistContent) + } } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9dcb482d..c2a13555 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ koin = "4.2.1" koin-plugin = "1.0.1" opencv = "4.13.0" +apache-commons-compress = "1.28.0" io-sentry-sentryAndroid = "8.17.0" @@ -18,12 +19,11 @@ markdown-renderer = "0.41.0" github-jvziyaoyao-scale = "1.1.1-beta.3" github-cheonjaeung-gridlayout = "2.7.4" aboutlibraries = "14.2.1" - kermit = "2.1.0" - filekit = "0.14.2" kotlinx-io = "0.9.0" bignum = "0.3.10" + junit = "4.13.2" robolectric = "4.16.1" @@ -44,6 +44,7 @@ koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose" } koin-androidx-workmanager = { module = "io.insert-koin:koin-androidx-workmanager" } opencv = { module = "org.opencv:opencv", version.ref = "opencv" } +apache-commons-compress = { module = "org.apache.commons:commons-compress", version.ref = "apache-commons-compress" } io-sentry-sentryAndroid = { module = "io.sentry:sentry-android", version.ref = "io-sentry-sentryAndroid" } @@ -56,9 +57,7 @@ github-jvziyaoyao-scale-imageViewer = { module = "com.jvziyaoyao.scale:image-vie github-cheonjaeung-gridlayout = { module = "com.cheonjaeung.compose.grid:grid", version.ref = "github-cheonjaeung-gridlayout" } aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutlibraries" } aboutlibraries-compose-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "aboutlibraries" } - kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } - filekit-core = { module = "io.github.vinceglb:filekit-core", version.ref = "filekit" } filekit-dialogs-compose = { module = "io.github.vinceglb:filekit-dialogs-compose", version.ref = "filekit" } kotlinx-io = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinx-io" } From c7603841e86102aaaf577d5e8537af4e550521d2 Mon Sep 17 00:00:00 2001 From: 283375 Date: Thu, 25 Jun 2026 06:11:45 +0800 Subject: [PATCH 09/17] replace `androidx.exifinterface` with `Kim` --- app/build.gradle.kts | 3 +- .../arcaeaoffline/helpers/DeviceOcrHelper.kt | 29 ++++++++----------- .../helpers/DeviceOcrHelperTest.kt | 20 +------------ gradle/androidx.versions.toml | 3 -- gradle/libs.versions.toml | 2 ++ 5 files changed, 16 insertions(+), 41 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f7bedae7..1b6e6452 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -178,8 +178,6 @@ dependencies { implementation(androidx.lifecycle.runtime.compose) implementation(androidx.lifecycle.viewmodel.compose) - implementation(androidx.exifinterface) - implementation(androidx.navigation.compose) implementation(androidx.work.workRuntime) @@ -188,6 +186,7 @@ dependencies { // 3rd party implementation(libs.kermit) implementation(libs.bignum) + implementation(libs.kim) implementation(libs.kotlinx.serialization) implementation(libs.ktoml.core) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/DeviceOcrHelper.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/DeviceOcrHelper.kt index 6cbb6237..a8985152 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/DeviceOcrHelper.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/DeviceOcrHelper.kt @@ -3,9 +3,13 @@ package xyz.sevive.arcaeaoffline.helpers import ai.onnxruntime.OrtSession import android.content.Context import android.net.Uri -import androidx.exifinterface.media.ExifInterface +import de.stefan_oltmann.kim.Kim +import de.stefan_oltmann.kim.format.tiff.constant.ExifTag +import de.stefan_oltmann.kim.input.ByteReader +import de.stefan_oltmann.kim.input.KotlinIoSourceByteReader import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.readBytes +import io.github.vinceglb.filekit.size import io.github.vinceglb.filekit.source import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone @@ -14,7 +18,6 @@ import kotlinx.datetime.asTimeZone import kotlinx.datetime.format.char import kotlinx.datetime.parseOrNull import kotlinx.datetime.toInstant -import kotlinx.io.asInputStream import kotlinx.io.buffered import org.opencv.core.MatOfByte import org.opencv.imgcodecs.Imgcodecs @@ -34,7 +37,6 @@ import xyz.sevive.arcaeaoffline.core.ocr.device.rois.masker.DeviceRoisMaskerAuto import xyz.sevive.arcaeaoffline.core.ocr.device.rois.masker.DeviceRoisMaskerAutoT2 import xyz.sevive.arcaeaoffline.core.ocr.device.toPlayResult import xyz.sevive.arcaeaoffline.helpers.context.getFilename -import java.io.InputStream import kotlin.time.Instant object DeviceOcrHelper { @@ -97,21 +99,18 @@ object DeviceOcrHelper { } fun readImageDateFromExif( - imageInputStream: InputStream, - fallbackDate: Instant? = null, + byteReader: ByteReader, overrideDate: Instant? = null, defaultTimeZoneProvider: () -> TimeZone = { TimeZone.currentSystemDefault() }, ): Instant? { if (overrideDate != null) return overrideDate - val imgExif = ExifInterface(imageInputStream) - val imgExifDateTimeOriginal = - imgExif.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL) ?: return fallbackDate - - val localDateTime = exifTagLocalDateTimeFormat.parseOrNull(imgExifDateTimeOriginal) ?: return null + val metadata = Kim.readMetadata(byteReader) ?: return null + val dateTimeOriginal = metadata.findStringValue(ExifTag.EXIF_TAG_DATE_TIME_ORIGINAL) ?: return null + val localDateTime = exifTagLocalDateTimeFormat.parseOrNull(dateTimeOriginal) ?: return null val exifTimeZone = - imgExif.getAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL)?.let { + metadata.findStringValue(ExifTag.EXIF_TAG_OFFSET_TIME_ORIGINAL)?.let { val zoneOffset = UtcOffset.parseOrNull(it) zoneOffset?.asTimeZone() } @@ -121,26 +120,22 @@ object DeviceOcrHelper { fun readImageDateFromExif( imageFile: PlatformFile, - fallbackDate: Instant? = null, overrideDate: Instant? = null, defaultTimeZoneProvider: () -> TimeZone = { TimeZone.currentSystemDefault() }, ): Instant? = readImageDateFromExif( - imageInputStream = imageFile.source().buffered().asInputStream(), - fallbackDate = fallbackDate, + byteReader = KotlinIoSourceByteReader(imageFile.source().buffered(), imageFile.size()), overrideDate = overrideDate, defaultTimeZoneProvider = defaultTimeZoneProvider, ) fun readImageDateFromExif( imageUri: Uri, - fallbackDate: Instant? = null, overrideDate: Instant? = null, defaultTimeZoneProvider: () -> TimeZone = { TimeZone.currentSystemDefault() }, ): Instant? = readImageDateFromExif( imageFile = PlatformFile(imageUri), - fallbackDate = fallbackDate, overrideDate = overrideDate, defaultTimeZoneProvider = defaultTimeZoneProvider, ) @@ -156,7 +151,7 @@ object DeviceOcrHelper { val arcaeaPartnerModifiers = customArcaeaPartnerModifiers ?: ArcaeaPartnerModifiers(context.assets) - val date = readImageDateFromExif(imageUri, fallbackDate, overrideDate) + val date = readImageDateFromExif(imageUri, overrideDate) ?: fallbackDate val imgFilename = context.getFilename(imageUri) return ocrResult.toPlayResult( diff --git a/app/src/test/java/xyz/sevive/arcaeaoffline/helpers/DeviceOcrHelperTest.kt b/app/src/test/java/xyz/sevive/arcaeaoffline/helpers/DeviceOcrHelperTest.kt index 44c2c591..c9f84e17 100644 --- a/app/src/test/java/xyz/sevive/arcaeaoffline/helpers/DeviceOcrHelperTest.kt +++ b/app/src/test/java/xyz/sevive/arcaeaoffline/helpers/DeviceOcrHelperTest.kt @@ -7,14 +7,9 @@ import okio.IOException import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner import xyz.sevive.arcaeaoffline.TestUtils import kotlin.time.Instant -// The helper is using androidx dependency, consider migrating to kmp dependency -// and remove RobolectricTestRunner -@RunWith(RobolectricTestRunner::class) class DeviceOcrHelperTest { @Test fun `overrideDate has highest priority`() { @@ -30,20 +25,7 @@ class DeviceOcrHelperTest { } @Test - fun `returns fallbackDate when EXIF lacks DateTimeOriginal`() { - val file = TestUtils.getResourceFile("exif-null.jpg") - - val fallback = Instant.parse("2026-01-02T03:04:05Z") - val result = - DeviceOcrHelper.readImageDateFromExif( - file, - fallbackDate = fallback, - ) - assertEquals(fallback, result) - } - - @Test - fun `returns null when no override, no fallback and no EXIF date`() { + fun `returns null when no override and no EXIF date`() { val file = TestUtils.getResourceFile("exif-null.jpg") val result = DeviceOcrHelper.readImageDateFromExif(file) diff --git a/gradle/androidx.versions.toml b/gradle/androidx.versions.toml index 3d20606d..93320ebf 100644 --- a/gradle/androidx.versions.toml +++ b/gradle/androidx.versions.toml @@ -9,7 +9,6 @@ compose-ui = "1.11.2" compose-material3 = "1.4.0" compose-material3-adaptive = "1.2.0" lifecycle = "2.10.0" -exifinterface = "1.4.2" navigation = "2.9.8" work = "2.11.2" @@ -47,8 +46,6 @@ compose-material3-adaptive-navigation = { group = "androidx.compose.material3.ad lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } -exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "exifinterface" } - navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" } work-workRuntime = { module = "androidx.work:work-runtime", version.ref = "work" } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c2a13555..3e33c813 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,6 +23,7 @@ kermit = "2.1.0" filekit = "0.14.2" kotlinx-io = "0.9.0" bignum = "0.3.10" +kim = "0.31.0" junit = "4.13.2" robolectric = "4.16.1" @@ -62,6 +63,7 @@ filekit-core = { module = "io.github.vinceglb:filekit-core", version.ref = "file filekit-dialogs-compose = { module = "io.github.vinceglb:filekit-dialogs-compose", version.ref = "filekit" } kotlinx-io = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinx-io" } bignum = { module = "com.ionspin.kotlin:bignum", version.ref = "bignum" } +kim = { module = "de.stefan-oltmann:kim", version.ref = "kim" } junit = { module = "junit:junit", version.ref = "junit" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } From a38f60f004c280871fcd65617796f3c6b7f4354d Mon Sep 17 00:00:00 2001 From: 283375 Date: Thu, 25 Jun 2026 06:42:26 +0800 Subject: [PATCH 10/17] replace java input/output stream datastore serializer with okio serializer --- .../datastore/AppDataStoreProvider.kt | 35 ++++++++++++++----- .../arcaeaoffline/datastore/AppPreferences.kt | 20 +++++------ .../datastore/EmergencyModePreferences.kt | 20 +++++------ .../datastore/OcrQueuePreferences.kt | 20 +++++------ .../datastore/UnstableFlavorPreferences.kt | 20 +++++------ 5 files changed, 67 insertions(+), 48 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/datastore/AppDataStoreProvider.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/datastore/AppDataStoreProvider.kt index e1cd7b71..966aec2b 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/datastore/AppDataStoreProvider.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/datastore/AppDataStoreProvider.kt @@ -4,8 +4,11 @@ import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.core.DataStoreFactory import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.core.okio.OkioStorage import androidx.datastore.dataStoreFile import co.touchlab.kermit.Logger +import okio.FileSystem +import okio.Path.Companion.toOkioPath object AppDataStoreProvider { private const val LOG_TAG = "AppDataStoreProvider" @@ -17,45 +20,61 @@ object AppDataStoreProvider { fun appPreferences(context: Context): DataStore = DataStoreFactory.create( - serializer = AppPreferencesSerializer, + storage = + OkioStorage( + fileSystem = FileSystem.SYSTEM, + serializer = AppPreferencesSerializer, + producePath = { context.dataStoreFile("app_preferences.toml").toOkioPath() }, + ), corruptionHandler = ReplaceFileCorruptionHandler { logCorrupt("AppPreferences") AppPreferencesSerializer.defaultValue }, - produceFile = { context.dataStoreFile("app_preferences.toml") }, ) fun emergencyModePreferences(context: Context): DataStore = DataStoreFactory.create( - serializer = EmergencyModePreferencesSerializer, + storage = + OkioStorage( + fileSystem = FileSystem.SYSTEM, + serializer = EmergencyModePreferencesSerializer, + producePath = { context.dataStoreFile("emergency_mode_preferences.toml").toOkioPath() }, + ), corruptionHandler = ReplaceFileCorruptionHandler { logCorrupt("EmergencyModePreferences") EmergencyModePreferencesSerializer.defaultValue }, - produceFile = { context.dataStoreFile("emergency_mode_preferences.toml") }, ) fun ocrQueuePreferences(context: Context): DataStore = DataStoreFactory.create( - serializer = OcrQueuePreferencesSerializer, + storage = + OkioStorage( + fileSystem = FileSystem.SYSTEM, + serializer = OcrQueuePreferencesSerializer, + producePath = { context.dataStoreFile("ocr_queue_preferences.toml").toOkioPath() }, + ), corruptionHandler = ReplaceFileCorruptionHandler { logCorrupt("OcrQueuePreferences") OcrQueuePreferencesSerializer.defaultValue }, - produceFile = { context.dataStoreFile("ocr_queue_preferences.toml") }, ) fun unstableFlavorPreferences(context: Context): DataStore = DataStoreFactory.create( - serializer = UnstableFlavorPreferencesSerializer, + storage = + OkioStorage( + fileSystem = FileSystem.SYSTEM, + serializer = UnstableFlavorPreferencesSerializer, + producePath = { context.dataStoreFile("unstable_flavor_preferences.toml").toOkioPath() }, + ), corruptionHandler = ReplaceFileCorruptionHandler { logCorrupt("UnstableFlavorPreferences") UnstableFlavorPreferencesSerializer.defaultValue }, - produceFile = { context.dataStoreFile("unstable_flavor_preferences.toml") }, ) } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/datastore/AppPreferences.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/datastore/AppPreferences.kt index 5593b970..09596f76 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/datastore/AppPreferences.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/datastore/AppPreferences.kt @@ -2,15 +2,15 @@ package xyz.sevive.arcaeaoffline.datastore import android.content.Context import androidx.datastore.core.CorruptionException -import androidx.datastore.core.Serializer +import androidx.datastore.core.okio.OkioSerializer import com.akuleshov7.ktoml.Toml import kotlinx.coroutines.CancellationException import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString -import java.io.InputStream -import java.io.OutputStream +import okio.BufferedSink +import okio.BufferedSource @Serializable data class AppPreferences( @@ -19,24 +19,24 @@ data class AppPreferences( val autoSendCrashReports: Boolean = false, ) -object AppPreferencesSerializer : Serializer { +object AppPreferencesSerializer : OkioSerializer { override val defaultValue: AppPreferences = AppPreferences() - override suspend fun readFrom(input: InputStream): AppPreferences = + override suspend fun readFrom(source: BufferedSource): AppPreferences = try { - val tomlString = input.readBytes().decodeToString() - Toml.decodeFromString(tomlString) + Toml.decodeFromString(source.readUtf8()) } catch (e: CancellationException) { throw e } catch (exception: Exception) { throw CorruptionException("Cannot read AppPreferences from TOML file", exception) } - @Suppress("BlockingMethodInNonBlockingContext") override suspend fun writeTo( t: AppPreferences, - output: OutputStream, - ) = output.write(Toml.encodeToString(t).encodeToByteArray()) + sink: BufferedSink, + ) { + sink.writeUtf8(Toml.encodeToString(t)) + } } class AppPreferencesRepository( diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/datastore/EmergencyModePreferences.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/datastore/EmergencyModePreferences.kt index 8b5e8de2..b6f70d49 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/datastore/EmergencyModePreferences.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/datastore/EmergencyModePreferences.kt @@ -2,15 +2,15 @@ package xyz.sevive.arcaeaoffline.datastore import android.content.Context import androidx.datastore.core.CorruptionException -import androidx.datastore.core.Serializer +import androidx.datastore.core.okio.OkioSerializer import com.akuleshov7.ktoml.Toml import kotlinx.coroutines.CancellationException import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString -import java.io.InputStream -import java.io.OutputStream +import okio.BufferedSink +import okio.BufferedSource @Serializable data class EmergencyModePreferences( @@ -19,24 +19,24 @@ data class EmergencyModePreferences( val lastOutputDirectory: String? = null, ) -object EmergencyModePreferencesSerializer : Serializer { +object EmergencyModePreferencesSerializer : OkioSerializer { override val defaultValue = EmergencyModePreferences() - override suspend fun readFrom(input: InputStream): EmergencyModePreferences = + override suspend fun readFrom(source: BufferedSource): EmergencyModePreferences = try { - val tomlString = input.readBytes().decodeToString() - Toml.decodeFromString(tomlString) + Toml.decodeFromString(source.readUtf8()) } catch (e: CancellationException) { throw e } catch (exception: Exception) { throw CorruptionException("Cannot read EmergencyModePreferences from TOML file", exception) } - @Suppress("BlockingMethodInNonBlockingContext") override suspend fun writeTo( t: EmergencyModePreferences, - output: OutputStream, - ) = output.write(Toml.encodeToString(t).encodeToByteArray()) + sink: BufferedSink, + ) { + sink.writeUtf8(Toml.encodeToString(t)) + } } class EmergencyModePreferencesRepository( diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/datastore/OcrQueuePreferences.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/datastore/OcrQueuePreferences.kt index a24f8029..00c3970a 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/datastore/OcrQueuePreferences.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/datastore/OcrQueuePreferences.kt @@ -2,15 +2,15 @@ package xyz.sevive.arcaeaoffline.datastore import android.content.Context import androidx.datastore.core.CorruptionException -import androidx.datastore.core.Serializer +import androidx.datastore.core.okio.OkioSerializer import com.akuleshov7.ktoml.Toml import kotlinx.coroutines.CancellationException import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString -import java.io.InputStream -import java.io.OutputStream +import okio.BufferedSink +import okio.BufferedSource @Serializable data class OcrQueuePreferences( @@ -23,24 +23,24 @@ data class OcrQueuePreferences( val parallelCount: Int = Runtime.getRuntime().availableProcessors() / 2, ) -object OcrQueuePreferencesSerializer : Serializer { +object OcrQueuePreferencesSerializer : OkioSerializer { override val defaultValue: OcrQueuePreferences = OcrQueuePreferences() - override suspend fun readFrom(input: InputStream): OcrQueuePreferences = + override suspend fun readFrom(source: BufferedSource): OcrQueuePreferences = try { - val tomlString = input.readBytes().decodeToString() - Toml.decodeFromString(tomlString) + Toml.decodeFromString(source.readUtf8()) } catch (e: CancellationException) { throw e } catch (exception: Exception) { throw CorruptionException("Cannot read OcrQueuePreferences from TOML file", exception) } - @Suppress("BlockingMethodInNonBlockingContext") override suspend fun writeTo( t: OcrQueuePreferences, - output: OutputStream, - ) = output.write(Toml.encodeToString(t).encodeToByteArray()) + sink: BufferedSink, + ) { + sink.writeUtf8(Toml.encodeToString(t)) + } } class OcrQueuePreferencesRepository( diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/datastore/UnstableFlavorPreferences.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/datastore/UnstableFlavorPreferences.kt index 6b3b7c2c..a6e413d2 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/datastore/UnstableFlavorPreferences.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/datastore/UnstableFlavorPreferences.kt @@ -2,15 +2,15 @@ package xyz.sevive.arcaeaoffline.datastore import android.content.Context import androidx.datastore.core.CorruptionException -import androidx.datastore.core.Serializer +import androidx.datastore.core.okio.OkioSerializer import com.akuleshov7.ktoml.Toml import kotlinx.coroutines.CancellationException import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString -import java.io.InputStream -import java.io.OutputStream +import okio.BufferedSink +import okio.BufferedSource @Serializable data class UnstableFlavorPreferences( @@ -19,24 +19,24 @@ data class UnstableFlavorPreferences( val unstableAlertRead: Boolean = false, ) -object UnstableFlavorPreferencesSerializer : Serializer { +object UnstableFlavorPreferencesSerializer : OkioSerializer { override val defaultValue = UnstableFlavorPreferences() - override suspend fun readFrom(input: InputStream): UnstableFlavorPreferences = + override suspend fun readFrom(source: BufferedSource): UnstableFlavorPreferences = try { - val tomlString = input.readBytes().decodeToString() - Toml.decodeFromString(tomlString) + Toml.decodeFromString(source.readUtf8()) } catch (e: CancellationException) { throw e } catch (exception: Exception) { throw CorruptionException("Cannot read UnstableFlavorPreferences from TOML file", exception) } - @Suppress("BlockingMethodInNonBlockingContext") override suspend fun writeTo( t: UnstableFlavorPreferences, - output: OutputStream, - ) = output.write(Toml.encodeToString(t).encodeToByteArray()) + sink: BufferedSink, + ) { + sink.writeUtf8(Toml.encodeToString(t)) + } } class UnstableFlavorPreferencesRepository( From d901dbdb7f3ed3c5f656f878d8e896686b6b9c2a Mon Sep 17 00:00:00 2001 From: 283375 Date: Thu, 25 Jun 2026 21:57:50 +0800 Subject: [PATCH 11/17] rewrite ImagePreviewDialog --- app/build.gradle.kts | 10 +- .../xyz/sevive/arcaeaoffline/di/AppModule.kt | 18 +++ .../common/imagepreview/ImagePreviewDialog.kt | 83 ------------- .../imagepreview/ImagePreviewDialogImage.kt | 31 ----- .../ui/components/ImagePreviewDialog.kt | 113 ++++++++++++++++++ .../queue/tasklist/OcrQueueTaskListItem.kt | 41 +------ app/stability_config.conf | 19 +++ core/build.gradle.kts | 2 +- gradle/libs.versions.toml | 7 +- 9 files changed, 167 insertions(+), 157 deletions(-) delete mode 100644 app/src/main/java/xyz/sevive/arcaeaoffline/ui/common/imagepreview/ImagePreviewDialog.kt delete mode 100644 app/src/main/java/xyz/sevive/arcaeaoffline/ui/common/imagepreview/ImagePreviewDialogImage.kt create mode 100644 app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ImagePreviewDialog.kt create mode 100644 app/stability_config.conf diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1b6e6452..e9f5d456 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -58,9 +58,13 @@ appVersioning { } } +composeCompiler { + stabilityConfigurationFiles.add(project.layout.projectDirectory.file("stability_config.conf")) +} + android { namespace = "xyz.sevive.arcaeaoffline" - compileSdk = 36 + compileSdk = 37 defaultConfig { applicationId = "xyz.sevive.arcaeaoffline" @@ -212,7 +216,9 @@ dependencies { implementation(libs.markdown.renderer.m3) implementation(libs.markdown.renderer.android) - implementation(libs.github.jvziyaoyao.scale.imageViewer) + implementation(libs.panpf.sketch.compose) + implementation(libs.panpf.sketch.compose.koin) + implementation(libs.panpf.zoomimage.sketch.koin) implementation(libs.github.cheonjaeung.gridlayout) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/di/AppModule.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/di/AppModule.kt index aed92578..46a0c9f8 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/di/AppModule.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/di/AppModule.kt @@ -1,6 +1,9 @@ package xyz.sevive.arcaeaoffline.di import android.content.Context +import com.github.panpf.sketch.Sketch +import com.github.panpf.sketch.cache.MemoryCache +import com.github.panpf.sketch.util.Logger import org.koin.dsl.module import org.koin.plugin.module.dsl.bind import org.koin.plugin.module.dsl.create @@ -53,9 +56,24 @@ internal fun ocrQueueTaskDao(db: OcrQueueDatabase) = db.ocrQueueTaskDao() internal fun ocrQueueEnqueueBufferDao(db: OcrQueueDatabase) = db.ocrQueueEnqueueBufferDao() +val thirdPartyModule = + module { + single { + Sketch + .Builder(get()) + .apply { + logger(level = Logger.Level.Debug) + memoryCache { + MemoryCache.Builder(get()).apply { maxSizePercent(0.1) }.build() + } + }.build() + } + } + val appModule = module { includes(coreModule) + includes(thirdPartyModule) single { create(::createAppDatabase) } single { create(::ocrHistoryDao) } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/common/imagepreview/ImagePreviewDialog.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/common/imagepreview/ImagePreviewDialog.kt deleted file mode 100644 index ed97da10..00000000 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/common/imagepreview/ImagePreviewDialog.kt +++ /dev/null @@ -1,83 +0,0 @@ -package xyz.sevive.arcaeaoffline.ui.common.imagepreview - -import android.graphics.BitmapFactory -import android.widget.Toast -import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Surface -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.dimensionResource -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import com.jvziyaoyao.scale.zoomable.zoomable.rememberZoomableState -import xyz.sevive.arcaeaoffline.R -import java.io.InputStream - -@Composable -fun ImagePreviewDialog( - imageBitmap: ImageBitmap, - onDismiss: () -> Unit, - topBarContent: (@Composable ColumnScope.() -> Unit)? = null, -) { - val zoomableState = - rememberZoomableState( - Size(imageBitmap.width.toFloat(), imageBitmap.height.toFloat()), - ) - - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false), - ) { - Column(Modifier.fillMaxSize()) { - if (topBarContent != null) { - Surface( - Modifier - .fillMaxWidth() - .animateContentSize(), - ) { - Column(Modifier.padding(dimensionResource(R.dimen.card_padding))) { - topBarContent() - } - } - } - - ImagePreviewDialogImage( - imageBitmap = imageBitmap, - zoomableState = zoomableState, - onDismiss = onDismiss, - modifier = Modifier.fillMaxSize(), - ) - } - } -} - -@Composable -fun ImagePreviewDialog( - inputStream: InputStream?, - onDismiss: () -> Unit, - topBarContent: (@Composable ColumnScope.() -> Unit)? = null, -) { - val context = LocalContext.current - - if (inputStream == null) { - Toast.makeText(context, "Cannot preview image", Toast.LENGTH_LONG).show() - onDismiss() - return - } - val bitmap = inputStream.use { BitmapFactory.decodeStream(inputStream) } - - ImagePreviewDialog( - imageBitmap = bitmap.asImageBitmap(), - onDismiss = onDismiss, - topBarContent = topBarContent, - ) -} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/common/imagepreview/ImagePreviewDialogImage.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/common/imagepreview/ImagePreviewDialogImage.kt deleted file mode 100644 index d2857e1c..00000000 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/common/imagepreview/ImagePreviewDialogImage.kt +++ /dev/null @@ -1,31 +0,0 @@ -package xyz.sevive.arcaeaoffline.ui.common.imagepreview - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ImageBitmap -import com.jvziyaoyao.scale.image.viewer.ImageViewer -import com.jvziyaoyao.scale.zoomable.zoomable.ZoomableGestureScope -import com.jvziyaoyao.scale.zoomable.zoomable.ZoomableViewState -import kotlinx.coroutines.launch - -@Composable -fun ImagePreviewDialogImage( - imageBitmap: ImageBitmap, - zoomableState: ZoomableViewState, - onDismiss: () -> Unit, - modifier: Modifier = Modifier, -) { - val coroutineScope = rememberCoroutineScope() - - ImageViewer( - state = zoomableState, - model = imageBitmap, - modifier = modifier, - detectGesture = - ZoomableGestureScope( - onTap = { onDismiss() }, - onDoubleTap = { coroutineScope.launch { zoomableState.toggleScale(it) } }, - ), - ) -} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ImagePreviewDialog.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ImagePreviewDialog.kt new file mode 100644 index 00000000..c3e10b26 --- /dev/null +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ImagePreviewDialog.kt @@ -0,0 +1,113 @@ +package xyz.sevive.arcaeaoffline.ui.components + +import android.net.Uri +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeContent +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.github.panpf.zoomimage.SketchZoomAsyncImage +import xyz.sevive.arcaeaoffline.R +import xyz.sevive.arcaeaoffline.helpers.context.getFilename + +@Composable +private fun TopFileInfoCard( + uri: Uri, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val filename = remember(uri.toString()) { context.getFilename(uri) ?: "-" } + var toggle by rememberSaveable { mutableStateOf(true) } + + Card( + onClick = { toggle = !toggle }, + modifier = modifier, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer.copy(0.5f)), + ) { + Text( + if (toggle) filename else uri.toString(), + Modifier.padding(dimensionResource(R.dimen.page_padding)), + style = MaterialTheme.typography.labelLarge, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ImagePreviewDialogFullscreen( + onDismissRequest: () -> Unit, + uri: Uri, + modifier: Modifier = Modifier, + properties: DialogProperties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false), +) { + Dialog(onDismissRequest, properties = properties) { + Scaffold( + modifier, + contentWindowInsets = WindowInsets.safeContent, + containerColor = MaterialTheme.colorScheme.background.copy(0.3f), + topBar = { + TopFileInfoCard( + uri, + Modifier + .windowInsetsPadding(WindowInsets.statusBars) + .fillMaxWidth(), + ) + }, + bottomBar = { + Box( + Modifier + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(bottom = 28.dp) + .fillMaxWidth(), + ) { + FilledTonalIconButton( + onClick = onDismissRequest, + Modifier + .align(Alignment.Center) + .size(56.dp), + ) { + Icon(Icons.Default.Close, contentDescription = null, Modifier.size(28.dp)) + } + } + }, + ) { innerPadding -> + SketchZoomAsyncImage( + uri = uri.toString(), + contentDescription = null, + modifier = + Modifier + .consumeWindowInsets(innerPadding) + .fillMaxSize(), + ) + } + } +} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/tasklist/OcrQueueTaskListItem.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/tasklist/OcrQueueTaskListItem.kt index 39a03209..bb4d0450 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/tasklist/OcrQueueTaskListItem.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/tasklist/OcrQueueTaskListItem.kt @@ -1,58 +1,23 @@ package xyz.sevive.arcaeaoffline.ui.screens.ocr.queue.tasklist -import android.net.Uri -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.OutlinedCard -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.dimensionResource -import io.github.vinceglb.filekit.PlatformFile -import io.github.vinceglb.filekit.source -import kotlinx.io.asInputStream -import kotlinx.io.buffered import xyz.sevive.arcaeaoffline.R import xyz.sevive.arcaeaoffline.core.database.entities.Chart import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult -import xyz.sevive.arcaeaoffline.helpers.context.getFilename -import xyz.sevive.arcaeaoffline.ui.common.imagepreview.ImagePreviewDialog import xyz.sevive.arcaeaoffline.ui.components.ArcaeaChartSelector import xyz.sevive.arcaeaoffline.ui.components.ArcaeaPlayResultEditorDialog import xyz.sevive.arcaeaoffline.ui.components.BasicAlertDialogSurface +import xyz.sevive.arcaeaoffline.ui.components.ImagePreviewDialogFullscreen import xyz.sevive.arcaeaoffline.ui.screens.ocr.queue.OcrQueueScreenViewModel -@Composable -private fun OcrQueueTaskListItemImagePreviewDialog( - fileUri: Uri, - onDismissRequest: () -> Unit, -) { - val context = LocalContext.current - - val filename = context.getFilename(fileUri) ?: "-" - var showFileUri by rememberSaveable { mutableStateOf(false) } - val displayText = if (showFileUri) fileUri.toString() else filename - - ImagePreviewDialog( - inputStream = PlatformFile(fileUri).source().buffered().asInputStream(), - onDismiss = onDismissRequest, - topBarContent = { - Text( - displayText, - Modifier - .fillMaxWidth() - .clickable { showFileUri = !showFileUri }, - ) - }, - ) -} - @Composable internal fun OcrQueueTaskListItem( uiItem: OcrQueueScreenViewModel.TaskUiItem, @@ -63,9 +28,9 @@ internal fun OcrQueueTaskListItem( ) { var showImagePreview by rememberSaveable { mutableStateOf(false) } if (showImagePreview) { - OcrQueueTaskListItemImagePreviewDialog( - uiItem.dbItem.fileUri, + ImagePreviewDialogFullscreen( onDismissRequest = { showImagePreview = false }, + uri = uiItem.dbItem.fileUri, ) } diff --git a/app/stability_config.conf b/app/stability_config.conf new file mode 100644 index 00000000..cd50b45f --- /dev/null +++ b/app/stability_config.conf @@ -0,0 +1,19 @@ +com.github.panpf.sketch.Sketch +com.github.panpf.sketch.PlatformContext +com.github.panpf.sketch.drawable.EquitableDrawable +com.github.panpf.sketch.request.Image +com.github.panpf.sketch.request.ImageOptions +com.github.panpf.sketch.request.ImageRequest +com.github.panpf.sketch.request.ImageResult +com.github.panpf.sketch.state.ColorDrawableStateImage +com.github.panpf.sketch.state.ConditionStateImage +com.github.panpf.sketch.state.CurrentStateImage +com.github.panpf.sketch.state.DrawableStateImage +com.github.panpf.sketch.state.MemoryCacheStateImage +com.github.panpf.sketch.state.IconAnimatableStateImage +com.github.panpf.sketch.state.IconStateImage +com.github.panpf.sketch.state.StateImage +com.github.panpf.sketch.state.ThumbnailMemoryCacheStateImage +com.github.panpf.sketch.util.ColorFetcher +com.github.panpf.sketch.util.IntColorFetcher +com.github.panpf.sketch.util.Size diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 05fcdcab..d037942c 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -22,7 +22,7 @@ kotlin { android { namespace = "xyz.sevive.arcaeaoffline.core" - compileSdk = 36 + compileSdk = 37 defaultConfig { minSdk = 24 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3e33c813..923351f7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,7 +16,8 @@ apache-commons-compress = "1.28.0" io-sentry-sentryAndroid = "8.17.0" markdown-renderer = "0.41.0" -github-jvziyaoyao-scale = "1.1.1-beta.3" +panpf-sketch = "4.5.0-beta05" +panpf-zoomimage = "1.5.0-beta02" github-cheonjaeung-gridlayout = "2.7.4" aboutlibraries = "14.2.1" kermit = "2.1.0" @@ -54,7 +55,9 @@ onnxruntime-android = { module = "com.microsoft.onnxruntime:onnxruntime-android" markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "markdown-renderer" } markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "markdown-renderer" } markdown-renderer-android = { module = "com.mikepenz:multiplatform-markdown-renderer-android", version.ref = "markdown-renderer" } -github-jvziyaoyao-scale-imageViewer = { module = "com.jvziyaoyao.scale:image-viewer", version.ref = "github-jvziyaoyao-scale" } +panpf-sketch-compose = { module = "io.github.panpf.sketch4:sketch-compose", version.ref = "panpf-sketch" } +panpf-sketch-compose-koin = { module = "io.github.panpf.sketch4:sketch-compose-koin", version.ref = "panpf-sketch" } +panpf-zoomimage-sketch-koin = { module = "io.github.panpf.zoomimage:zoomimage-compose-sketch4-koin", version.ref = "panpf-zoomimage" } github-cheonjaeung-gridlayout = { module = "com.cheonjaeung.compose.grid:grid", version.ref = "github-cheonjaeung-gridlayout" } aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutlibraries" } aboutlibraries-compose-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "aboutlibraries" } From d1322ba4590cf09f893ad5a030c1624a51054ae3 Mon Sep 17 00:00:00 2001 From: 283375 Date: Thu, 25 Jun 2026 22:15:45 +0800 Subject: [PATCH 12/17] use sketch for app icon fetching --- app/build.gradle.kts | 1 + .../helpers/ArcaeaPackageHelper.kt | 8 ------ .../ui/components/ArcaeaAppIcon.kt | 25 ++++++++----------- gradle/libs.versions.toml | 1 + 4 files changed, 13 insertions(+), 22 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e9f5d456..0c9ba255 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -218,6 +218,7 @@ dependencies { implementation(libs.panpf.sketch.compose) implementation(libs.panpf.sketch.compose.koin) + implementation(libs.panpf.sketch.extensions.appicon) implementation(libs.panpf.zoomimage.sketch.koin) implementation(libs.github.cheonjaeung.gridlayout) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPackageHelper.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPackageHelper.kt index 61f0e459..9474fa3a 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPackageHelper.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/ArcaeaPackageHelper.kt @@ -3,7 +3,6 @@ package xyz.sevive.arcaeaoffline.helpers import android.content.Context import android.content.pm.PackageInfo import android.content.pm.PackageManager -import android.graphics.drawable.Drawable import co.touchlab.kermit.Logger import io.github.vinceglb.filekit.FileKit import io.github.vinceglb.filekit.absolutePath @@ -41,13 +40,6 @@ class ArcaeaPackageHelper( null } - fun getIcon(): Drawable? = - try { - packageManager.getApplicationIcon(ARCAEA_PACKAGE_NAME) - } catch (_: PackageManager.NameNotFoundException) { - null - } - fun getApkZipFile(): ZipFile? { try { val appInfo = packageManager.getApplicationInfo(ARCAEA_PACKAGE_NAME, 0) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaAppIcon.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaAppIcon.kt index 2f0caa9b..49728519 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaAppIcon.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaAppIcon.kt @@ -1,18 +1,17 @@ package xyz.sevive.arcaeaoffline.ui.components -import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Cancel import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.core.graphics.drawable.toBitmap +import com.github.panpf.sketch.AsyncImage +import com.github.panpf.sketch.fetch.newAppIconUri import xyz.sevive.arcaeaoffline.helpers.ArcaeaPackageHelper @Composable @@ -27,18 +26,16 @@ fun ArcaeaAppIcon( iconRenderSize: Dp = 24.dp, ) { val context = LocalContext.current - val density = LocalDensity.current - val iconSizePx = remember(iconRenderSize) { with(density) { iconRenderSize.roundToPx() } } - val bitmap = - remember(iconSizePx) { - ArcaeaPackageHelper(context).getIcon()?.toBitmap(width = iconSizePx, height = iconSizePx) + val packageInfo = remember { ArcaeaPackageHelper(context).getPackageInfo() } + val appIconUri = + packageInfo?.let { + @Suppress("DEPRECATION") + newAppIconUri(packageName = it.packageName, versionCode = it.versionCode) } - if (forceDisabled) { - ArcaeaAppIconDisabledContent(modifier) + if (forceDisabled || appIconUri == null) { + ArcaeaAppIconDisabledContent(modifier.size(iconRenderSize)) } else { - bitmap?.let { - Image(bitmap.asImageBitmap(), contentDescription = null, modifier = modifier) - } ?: ArcaeaAppIconDisabledContent(modifier) + AsyncImage(appIconUri, contentDescription = null, modifier.size(iconRenderSize)) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 923351f7..b4e64dcb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -57,6 +57,7 @@ markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer- markdown-renderer-android = { module = "com.mikepenz:multiplatform-markdown-renderer-android", version.ref = "markdown-renderer" } panpf-sketch-compose = { module = "io.github.panpf.sketch4:sketch-compose", version.ref = "panpf-sketch" } panpf-sketch-compose-koin = { module = "io.github.panpf.sketch4:sketch-compose-koin", version.ref = "panpf-sketch" } +panpf-sketch-extensions-appicon = { module = "io.github.panpf.sketch4:sketch-extensions-appicon", version.ref = "panpf-sketch" } panpf-zoomimage-sketch-koin = { module = "io.github.panpf.zoomimage:zoomimage-compose-sketch4-koin", version.ref = "panpf-zoomimage" } github-cheonjaeung-gridlayout = { module = "com.cheonjaeung.compose.grid:grid", version.ref = "github-cheonjaeung-gridlayout" } aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutlibraries" } From 55b4dc36f195f02f234bc4fe7b79d4ea7e17fc65 Mon Sep 17 00:00:00 2001 From: 283375 Date: Fri, 26 Jun 2026 00:25:47 +0800 Subject: [PATCH 13/17] reduce java imports --- .../LinearProgressIndicatorWrapper.kt | 16 ++- .../ui/helpers/ArcaeaFormatters.kt | 66 ++++----- .../ui/helpers/ArcaeaFormattersTest.kt | 125 ++++++++++++++++++ .../core/ocr/device/DeviceOcrOnnxHelper.kt | 14 +- 4 files changed, 168 insertions(+), 53 deletions(-) create mode 100644 app/src/test/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormattersTest.kt 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 40dbad92..926cc74a 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 @@ -16,7 +16,9 @@ import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.em -import java.text.NumberFormat +import kotlin.math.roundToInt + +typealias PercentageFormatter = (Double) -> String @Composable private fun rememberIsIndeterminate( @@ -37,7 +39,7 @@ private fun rememberPercentage( private fun rememberProgressLabel( current: Int, total: Int, - formatter: NumberFormat, + formatter: PercentageFormatter, ): AnnotatedString { val isIndeterminate = rememberIsIndeterminate(current, total) val percentage = rememberPercentage(current, total) @@ -57,14 +59,16 @@ private fun rememberProgressLabel( buildAnnotatedString { append(current.toString()) withStyle(SpanStyle(fontSize = 0.9.em)) { append("/$total") } - append(" (${formatter.format(percentage)})") + append(" (${formatter(percentage)})") } } } } object LinearProgressIndicatorWrapperDefaults { - val formatter: NumberFormat = NumberFormat.getPercentInstance() + val formatter: PercentageFormatter = { percentage -> + "${(percentage * 100).roundToInt()}%" + } val indeterminateLabel: String? = null val determinateLabel: String? = null } @@ -76,7 +80,7 @@ fun LinearProgressIndicatorWrapper( modifier: Modifier = Modifier, indeterminateLabel: String? = LinearProgressIndicatorWrapperDefaults.indeterminateLabel, determinateLabel: String? = LinearProgressIndicatorWrapperDefaults.determinateLabel, - formatter: NumberFormat = LinearProgressIndicatorWrapperDefaults.formatter, + formatter: PercentageFormatter = LinearProgressIndicatorWrapperDefaults.formatter, ) { val isIndeterminate = rememberIsIndeterminate(current, total) val percentage = rememberPercentage(current, total) @@ -107,7 +111,7 @@ fun LinearProgressIndicatorWrapper( modifier: Modifier = Modifier, indeterminateLabel: String? = LinearProgressIndicatorWrapperDefaults.indeterminateLabel, determinateLabel: String? = LinearProgressIndicatorWrapperDefaults.determinateLabel, - formatter: NumberFormat = LinearProgressIndicatorWrapperDefaults.formatter, + formatter: PercentageFormatter = LinearProgressIndicatorWrapperDefaults.formatter, ) { LinearProgressIndicatorWrapper( current = progress?.first ?: 0, diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormatters.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormatters.kt index cfb5a6d3..84b36bda 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormatters.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormatters.kt @@ -5,15 +5,15 @@ import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.em +import com.ionspin.kotlin.bignum.decimal.DecimalMode +import com.ionspin.kotlin.bignum.decimal.RoundingMode +import com.ionspin.kotlin.bignum.decimal.toBigDecimal import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass import xyz.sevive.arcaeaoffline.core.database.entities.Chart import xyz.sevive.arcaeaoffline.core.database.entities.Difficulty -import java.math.RoundingMode -import java.text.DecimalFormat +import xyz.sevive.arcaeaoffline.ui.helpers.ArcaeaFormatters.constantToRatingClassText object ArcaeaFormatters { - private val RATING_PLUS_CONSTANTS = listOf(78, 79, 89, 97, 98, 99, 107, 108, 109) - /** * Format a score. * @@ -29,21 +29,16 @@ object ArcaeaFormatters { /** * Format the given potential to text. * If the potential is null, return "-.--" instead. - * - * @param pattern The pattern to use for the formatting, will be passed to [java.text.DecimalFormat]. */ fun potentialToText( potential: Double?, - pattern: String = "0.00", - roundingMode: RoundingMode = RoundingMode.DOWN, + decimalMode: DecimalMode = DecimalMode(roundingMode = RoundingMode.ROUND_HALF_TOWARDS_ZERO, scale = 2), ): String = - if (potential != null) { - val decimalFormat = DecimalFormat(pattern) - decimalFormat.roundingMode = roundingMode - decimalFormat.format(potential) - } else { - "-.--" - } + potential + ?.toBigDecimal() + ?.roundToDigitPositionAfterDecimalPoint(decimalMode.scale, decimalMode.roundingMode) + ?.scale(decimalMode.scale) + ?.toPlainString() ?: "-.--" /** * Format the given playResult to a level text. @@ -52,12 +47,12 @@ object ArcaeaFormatters { */ fun scoreToLevelText(score: Int): String = when { - score >= 9900000 -> "EX+" - score >= 9800000 -> "EX" - score >= 9500000 -> "AA" - score >= 9200000 -> "A" - score >= 8900000 -> "B" - score >= 8600000 -> "C" + score >= 9_900_000 -> "EX+" + score >= 9_800_000 -> "EX" + score >= 9_500_000 -> "AA" + score >= 9_200_000 -> "A" + score >= 8_900_000 -> "B" + score >= 8_600_000 -> "C" else -> "D" } @@ -67,16 +62,16 @@ object ArcaeaFormatters { * * If the constant is null, return "?" instead. */ - private fun constantToRatingClassText(constant: Int?): String { + internal fun constantToRatingClassText(constant: Int?): String { if (constant == null) return "?" - var text = (constant / 10).toString() + val base = constant / 10 + val remainder = constant % 10 - if (RATING_PLUS_CONSTANTS.contains(constant)) { - text += "+" + return buildString { + append(base) + if (base >= 7 && remainder >= 7) append('+') } - - return text } /** @@ -101,26 +96,23 @@ object ArcaeaFormatters { } } - private fun ratingText( + internal fun ratingText( ratingClass: ArcaeaRatingClass, rating: Int, ratingPlus: Boolean, constant: Int = 0, - ): String { - var text = ratingClass.toString() - text += ' ' + ) = buildString { + append(ratingClass.toString()) + append(' ') if (constant > 0) { - val decimalFormat = DecimalFormat("0.0") - text += decimalFormat.format(constant / 10.0) + append((constant.toBigDecimal() / 10.toBigDecimal()).toPlainString()) } else { - text += rating.toString() + append(rating.toString()) if (ratingPlus) { - text += '+' + append('+') } } - - return text } /** diff --git a/app/src/test/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormattersTest.kt b/app/src/test/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormattersTest.kt new file mode 100644 index 00000000..65f4b4ce --- /dev/null +++ b/app/src/test/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormattersTest.kt @@ -0,0 +1,125 @@ +package xyz.sevive.arcaeaoffline.ui.helpers + +import org.junit.Assert.assertEquals +import org.junit.Test +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass + +class ArcaeaFormattersTest { + @Test + fun scoreTest() { + assertEquals("00'000'000", ArcaeaFormatters.score(0)) + assertEquals("09'800'000", ArcaeaFormatters.score(9_800_000)) + assertEquals("10'002'221", ArcaeaFormatters.score(10_002_221)) + assertEquals("100002221", ArcaeaFormatters.score(100_002_221)) + } + + @Test + fun potentialToTextTest() { + assertEquals("0.00", ArcaeaFormatters.potentialToText(0.0)) + assertEquals("12.00", ArcaeaFormatters.potentialToText(12.0)) + assertEquals("12.05", ArcaeaFormatters.potentialToText(12.05)) + assertEquals("12.05", ArcaeaFormatters.potentialToText(12.054)) + assertEquals("12.05", ArcaeaFormatters.potentialToText(12.055)) + assertEquals("-.--", ArcaeaFormatters.potentialToText(null)) + } + + @Test + fun scoreToLevelTextTest() { + assertEquals("EX+", ArcaeaFormatters.scoreToLevelText(10_002_221)) + assertEquals("EX+", ArcaeaFormatters.scoreToLevelText(9_950_000)) + assertEquals("EX+", ArcaeaFormatters.scoreToLevelText(9_900_000)) + assertEquals("EX", ArcaeaFormatters.scoreToLevelText(9_850_000)) + assertEquals("EX", ArcaeaFormatters.scoreToLevelText(9_800_000)) + assertEquals("AA", ArcaeaFormatters.scoreToLevelText(9_700_000)) + assertEquals("AA", ArcaeaFormatters.scoreToLevelText(9_500_000)) + assertEquals("A", ArcaeaFormatters.scoreToLevelText(9_300_000)) + assertEquals("A", ArcaeaFormatters.scoreToLevelText(9_200_000)) + assertEquals("B", ArcaeaFormatters.scoreToLevelText(9_100_000)) + assertEquals("B", ArcaeaFormatters.scoreToLevelText(8_900_000)) + assertEquals("C", ArcaeaFormatters.scoreToLevelText(8_600_000)) + assertEquals("D", ArcaeaFormatters.scoreToLevelText(8_000_000)) + assertEquals("D", ArcaeaFormatters.scoreToLevelText(5_000_000)) + assertEquals("D", ArcaeaFormatters.scoreToLevelText(0)) + } + + @Test + fun constantToRatingClassTextTest() { + val ranges = + mapOf( + 10..19 to "1", + 20..29 to "2", + 30..39 to "3", + 40..49 to "4", + 50..59 to "5", + 60..69 to "6", + 70..76 to "7", + 77..79 to "7+", + 80..86 to "8", + 87..89 to "8+", + 90..96 to "9", + 97..99 to "9+", + 100..106 to "10", + 107..109 to "10+", + 110..116 to "11", + 117..119 to "11+", + 120..126 to "12", + ) + + ranges.forEach { (range, expected) -> + range.forEach { constant -> + val actual = ArcaeaFormatters.constantToRatingClassText(constant) + assertEquals( + "constant [$constant] should be converted to [$expected], but was [$actual]", + expected, + actual, + ) + } + } + } + + @Test + fun ratingTextRatingTest() { + assertEquals( + "PAST 2", + ArcaeaFormatters.ratingText(ArcaeaRatingClass.PAST, 2, false), + ) + + assertEquals( + "PRESENT 7+", + ArcaeaFormatters.ratingText(ArcaeaRatingClass.PRESENT, 7, true), + ) + + assertEquals( + "FUTURE 10", + ArcaeaFormatters.ratingText(ArcaeaRatingClass.FUTURE, 10, false), + ) + + assertEquals( + "BEYOND 11+", + ArcaeaFormatters.ratingText(ArcaeaRatingClass.BEYOND, 11, true), + ) + } + + @Test + fun ratingTextConstantTest() { + assertEquals( + "PAST 2.1", + ArcaeaFormatters.ratingText(ArcaeaRatingClass.PAST, 2, false, 21), + ) + + assertEquals( + "PRESENT 7.9", + ArcaeaFormatters.ratingText(ArcaeaRatingClass.PRESENT, 7, true, 79), + ) + + assertEquals( + "FUTURE 10.6", + ArcaeaFormatters.ratingText(ArcaeaRatingClass.FUTURE, 10, false, 106), + ) + + assertEquals( + "BEYOND 11.8", + ArcaeaFormatters.ratingText(ArcaeaRatingClass.BEYOND, 11, true, 118), + ) + } +} diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcrOnnxHelper.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcrOnnxHelper.kt index ccb1be1b..376e4734 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcrOnnxHelper.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcrOnnxHelper.kt @@ -14,7 +14,6 @@ import org.opencv.core.Mat import org.opencv.core.Size import org.opencv.imgproc.Imgproc import java.nio.ByteBuffer -import java.nio.ByteOrder import kotlin.jvm.optionals.getOrElse import kotlin.properties.Delegates @@ -69,15 +68,10 @@ object DeviceOcrOnnxHelper { * @return arrayOf(major, minor, patch) */ fun modelVersion(version: Long): List { - val buffer = ByteBuffer.allocate(Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN) - buffer.putLong(version) - buffer.flip() - - val major = buffer.short.toInt() shl 16 - val minor = buffer.short.toInt() shl 8 - val patch = buffer.int - - return listOf(major shr 16, minor shr 8, patch) + val major = ((version shr 48) and 0xFFFF).toInt() + val minor = ((version shr 32) and 0xFFFF).toInt() + val patch = (version and 0xFFFFFFFF).toInt() + return listOf(major, minor, patch) } fun modelVersionString(version: Long): String = "v" + modelVersion(version).joinToString(".") From fa8f32040c395e6bcb1117249032f191191abbd2 Mon Sep 17 00:00:00 2001 From: 283375 Date: Fri, 26 Jun 2026 00:29:58 +0800 Subject: [PATCH 14/17] refactor LinearProgressIndicatorWrapper --- .../LinearProgressIndicatorWrapper.kt | 105 +++++++----------- 1 file changed, 43 insertions(+), 62 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 926cc74a..92825965 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 @@ -1,70 +1,26 @@ package xyz.sevive.arcaeaoffline.ui.components +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.height import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.dp import kotlin.math.roundToInt typealias PercentageFormatter = (Double) -> String -@Composable -private fun rememberIsIndeterminate( - current: Int, - total: Int, -): Boolean = remember(current, total) { total < 1 || current == -1 } - -@Composable -private fun rememberPercentage( - current: Int, - total: Int, -): Double { - val isIndeterminate = rememberIsIndeterminate(current, total) - return remember(current, total) { if (isIndeterminate) 0.0 else current.toDouble() / total } -} - -@Composable -private fun rememberProgressLabel( - current: Int, - total: Int, - formatter: PercentageFormatter, -): AnnotatedString { - val isIndeterminate = rememberIsIndeterminate(current, total) - val percentage = rememberPercentage(current, total) - - val indeterminateText = - remember { - buildAnnotatedString { - append("-") - withStyle(SpanStyle(fontSize = 0.9.em)) { append("/-") } - append(" (--%)") - } - } - return remember(current, total, formatter) { - if (isIndeterminate) { - indeterminateText - } else { - buildAnnotatedString { - append(current.toString()) - withStyle(SpanStyle(fontSize = 0.9.em)) { append("/$total") } - append(" (${formatter(percentage)})") - } - } - } -} - object LinearProgressIndicatorWrapperDefaults { val formatter: PercentageFormatter = { percentage -> "${(percentage * 100).roundToInt()}%" @@ -82,25 +38,50 @@ fun LinearProgressIndicatorWrapper( determinateLabel: String? = LinearProgressIndicatorWrapperDefaults.determinateLabel, formatter: PercentageFormatter = LinearProgressIndicatorWrapperDefaults.formatter, ) { - val isIndeterminate = rememberIsIndeterminate(current, total) - val percentage = rememberPercentage(current, total) - val progressLabel = rememberProgressLabel(current, total, formatter) + val isIndeterminate = total < 1 || current == -1 + val percentage = if (isIndeterminate) 0.0f else (current.toFloat() / total.toFloat()).coerceIn(0f, 1f) - val label = - remember(isIndeterminate, indeterminateLabel, determinateLabel) { - if (isIndeterminate) indeterminateLabel else determinateLabel + val animatedProgress by animateFloatAsState( + targetValue = percentage, + label = "ProgressIndicatorAnimation", + ) + + val secondaryColor = MaterialTheme.colorScheme.onSurfaceVariant + val progressLabel = + buildAnnotatedString { + if (isIndeterminate) { + append("-") + withStyle(SpanStyle(color = secondaryColor)) { append("/- (--%)") } + } else { + append(current.toString()) + withStyle(SpanStyle(color = secondaryColor)) { + append("/$total (${formatter(percentage.toDouble())})") + } + } } - Column(modifier.width(IntrinsicSize.Min)) { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - label?.let { Text(it) } - Text(progressLabel) + val label = if (isIndeterminate) indeterminateLabel else determinateLabel + + Column(modifier = modifier) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + label?.let { + Text(text = it, style = MaterialTheme.typography.bodyMedium) + } + Text(text = progressLabel, style = MaterialTheme.typography.bodyMedium) } + Spacer(modifier = Modifier.height(4.dp)) + if (isIndeterminate) { - LinearProgressIndicator(Modifier.fillMaxWidth()) + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) } else { - LinearProgressIndicator({ percentage.toFloat() }, Modifier.fillMaxWidth()) + LinearProgressIndicator( + progress = { animatedProgress }, + modifier = Modifier.fillMaxWidth(), + ) } } } From d2c9c6b17f0b86fc53eb1d4e587b8f546b2f57e9 Mon Sep 17 00:00:00 2001 From: 283375 Date: Fri, 26 Jun 2026 00:45:23 +0800 Subject: [PATCH 15/17] fix incorrect rounding mode --- .../xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormatters.kt | 2 +- .../xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormattersTest.kt | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormatters.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormatters.kt index 84b36bda..4ad94898 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormatters.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormatters.kt @@ -32,7 +32,7 @@ object ArcaeaFormatters { */ fun potentialToText( potential: Double?, - decimalMode: DecimalMode = DecimalMode(roundingMode = RoundingMode.ROUND_HALF_TOWARDS_ZERO, scale = 2), + decimalMode: DecimalMode = DecimalMode(roundingMode = RoundingMode.TOWARDS_ZERO, scale = 2), ): String = potential ?.toBigDecimal() diff --git a/app/src/test/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormattersTest.kt b/app/src/test/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormattersTest.kt index 65f4b4ce..74337b8c 100644 --- a/app/src/test/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormattersTest.kt +++ b/app/src/test/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormattersTest.kt @@ -20,6 +20,8 @@ class ArcaeaFormattersTest { assertEquals("12.05", ArcaeaFormatters.potentialToText(12.05)) assertEquals("12.05", ArcaeaFormatters.potentialToText(12.054)) assertEquals("12.05", ArcaeaFormatters.potentialToText(12.055)) + assertEquals("12.05", ArcaeaFormatters.potentialToText(12.056)) + assertEquals("12.05", ArcaeaFormatters.potentialToText(12.057)) assertEquals("-.--", ArcaeaFormatters.potentialToText(null)) } From 0ca91c86b87ad148c299900a8caf36bf85361086 Mon Sep 17 00:00:00 2001 From: 283375 Date: Fri, 26 Jun 2026 00:51:51 +0800 Subject: [PATCH 16/17] suppress API 26 java.time warning --- .../ui/common/datetimeeditor/DateTimeEditDialog.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/common/datetimeeditor/DateTimeEditDialog.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/common/datetimeeditor/DateTimeEditDialog.kt index c00846cc..d60263bf 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/common/datetimeeditor/DateTimeEditDialog.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/common/datetimeeditor/DateTimeEditDialog.kt @@ -1,5 +1,6 @@ package xyz.sevive.arcaeaoffline.ui.common.datetimeeditor +import android.annotation.SuppressLint import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.lazy.LazyColumn @@ -111,6 +112,7 @@ internal fun DateTimeEditDialog( val minDateMillis = remember(minDate) { minDate?.atStartOfDayIn(TimeZone.UTC)?.toEpochMilliseconds() } val datePickerState = + @SuppressLint("NewApi") // coreLibraryDesugaring will handle the compatibility rememberDatePickerState( initialSelectedDate = dateTime.date.toJavaLocalDate(), selectableDates = From b8c3be8354e382e56d274f32808b88a1c4decb34 Mon Sep 17 00:00:00 2001 From: 283375 Date: Fri, 26 Jun 2026 01:02:14 +0800 Subject: [PATCH 17/17] remove unnecessary import directive --- .../java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormatters.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormatters.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormatters.kt index 4ad94898..444e6f79 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormatters.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormatters.kt @@ -11,7 +11,6 @@ import com.ionspin.kotlin.bignum.decimal.toBigDecimal import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass import xyz.sevive.arcaeaoffline.core.database.entities.Chart import xyz.sevive.arcaeaoffline.core.database.entities.Difficulty -import xyz.sevive.arcaeaoffline.ui.helpers.ArcaeaFormatters.constantToRatingClassText object ArcaeaFormatters { /**