From 12a39ebecc60bbc0edddc1c385ad5d708ee0f18e Mon Sep 17 00:00:00 2001 From: 283375 Date: Tue, 3 Nov 2026 22:51:37 +0800 Subject: [PATCH 01/19] rework ocr from share flow The share flow lagged behind the OCR queue: no validator warnings, no save confirmation, no retry after a failure, and a missing image hashes database surfaced only as a raw exception with no way into the app to fix it. The setup entry on the dependency status card deep links into the OCR dependencies screen via an initial route extra on MainActivity, seeded into the entry screen's detail pane; detail routes are entry-screen state, not nav graph destinations, so navDeepLink cannot reach them. The share screen stays unfinished in its own task so back from the app returns to it. --- .../xyz/sevive/arcaeaoffline/MainActivity.kt | 9 ++++- .../xyz/sevive/arcaeaoffline/MainScreen.kt | 27 +++++++++++++- .../helpers/OcrDependencyStatus.kt | 7 ++++ .../arcaeaoffline/ui/AdaptiveEntryScreen.kt | 6 ++- .../ocrfromshare/OcrFromShareActions.kt | 37 ++++++++++++++++++- .../OcrFromShareOcrDependencyStatusCard.kt | 34 +++++++++++++++++ .../ocrfromshare/OcrFromShareOcrResult.kt | 21 ++++++++++- .../ocrfromshare/OcrFromShareScreen.kt | 19 +++++++++- .../ocrfromshare/OcrFromShareViewModel.kt | 11 +++++- .../ui/components/SaveWithWarningsDialog.kt | 31 ++++++++++++++++ .../arcaeaoffline/ui/navigation/Main.kt | 2 + .../DatabaseAddPlayResultScreen.kt | 25 +------------ .../ui/screens/ocr/OcrEntryScreen.kt | 25 +++++++------ .../ui/screens/ocr/OcrNavEntry.kt | 10 +---- app/src/main/res/values-zh-rCN/strings.xml | 3 +- app/src/main/res/values/strings.xml | 3 +- 16 files changed, 215 insertions(+), 55 deletions(-) create mode 100644 app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/SaveWithWarningsDialog.kt diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/MainActivity.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/MainActivity.kt index aed64a16..5f3f0236 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/MainActivity.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/MainActivity.kt @@ -15,6 +15,11 @@ import org.opencv.android.OpenCVLoader import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme class MainActivity : ComponentActivity() { + companion object { + /** Extra carrying the route a fresh session should open on, e.g. an OCR sub-screen route. */ + const val EXTRA_INITIAL_ROUTE = "xyz.sevive.arcaeaoffline.extra.INITIAL_ROUTE" + } + override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) @@ -26,13 +31,15 @@ class MainActivity : ComponentActivity() { OpenCVLoader.initLocal() FileKit.init(this) + val initialRoute = intent?.getStringExtra(EXTRA_INITIAL_ROUTE) + setContent { ArcaeaOfflineTheme { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background, ) { - MainScreen() + MainScreen(initialNavigateRoute = initialRoute) } } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/MainScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/MainScreen.kt index 0dcea2e5..fcfffb97 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/MainScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/MainScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -31,13 +32,35 @@ internal fun mainNavControllerNavigateToRoute( } @Composable -fun MainScreen(modifier: Modifier = Modifier) { +fun MainScreen( + initialNavigateRoute: String? = null, + modifier: Modifier = Modifier, +) { val navController = rememberNavController() val navBackStackEntry by navController.currentBackStackEntryAsState() val currentRoute = navBackStackEntry?.destination?.route val currentRouteRoot = currentRoute?.split("/") + LaunchedEffect(initialNavigateRoute) { + val route = initialNavigateRoute ?: return@LaunchedEffect + val tabRoute = route.substringBefore('/') + if (MainScreen.entries.none { it.route == tabRoute }) return@LaunchedEffect + + // A fresh tab entry, not the restoreState helper: a restored entry would bring back + // its saved detail pane instead of the requested initial one. The start destination + // is popped (state saved for later tab switches) so back exits the task to the + // source activity instead of walking through Home. + val startRoute = navController.graph.startDestinationRoute ?: return@LaunchedEffect + navController.navigate(tabRoute) { + popUpTo(startRoute) { + saveState = true + inclusive = true + } + launchSingleTop = true + } + } + val navigationSuiteColors = NavigationSuiteDefaults.colors( navigationBarContainerColor = MaterialTheme.colorScheme.inverseOnSurface, @@ -59,6 +82,6 @@ fun MainScreen(modifier: Modifier = Modifier) { modifier = modifier, navigationSuiteColors = navigationSuiteColors, ) { - MainNavigationGraph(navController) + MainNavigationGraph(navController, initialNavigateRoute) } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatus.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatus.kt index 1f73291a..b7269bb9 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatus.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatus.kt @@ -119,3 +119,10 @@ data class CrnnModelStatusDetail( } } } + +/** + * Whether the image hashes database needs setup: absent, failed to load, or holding no hashes. + * + * Null counts mean the detail has not loaded yet, not an empty database. + */ +fun ImageHashesDatabaseStatusDetail.needsSetup(): Boolean = absence || exception != null || jacketCount == 0 || partnerIconCount == 0 diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/AdaptiveEntryScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/AdaptiveEntryScreen.kt index ddf4ad54..6c506bc9 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/AdaptiveEntryScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/AdaptiveEntryScreen.kt @@ -23,6 +23,9 @@ import xyz.sevive.arcaeaoffline.ui.navigation.LocalListDetailNavigationContext * * [listPane] and [detailPane] can read navigation operations * from [LocalListDetailNavigationContext] without threading parameters. + * + * [initialDetailRoute] seeds the detail pane for a fresh composition (e.g. deep links); + * `null` starts on the list pane alone. */ @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable @@ -30,10 +33,11 @@ fun AdaptiveEntryScreen( listPane: @Composable () -> Unit, detailPane: @Composable (route: String) -> Unit, extraPane: (@Composable (route: String) -> Unit)? = null, + initialDetailRoute: String? = null, ) { val navigator = rememberListDetailPaneScaffoldNavigator() val coroutineScope = rememberCoroutineScope() - var detailPaneRoute by rememberSaveable { mutableStateOf(null) } + var detailPaneRoute by rememberSaveable { mutableStateOf(initialDetailRoute) } var extraPaneRoute by rememberSaveable { mutableStateOf(null) } LaunchedEffect(Unit) { diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareActions.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareActions.kt index 2977aee9..aecfc92f 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareActions.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareActions.kt @@ -19,6 +19,7 @@ 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.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -27,9 +28,12 @@ import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch import xyz.sevive.arcaeaoffline.R +import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidator import xyz.sevive.arcaeaoffline.permissions.storage.SaveBitmapToGallery import xyz.sevive.arcaeaoffline.ui.common.PermissionRequiredDialog import xyz.sevive.arcaeaoffline.ui.components.IconRow +import xyz.sevive.arcaeaoffline.ui.components.SaveWithWarningsDialog +import xyz.sevive.arcaeaoffline.ui.components.rememberArcaeaChartDisplay import xyz.sevive.arcaeaoffline.ui.theme.spacing @Composable @@ -62,10 +66,34 @@ internal fun OcrFromShareActions(ocrFromShareViewModel: OcrFromShareViewModel) { val imageSaved by ocrFromShareViewModel.imageSaved.collectAsStateWithLifecycle() val scoreCached by ocrFromShareViewModel.scoreCached.collectAsStateWithLifecycle() + val display by rememberArcaeaChartDisplay(score?.songId, score?.ratingClass) + val warnings = + remember(score, display) { + score?.let { ArcaeaPlayResultValidator.validate(playResult = it, chartInfo = display?.chartInfo) }.orEmpty() + } + + var showSaveWithWarningsDialog by rememberSaveable { mutableStateOf(false) } + if (showSaveWithWarningsDialog) { + SaveWithWarningsDialog( + warnings = warnings, + onConfirm = { + showSaveWithWarningsDialog = false + coroutineScope.launch { ocrFromShareViewModel.saveScore() } + }, + onDismissRequest = { showSaveWithWarningsDialog = false }, + ) + } + Column { Row(horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm)) { Button( - onClick = { coroutineScope.launch { ocrFromShareViewModel.saveScore() } }, + onClick = { + if (warnings.isEmpty()) { + coroutineScope.launch { ocrFromShareViewModel.saveScore() } + } else { + showSaveWithWarningsDialog = true + } + }, enabled = score != null && !scoreSaved && !scoreCached, ) { IconRow { @@ -85,6 +113,13 @@ internal fun OcrFromShareActions(ocrFromShareViewModel: OcrFromShareViewModel) { } } + if (scoreSaved) { + Text( + stringResource(R.string.ocr_from_share_score_saved), + style = MaterialTheme.typography.bodyMedium, + ) + } + OutlinedButton( onClick = { if (SaveBitmapToGallery.checkPermission(context)) { diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrDependencyStatusCard.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrDependencyStatusCard.kt index 23276b97..28bfddd1 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrDependencyStatusCard.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrDependencyStatusCard.kt @@ -1,18 +1,52 @@ package xyz.sevive.arcaeaoffline.ui.activities.ocrfromshare +import android.content.Intent +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material3.Card import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import xyz.sevive.arcaeaoffline.MainActivity +import xyz.sevive.arcaeaoffline.R +import xyz.sevive.arcaeaoffline.helpers.needsSetup +import xyz.sevive.arcaeaoffline.ui.components.TextItem import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyCrnnModelStatusViewer import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyImageHashesDatabaseStatusViewer +import xyz.sevive.arcaeaoffline.ui.navigation.OcrSubScreen @Composable internal fun OcrFromShareOcrDependencyStatusCard( uiState: OcrFromShareViewModel.OcrDependencyViewersUiState, modifier: Modifier = Modifier, ) { + val context = LocalContext.current + Card(modifier) { OcrDependencyImageHashesDatabaseStatusViewer(uiState = uiState.imageHashesDatabase) OcrDependencyCrnnModelStatusViewer(uiState = uiState.crnnModel) + + if (uiState.imageHashesDatabase.statusDetail.needsSetup()) { + TextItem( + title = stringResource(R.string.ocr_guide_image_hashes_database_title), + content = stringResource(R.string.ocr_guide_image_hashes_database_content), + leadingIcon = Icons.Default.CloudDownload, + trailingIcon = Icons.AutoMirrored.Filled.ArrowForward, + onClick = { + // User should expect returning to this activity after setting up, + // so do not finish activity here. + // CLEAR_TOP tells the running instance to recreate and re-read the + // route extra for jumping. + val intent = + Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtra(MainActivity.EXTRA_INITIAL_ROUTE, OcrSubScreen.Dependencies.route) + } + context.startActivity(intent) + }, + ) + } } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrResult.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrResult.kt index ff1a122f..4237e4be 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrResult.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrResult.kt @@ -1,6 +1,7 @@ package xyz.sevive.arcaeaoffline.ui.activities.ocrfromshare import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Edit @@ -8,26 +9,37 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton 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.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import xyz.sevive.arcaeaoffline.R +import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidator import xyz.sevive.arcaeaoffline.ui.components.ArcaeaPlayResultCard import xyz.sevive.arcaeaoffline.ui.components.ArcaeaPlayResultEditorDialog import xyz.sevive.arcaeaoffline.ui.components.rememberArcaeaChartDisplay @Composable internal fun OcrFromShareOcrResult(viewModel: OcrFromShareViewModel) { + val context = LocalContext.current + val score by viewModel.playResult.collectAsStateWithLifecycle() val exception by viewModel.exception.collectAsStateWithLifecycle() val display by rememberArcaeaChartDisplay(score?.songId, score?.ratingClass) + val warnings = + remember(score, display) { + score?.let { ArcaeaPlayResultValidator.validate(playResult = it, chartInfo = display?.chartInfo) }.orEmpty() + } + var showScoreEditorDialog by rememberSaveable { mutableStateOf(false) } if (showScoreEditorDialog && score != null) { @@ -48,6 +60,7 @@ internal fun OcrFromShareOcrResult(viewModel: OcrFromShareViewModel) { modifier = Modifier.weight(1f), difficultyWithSong = display?.difficultyWithSong, chartInfo = display?.chartInfo, + warnings = warnings, ) AnimatedVisibility(visible = !scoreSaved && !scoreCached) { @@ -60,7 +73,13 @@ internal fun OcrFromShareOcrResult(viewModel: OcrFromShareViewModel) { } } } else if (exception != null) { - Text(exception.toString(), color = MaterialTheme.colorScheme.error) + Column { + Text(exception.toString(), color = MaterialTheme.colorScheme.error) + + TextButton(onClick = { viewModel.retryOcr(context) }) { + Text(stringResource(R.string.ocr_from_share_retry_ocr)) + } + } } else { Text(stringResource(R.string.ocr_from_share_waiting_result)) } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt index c107cca6..6104b999 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt @@ -26,7 +26,10 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle import xyz.sevive.arcaeaoffline.R import xyz.sevive.arcaeaoffline.ui.theme.spacing @@ -103,7 +106,13 @@ internal fun OcrFromShareScreenContentMedium( val imageBitmap by viewModel.imageBitmap.collectAsStateWithLifecycle() val ocrDependencyViewersUiState by viewModel.ocrDependencyViewersUiState.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { viewModel.reloadOcrDependencyViewersUiState(context) } + // Reload on every resume: returning from the app's dependency setup changes the status. + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(lifecycleOwner) { + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.reloadOcrDependencyViewersUiState(context) + } + } Scaffold(topBar = { OcrFromShareTopBar() }) { innerPadding -> Row( @@ -160,7 +169,13 @@ fun OcrFromShareScreenCompact( val imageBitmap by viewModel.imageBitmap.collectAsStateWithLifecycle() val ocrDependencyViewersUiState by viewModel.ocrDependencyViewersUiState.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { viewModel.reloadOcrDependencyViewersUiState(context) } + // Reload on every resume: returning from the app's dependency setup changes the status. + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(lifecycleOwner) { + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.reloadOcrDependencyViewersUiState(context) + } + } Scaffold(topBar = { OcrFromShareTopBar() }) { innerPadding -> LazyColumn( diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt index 37cb76e7..4f80bc33 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt @@ -15,7 +15,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -126,6 +125,15 @@ class OcrFromShareViewModel( private val _exception = MutableStateFlow(null) val exception = _exception.asStateFlow() + private var lastImageUri: Uri? = null + + /** Re-runs OCR on the last shared image, e.g. after the dependencies were set up. */ + fun retryOcr(context: Context) { + val imageUri = lastImageUri ?: return + _exception.value = null + viewModelScope.launch { startOcr(imageUri, context) } + } + fun setScore(playResult: PlayResult?) { _playResult.value = playResult } @@ -195,6 +203,7 @@ class OcrFromShareViewModel( context: Context, ) { logger.i { "OCR from share request: $imageUri" } + lastImageUri = imageUri val ortSession = DeviceOcrOnnxHelper.createOrtSession(context) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/SaveWithWarningsDialog.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/SaveWithWarningsDialog.kt new file mode 100644 index 00000000..8818b3eb --- /dev/null +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/SaveWithWarningsDialog.kt @@ -0,0 +1,31 @@ +package xyz.sevive.arcaeaoffline.ui.components + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import xyz.sevive.arcaeaoffline.R +import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidatorWarning +import xyz.sevive.arcaeaoffline.ui.components.dialogs.DialogConfirmButton +import xyz.sevive.arcaeaoffline.ui.components.dialogs.DialogDismissTextButton + +/** Asks for confirmation before saving a play result that raised validator warnings. */ +@Composable +fun SaveWithWarningsDialog( + warnings: List, + onConfirm: () -> Unit, + onDismissRequest: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(stringResource(R.string.play_result_save_with_warnings_title)) }, + text = { ArcaeaPlayResultValidatorWarningDetails(warnings) }, + confirmButton = { + DialogConfirmButton( + onClick = onConfirm, + customLabel = { Text(stringResource(R.string.general_save)) }, + ) + }, + dismissButton = { DialogDismissTextButton(onClick = onDismissRequest) }, + ) +} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/navigation/Main.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/navigation/Main.kt index ed682b4c..1c5cdfb8 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/navigation/Main.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/navigation/Main.kt @@ -58,6 +58,7 @@ enum class MainScreen( @Composable fun MainNavigationGraph( mainNavController: NavHostController, + initialNavigateRoute: String? = null, modifier: Modifier = Modifier, ) { NavHost( @@ -90,6 +91,7 @@ fun MainNavigationGraph( composable(MainScreen.Ocr.route) { OcrEntryScreen( + initialDetailRoute = initialNavigateRoute?.takeIf { it.startsWith(OCR_NAV_ROUTE_ROOT) }, onNavigateToResources = { // A cross-tab jump from the OCR hints, this behavior keeps OCR page on the back stack. mainNavController.navigate(MainScreen.Resources.route) { launchSingleTop = true } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/addplayresult/DatabaseAddPlayResultScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/addplayresult/DatabaseAddPlayResultScreen.kt index 982490c5..c3b1f32b 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/addplayresult/DatabaseAddPlayResultScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/addplayresult/DatabaseAddPlayResultScreen.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Warning -import androidx.compose.material3.AlertDialog import androidx.compose.material3.BottomAppBarDefaults import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -51,7 +50,6 @@ import xyz.sevive.arcaeaoffline.core.database.entities.playRating import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidatorWarning import xyz.sevive.arcaeaoffline.helpers.secondaryItemAlpha import xyz.sevive.arcaeaoffline.ui.SubScreenContainer -import xyz.sevive.arcaeaoffline.ui.components.ArcaeaPlayResultValidatorWarningDetails import xyz.sevive.arcaeaoffline.ui.components.ArcaeaPlayResultValidatorWarningDetailsDialog import xyz.sevive.arcaeaoffline.ui.components.LabeledCheckbox import xyz.sevive.arcaeaoffline.ui.components.PlayResultEditorClearTypeField @@ -62,12 +60,11 @@ import xyz.sevive.arcaeaoffline.ui.components.PlayResultEditorLostField import xyz.sevive.arcaeaoffline.ui.components.PlayResultEditorMaxRecallField import xyz.sevive.arcaeaoffline.ui.components.PlayResultEditorModifierField import xyz.sevive.arcaeaoffline.ui.components.PlayResultEditorPureField +import xyz.sevive.arcaeaoffline.ui.components.SaveWithWarningsDialog import xyz.sevive.arcaeaoffline.ui.components.SettingsGroupHeader import xyz.sevive.arcaeaoffline.ui.components.TextItem import xyz.sevive.arcaeaoffline.ui.components.arcaea.OutlinedArcaeaScoreTextField import xyz.sevive.arcaeaoffline.ui.components.arcaea.rememberArcaeaScoreTextFieldState -import xyz.sevive.arcaeaoffline.ui.components.dialogs.DialogConfirmButton -import xyz.sevive.arcaeaoffline.ui.components.dialogs.DialogDismissTextButton import xyz.sevive.arcaeaoffline.ui.helpers.ArcaeaFormatters import xyz.sevive.arcaeaoffline.ui.navigation.DatabaseSubScreen import xyz.sevive.arcaeaoffline.ui.theme.extendedColorScheme @@ -139,26 +136,6 @@ internal fun BottomActionsBar( } } -@Composable -private fun SaveWithWarningsDialog( - warnings: List, - onConfirm: () -> Unit, - onDismissRequest: () -> Unit, -) { - AlertDialog( - onDismissRequest = onDismissRequest, - title = { Text(stringResource(R.string.database_add_play_result_save_with_warnings_title)) }, - text = { ArcaeaPlayResultValidatorWarningDetails(warnings) }, - confirmButton = { - DialogConfirmButton( - onClick = onConfirm, - customLabel = { Text(stringResource(R.string.general_save)) }, - ) - }, - dismissButton = { DialogDismissTextButton(onClick = onDismissRequest) }, - ) -} - @Composable private fun PlayRatingPreviewLine( playResult: PlayResult, diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/OcrEntryScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/OcrEntryScreen.kt index 882adb2a..9f3632b4 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/OcrEntryScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/OcrEntryScreen.kt @@ -8,14 +8,17 @@ import xyz.sevive.arcaeaoffline.ui.screens.ocr.performance.OcrPerformanceScreen import xyz.sevive.arcaeaoffline.ui.screens.ocr.queue.OcrQueueScreen @Composable -fun OcrEntryScreen(onNavigateToResources: () -> Unit) = - AdaptiveEntryScreen( - listPane = { OcrNavEntry() }, - detailPane = { route -> - when (route) { - OcrSubScreen.Dependencies.route -> OcrDependenciesScreen() - OcrSubScreen.Queue.route -> OcrQueueScreen(onNavigateToResources = onNavigateToResources) - OcrSubScreen.Performance.route -> OcrPerformanceScreen() - } - }, - ) +fun OcrEntryScreen( + initialDetailRoute: String? = null, + onNavigateToResources: () -> Unit, +) = AdaptiveEntryScreen( + initialDetailRoute = initialDetailRoute, + listPane = { OcrNavEntry() }, + detailPane = { route -> + when (route) { + OcrSubScreen.Dependencies.route -> OcrDependenciesScreen() + OcrSubScreen.Queue.route -> OcrQueueScreen(onNavigateToResources = onNavigateToResources) + OcrSubScreen.Performance.route -> OcrPerformanceScreen() + } + }, +) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/OcrNavEntry.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/OcrNavEntry.kt index 6700382f..c1a5596a 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/OcrNavEntry.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/OcrNavEntry.kt @@ -23,7 +23,7 @@ import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.koin.compose.viewmodel.koinViewModel import xyz.sevive.arcaeaoffline.R -import xyz.sevive.arcaeaoffline.helpers.ImageHashesDatabaseStatusDetail +import xyz.sevive.arcaeaoffline.helpers.needsSetup import xyz.sevive.arcaeaoffline.ui.components.TextItem import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyCrnnModelStatusViewer import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyImageHashesDatabaseStatusViewer @@ -33,14 +33,6 @@ import xyz.sevive.arcaeaoffline.ui.navigation.OcrSubScreen import xyz.sevive.arcaeaoffline.ui.screens.NavEntryNavigateButton import xyz.sevive.arcaeaoffline.ui.screens.ocr.dependencies.OcrDependenciesScreenViewModel -/** - * Whether the image hashes database needs setup: absent, failed to load, or holding no hashes. - * - * Null counts mean the detail has not loaded yet, not an empty database. - */ -private fun ImageHashesDatabaseStatusDetail.needsSetup(): Boolean = - absence || exception != null || jacketCount == 0 || partnerIconCount == 0 - @OptIn(ExperimentalMaterial3Api::class) @Composable fun OcrNavEntry(modifier: Modifier = Modifier) { diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 7383162b..86e74189 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -103,6 +103,7 @@ 保存图片 暂存 成绩已保存 + 重新识别 返回上一应用 留在 %s @@ -201,7 +202,7 @@ 编辑游玩记录…… 点此选择谱面 请先选择谱面 - 仍有警告,确认保存? + 仍有警告,确认保存? 保留谱面 游玩记录已保存 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 595b439b..03b73110 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -119,6 +119,7 @@ CC BY-SA 3.0 Save Image Cache Score saved + Retry OCR Return to previous app Stay in %s @@ -223,7 +224,7 @@ CC BY-SA 3.0 Then edit your play result… Click here to select a chart. Select a chart first. - Save with warnings? + Save with warnings? Keep chart Play result saved From b86a9342a4d645b03873586dc240887f8d58c788 Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 4 Nov 2026 01:33:06 +0800 Subject: [PATCH 02/19] OcrFromShare state hoisting and rework --- .../arcaeaoffline/OcrFromShareActivity.kt | 47 +--- .../ocrfromshare/OcrFromShareActions.kt | 57 ++-- .../ocrfromshare/OcrFromShareOcrResult.kt | 213 +++++++++++++-- .../ocrfromshare/OcrFromShareScreen.kt | 23 +- .../ocrfromshare/OcrFromShareViewModel.kt | 246 +++++++++++------- app/src/main/res/values-zh-rCN/strings.xml | 5 +- app/src/main/res/values/strings.xml | 5 +- 7 files changed, 393 insertions(+), 203 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/OcrFromShareActivity.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/OcrFromShareActivity.kt index f7d6e3e7..dc702b04 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/OcrFromShareActivity.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/OcrFromShareActivity.kt @@ -1,7 +1,6 @@ package xyz.sevive.arcaeaoffline import android.content.Intent -import android.graphics.BitmapFactory import android.net.Uri import android.os.Bundle import androidx.activity.ComponentActivity @@ -11,11 +10,7 @@ import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSiz import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.core.content.IntentCompat import io.github.vinceglb.filekit.FileKit -import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.dialogs.init -import io.github.vinceglb.filekit.readBytes -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import org.koin.androidx.viewmodel.ext.android.viewModel import org.opencv.android.OpenCVLoader import xyz.sevive.arcaeaoffline.helpers.activity.getSourcePackageName @@ -36,7 +31,7 @@ class OcrFromShareActivity : ComponentActivity() { setTitle(R.string.title_activity_ocr_from_share) - viewModel.setShareSourceApp(this.getSourcePackageName(), packageManager) + viewModel.setShareSourceApp(getSourcePackageName(), packageManager) setContent { val windowSizeClass = calculateWindowSizeClass(this) @@ -59,7 +54,15 @@ class OcrFromShareActivity : ComponentActivity() { when (intent?.action) { Intent.ACTION_SEND -> { if (intent.type?.startsWith("image/") == true) { - handleSingleImageOcr() + // fix 'getParcelableExtra(String!): T?' is deprecated. Deprecated in Java + // https://stackoverflow.com/a/75824124/16484891 + // CC BY-SA 4.0 + IntentCompat + .getParcelableExtra( + intent, + Intent.EXTRA_STREAM, + Uri::class.java, + )?.let(viewModel::onImageShared) } } @@ -73,34 +76,4 @@ class OcrFromShareActivity : ComponentActivity() { else -> {} } } - - private fun handleSingleImageOcr() { - // fix 'getParcelableExtra(String!): T?' is deprecated. Deprecated in Java - // https://stackoverflow.com/a/75824124/16484891 - // CC BY-SA 4.0 - val uri = - IntentCompat.getParcelableExtra( - intent, - Intent.EXTRA_STREAM, - Uri::class.java, - ) ?: return - - // get an input stream from the Uri - // TODO: use input stream - val imageBytes = - runCatching { - runBlocking { PlatformFile(uri).readBytes() } - }.getOrElse { - viewModel.setException(Exception("Error reading image", it)) - return - } - - val imgBitmap = BitmapFactory.decodeStream(imageBytes.inputStream()) - viewModel.setBitmap(imgBitmap) - runBlocking { - launch { - viewModel.startOcr(uri, this@OcrFromShareActivity) - } - } - } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareActions.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareActions.kt index aecfc92f..e2d5e508 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareActions.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareActions.kt @@ -1,13 +1,16 @@ package xyz.sevive.arcaeaoffline.ui.activities.ocrfromshare import android.Manifest +import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Archive +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.FileDownload import androidx.compose.material.icons.filled.Save import androidx.compose.material3.Button @@ -30,10 +33,10 @@ import kotlinx.coroutines.launch import xyz.sevive.arcaeaoffline.R import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidator import xyz.sevive.arcaeaoffline.permissions.storage.SaveBitmapToGallery +import xyz.sevive.arcaeaoffline.ui.activities.ocrfromshare.OcrFromShareViewModel.OcrResultState import xyz.sevive.arcaeaoffline.ui.common.PermissionRequiredDialog import xyz.sevive.arcaeaoffline.ui.components.IconRow import xyz.sevive.arcaeaoffline.ui.components.SaveWithWarningsDialog -import xyz.sevive.arcaeaoffline.ui.components.rememberArcaeaChartDisplay import xyz.sevive.arcaeaoffline.ui.theme.spacing @Composable @@ -42,7 +45,7 @@ internal fun OcrFromShareActions(ocrFromShareViewModel: OcrFromShareViewModel) { val context = LocalContext.current - val handleSavePicture = { ocrFromShareViewModel.saveImage(context) } + val handleSavePicture = { ocrFromShareViewModel.saveImage() } val requestWriteStoragePermissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission(), @@ -61,15 +64,14 @@ internal fun OcrFromShareActions(ocrFromShareViewModel: OcrFromShareViewModel) { ) } - val score by ocrFromShareViewModel.playResult.collectAsStateWithLifecycle() - val scoreSaved by ocrFromShareViewModel.scoreSaved.collectAsStateWithLifecycle() + val resultState by ocrFromShareViewModel.ocrResultState.collectAsStateWithLifecycle() + val playResult = (resultState as? OcrResultState.Done)?.playResult val imageSaved by ocrFromShareViewModel.imageSaved.collectAsStateWithLifecycle() - val scoreCached by ocrFromShareViewModel.scoreCached.collectAsStateWithLifecycle() - - val display by rememberArcaeaChartDisplay(score?.songId, score?.ratingClass) + val saveState by ocrFromShareViewModel.playResultSaveState.collectAsStateWithLifecycle() + val display by ocrFromShareViewModel.display.collectAsStateWithLifecycle() val warnings = - remember(score, display) { - score?.let { ArcaeaPlayResultValidator.validate(playResult = it, chartInfo = display?.chartInfo) }.orEmpty() + remember(playResult, display) { + playResult?.let { ArcaeaPlayResultValidator.validate(playResult = it, chartInfo = display?.chartInfo) }.orEmpty() } var showSaveWithWarningsDialog by rememberSaveable { mutableStateOf(false) } @@ -78,7 +80,7 @@ internal fun OcrFromShareActions(ocrFromShareViewModel: OcrFromShareViewModel) { warnings = warnings, onConfirm = { showSaveWithWarningsDialog = false - coroutineScope.launch { ocrFromShareViewModel.saveScore() } + coroutineScope.launch { ocrFromShareViewModel.savePlayResult() } }, onDismissRequest = { showSaveWithWarningsDialog = false }, ) @@ -89,37 +91,42 @@ internal fun OcrFromShareActions(ocrFromShareViewModel: OcrFromShareViewModel) { Button( onClick = { if (warnings.isEmpty()) { - coroutineScope.launch { ocrFromShareViewModel.saveScore() } + coroutineScope.launch { ocrFromShareViewModel.savePlayResult() } } else { showSaveWithWarningsDialog = true } }, - enabled = score != null && !scoreSaved && !scoreCached, + enabled = playResult != null && !saveState.saved && !saveState.cached, ) { - IconRow { - Icon(Icons.Default.Save, null) - Text(stringResource(R.string.general_save)) + AnimatedContent(saveState.saved) { + if (it) { + IconRow { + Icon(Icons.Default.Check, null) + Text(stringResource(R.string.ocr_from_share_play_result_saved)) + } + } else { + IconRow { + Icon(Icons.Default.Save, null) + Text(stringResource(R.string.general_save)) + } + } } } OutlinedButton( - onClick = { coroutineScope.launch { ocrFromShareViewModel.cacheScore(context) } }, - enabled = score != null && !scoreSaved && !scoreCached, + onClick = { + // TODO: coroutineScope.launch { ocrFromShareViewModel.cachePlayResult(context) } + Toast.makeText(context, "TODO", Toast.LENGTH_SHORT).show() + }, + enabled = playResult != null && !saveState.saved && !saveState.cached, ) { IconRow { Icon(Icons.Default.Archive, null) - Text(stringResource(R.string.ocr_from_share_cache_score_button)) + Text(stringResource(R.string.ocr_from_share_cache_play_result_button)) } } } - if (scoreSaved) { - Text( - stringResource(R.string.ocr_from_share_score_saved), - style = MaterialTheme.typography.bodyMedium, - ) - } - OutlinedButton( onClick = { if (SaveBitmapToGallery.checkPermission(context)) { diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrResult.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrResult.kt index 4237e4be..a4a27711 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrResult.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrResult.kt @@ -1,16 +1,24 @@ package xyz.sevive.arcaeaoffline.ui.activities.ocrfromshare import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -18,53 +26,82 @@ 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.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import xyz.sevive.arcaeaoffline.R +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.ChartInfo +import xyz.sevive.arcaeaoffline.core.database.entities.DifficultyWithSong +import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidator +import xyz.sevive.arcaeaoffline.ui.activities.ocrfromshare.OcrFromShareViewModel.OcrResultState import xyz.sevive.arcaeaoffline.ui.components.ArcaeaPlayResultCard import xyz.sevive.arcaeaoffline.ui.components.ArcaeaPlayResultEditorDialog -import xyz.sevive.arcaeaoffline.ui.components.rememberArcaeaChartDisplay +import xyz.sevive.arcaeaoffline.ui.components.DifficultyWithSongDisplay +import xyz.sevive.arcaeaoffline.ui.components.IconRow +import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme +import xyz.sevive.arcaeaoffline.ui.theme.spacing +import kotlin.time.Instant @Composable internal fun OcrFromShareOcrResult(viewModel: OcrFromShareViewModel) { - val context = LocalContext.current + val resultState by viewModel.ocrResultState.collectAsStateWithLifecycle() + val display by viewModel.display.collectAsStateWithLifecycle() + val saveState by viewModel.playResultSaveState.collectAsStateWithLifecycle() - val score by viewModel.playResult.collectAsStateWithLifecycle() - val exception by viewModel.exception.collectAsStateWithLifecycle() - val display by rememberArcaeaChartDisplay(score?.songId, score?.ratingClass) - - val warnings = - remember(score, display) { - score?.let { ArcaeaPlayResultValidator.validate(playResult = it, chartInfo = display?.chartInfo) }.orEmpty() - } + OcrFromShareOcrResultContent( + playResult = (resultState as? OcrResultState.Done)?.playResult, + onSetPlayResult = { viewModel.setPlayResult(it) }, + display = display, + playResultSaved = saveState.saved, + playResultCached = saveState.cached, + exception = (resultState as? OcrResultState.Failed)?.exception, + onRetryOcr = { viewModel.retryOcr() }, + ) +} - var showScoreEditorDialog by rememberSaveable { mutableStateOf(false) } +@Composable +internal fun OcrFromShareOcrResultContent( + playResult: PlayResult?, + onSetPlayResult: (PlayResult) -> Unit, + display: DifficultyWithSongDisplay?, + playResultSaved: Boolean, + playResultCached: Boolean, + exception: Exception?, + onRetryOcr: () -> Unit, +) { + var showPlayResultEditorDialog by rememberSaveable { mutableStateOf(false) } - if (showScoreEditorDialog && score != null) { + if (showPlayResultEditorDialog && playResult != null) { ArcaeaPlayResultEditorDialog( - onDismiss = { showScoreEditorDialog = false }, - playResult = score!!, - onPlayResultChange = { viewModel.setScore(it) }, + onDismiss = { showPlayResultEditorDialog = false }, + playResult = playResult, + onPlayResultChange = onSetPlayResult, ) } - val scoreSaved by viewModel.scoreSaved.collectAsStateWithLifecycle() - val scoreCached by viewModel.scoreCached.collectAsStateWithLifecycle() + val warnings = + remember(playResult, display) { + playResult?.let { ArcaeaPlayResultValidator.validate(playResult = it, chartInfo = display?.chartInfo) }.orEmpty() + } - if (score != null) { + if (playResult != null) { Row(verticalAlignment = Alignment.Bottom) { ArcaeaPlayResultCard( - playResult = score!!, + playResult = playResult, modifier = Modifier.weight(1f), difficultyWithSong = display?.difficultyWithSong, chartInfo = display?.chartInfo, warnings = warnings, ) - AnimatedVisibility(visible = !scoreSaved && !scoreCached) { - IconButton(onClick = { showScoreEditorDialog = true }) { + AnimatedVisibility(visible = !playResultSaved && !playResultCached) { + IconButton(onClick = { showPlayResultEditorDialog = true }) { Icon( Icons.Default.Edit, contentDescription = stringResource(R.string.general_edit), @@ -74,13 +111,135 @@ internal fun OcrFromShareOcrResult(viewModel: OcrFromShareViewModel) { } } else if (exception != null) { Column { - Text(exception.toString(), color = MaterialTheme.colorScheme.error) + CompositionLocalProvider( + LocalContentColor provides MaterialTheme.colorScheme.error, + ) { + Text( + stringResource(R.string.ocr_from_share_error), + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + ) + Text(exception.toString()) + } - TextButton(onClick = { viewModel.retryOcr(context) }) { - Text(stringResource(R.string.ocr_from_share_retry_ocr)) + Spacer(Modifier.height(MaterialTheme.spacing.sm)) + + FilledTonalButton(onClick = onRetryOcr) { + IconRow { + Icon(Icons.Default.Refresh, contentDescription = null) + Text(stringResource(R.string.ocr_from_share_retry_ocr)) + } } } } else { - Text(stringResource(R.string.ocr_from_share_waiting_result)) + Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.xxs)) { + Text(stringResource(R.string.ocr_from_share_waiting_result)) + LinearProgressIndicator(gapSize = 0.dp) + } + } +} + +private fun previewPlayResult(): PlayResult = + PlayResult( + songId = "song_id", + ratingClass = ArcaeaRatingClass.PRESENT, + score = 10_001_230, + pure = 1284, + far = 0, + lost = 0, + maxRecall = 1234, + date = Instant.fromEpochSeconds(616), + clearType = ArcaeaPlayResultClearType.FULL_RECALL, + modifier = ArcaeaPlayResultModifier.HARD, + ) + +private fun previewDisplay(): DifficultyWithSongDisplay = + DifficultyWithSongDisplay( + difficultyWithSong = + DifficultyWithSong( + songId = "song_id", + ratingClass = ArcaeaRatingClass.PRESENT, + rating = 12, + ratingPlus = true, + title = "Test if Y", + artist = "void mian();", + ), + chartInfo = + ChartInfo( + songId = "song_id", + ratingClass = ArcaeaRatingClass.PRESENT, + constant = 129, + notes = 1234, + ), + ) + +@Preview(name = "OCR success", showBackground = true) +@Composable +private fun PreviewOcrResultWithScore() { + ArcaeaOfflineTheme { + Surface { + OcrFromShareOcrResultContent( + playResult = previewPlayResult(), + display = previewDisplay(), + exception = null, + playResultSaved = false, + playResultCached = false, + onSetPlayResult = {}, + onRetryOcr = {}, + ) + } + } +} + +@Preview(name = "OCR success & saved", showBackground = true) +@Composable +private fun PreviewOcrResultScoreSaved() { + ArcaeaOfflineTheme { + Surface { + OcrFromShareOcrResultContent( + playResult = previewPlayResult(), + display = previewDisplay(), + exception = null, + playResultSaved = true, + playResultCached = false, + onSetPlayResult = {}, + onRetryOcr = {}, + ) + } + } +} + +@Preview(name = "OCR error", showBackground = true, heightDp = 200) +@Composable +private fun PreviewOcrResultError() { + ArcaeaOfflineTheme { + Surface { + OcrFromShareOcrResultContent( + playResult = null, + display = null, + exception = IllegalStateException("Wow error"), + playResultSaved = false, + playResultCached = false, + onSetPlayResult = {}, + onRetryOcr = {}, + ) + } + } +} + +@Preview(name = "OCR waiting", showBackground = true, heightDp = 120) +@Composable +private fun PreviewOcrResultWaiting() { + ArcaeaOfflineTheme { + Surface { + OcrFromShareOcrResultContent( + playResult = null, + display = null, + exception = null, + playResultSaved = false, + playResultCached = false, + onSetPlayResult = {}, + onRetryOcr = {}, + ) + } } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt index 6104b999..13ed4a95 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt @@ -24,7 +24,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner @@ -39,25 +38,21 @@ internal fun OcrFromShareReturnToShareAppButton( ocrFromShareViewModel: OcrFromShareViewModel, modifier: Modifier = Modifier, ) { - val appName by ocrFromShareViewModel.shareSourceAppName.collectAsStateWithLifecycle() - val appIcon by ocrFromShareViewModel.shareSourceAppIcon.collectAsStateWithLifecycle() + val shareSource by ocrFromShareViewModel.shareSourceUiState.collectAsStateWithLifecycle() val iconSize = Icons.Default.Apps.defaultHeight AppIconLabelButton( onClick = { onReturnToShareApp() }, appIcon = { - if (appIcon != null) { - Image(appIcon!!, null, Modifier.size(iconSize)) + val icon = shareSource.icon + if (icon != null) { + Image(icon, null, Modifier.size(iconSize)) } else { Icon(Icons.Default.Apps, null, Modifier.size(iconSize)) } }, - appLabel = { - if (appName != null) { - Text(appName!!) - } - }, + appLabel = { shareSource.name?.let { Text(it) } }, actionText = { Text(stringResource(R.string.ocr_from_share_return_to_share_source)) }, modifier = modifier, colors = @@ -101,8 +96,6 @@ internal fun OcrFromShareScreenContentMedium( onStayInApp: () -> Unit, viewModel: OcrFromShareViewModel, ) { - val context = LocalContext.current - val imageBitmap by viewModel.imageBitmap.collectAsStateWithLifecycle() val ocrDependencyViewersUiState by viewModel.ocrDependencyViewersUiState.collectAsStateWithLifecycle() @@ -110,7 +103,7 @@ internal fun OcrFromShareScreenContentMedium( val lifecycleOwner = LocalLifecycleOwner.current LaunchedEffect(lifecycleOwner) { lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.reloadOcrDependencyViewersUiState(context) + viewModel.reloadOcrDependencyViewersUiState() } } @@ -164,8 +157,6 @@ fun OcrFromShareScreenCompact( onStayInApp: () -> Unit, viewModel: OcrFromShareViewModel, ) { - val context = LocalContext.current - val imageBitmap by viewModel.imageBitmap.collectAsStateWithLifecycle() val ocrDependencyViewersUiState by viewModel.ocrDependencyViewersUiState.collectAsStateWithLifecycle() @@ -173,7 +164,7 @@ fun OcrFromShareScreenCompact( val lifecycleOwner = LocalLifecycleOwner.current LaunchedEffect(lifecycleOwner) { lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.reloadOcrDependencyViewersUiState(context) + viewModel.reloadOcrDependencyViewersUiState() } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt index 4f80bc33..30b35920 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt @@ -3,6 +3,7 @@ package xyz.sevive.arcaeaoffline.ui.activities.ocrfromshare import android.content.Context import android.content.pm.PackageManager import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.net.Uri import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap @@ -11,10 +12,15 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import io.github.vinceglb.filekit.utils.div +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -23,6 +29,8 @@ import kotlinx.io.asOutputStream import kotlinx.io.buffered import kotlinx.io.files.SystemFileSystem import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult +import xyz.sevive.arcaeaoffline.core.database.repositories.ChartInfoRepository +import xyz.sevive.arcaeaoffline.core.database.repositories.DifficultyWithSongRepository import xyz.sevive.arcaeaoffline.core.database.repositories.PlayResultRepository import xyz.sevive.arcaeaoffline.core.ocr.ImageHashesDatabase import xyz.sevive.arcaeaoffline.core.ocr.device.DeviceOcrOnnxHelper @@ -33,19 +41,47 @@ import xyz.sevive.arcaeaoffline.helpers.DeviceOcrHelper import xyz.sevive.arcaeaoffline.helpers.OcrDependencyLoader import xyz.sevive.arcaeaoffline.helpers.OcrDependencyStatusBuilder import xyz.sevive.arcaeaoffline.permissions.storage.SaveBitmapToGallery +import xyz.sevive.arcaeaoffline.ui.components.DifficultyWithSongDisplay import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyCrnnModelStatusUiState import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyImageHashesDatabaseStatusUiState +import java.io.IOException import kotlin.time.Clock class OcrFromShareViewModel( + private val appContext: Context, private val playResultRepo: PlayResultRepository, private val ocrHistoryRepo: OcrHistoryRepository, + private val difficultyWithSongRepo: DifficultyWithSongRepository, + private val chartInfoRepo: ChartInfoRepository, ) : ViewModel() { class OcrDependencyViewersUiState( val imageHashesDatabase: OcrDependencyImageHashesDatabaseStatusUiState = OcrDependencyImageHashesDatabaseStatusUiState(), val crnnModel: OcrDependencyCrnnModelStatusUiState = OcrDependencyCrnnModelStatusUiState(), ) + /** Recognition outcome: waiting while OCR runs, failed with its error, or done with the result. */ + sealed interface OcrResultState { + data object Waiting : OcrResultState + + data class Failed( + val exception: Exception, + ) : OcrResultState + + data class Done( + val playResult: PlayResult, + ) : OcrResultState + } + + data class ShareSourceUiState( + val name: String? = null, + val icon: ImageBitmap? = null, + ) + + data class PlayResultSaveState( + val saved: Boolean = false, + val cached: Boolean = false, + ) + companion object { const val LOG_TAG = "OcrFromShareViewModel" } @@ -55,10 +91,10 @@ class OcrFromShareViewModel( private val _ocrDependencyViewersUiState = MutableStateFlow(OcrDependencyViewersUiState()) val ocrDependencyViewersUiState = _ocrDependencyViewersUiState.asStateFlow() - fun reloadOcrDependencyViewersUiState(context: Context) { + fun reloadOcrDependencyViewersUiState() { viewModelScope.launch(Dispatchers.IO) { val imageHashesDatabase = OcrDependencyStatusBuilder.imageHashesDatabase() - val crnnModel = OcrDependencyStatusBuilder.crnnModel(context) + val crnnModel = OcrDependencyStatusBuilder.crnnModel(appContext) _ocrDependencyViewersUiState.value = OcrDependencyViewersUiState( @@ -76,138 +112,156 @@ class OcrFromShareViewModel( null, ) - fun setBitmap(newBitmap: Bitmap) { - bitmap.value = newBitmap - } - private val shareSourceAppPackageName = MutableStateFlow(null) - private val _shareSourceAppName = MutableStateFlow(null) - val shareSourceAppName = _shareSourceAppName.asStateFlow() - - private val _shareSourceAppIcon = MutableStateFlow(null) - val shareSourceAppIcon = _shareSourceAppIcon.asStateFlow() + private val _shareSourceUiState = MutableStateFlow(ShareSourceUiState()) + val shareSourceUiState = _shareSourceUiState.asStateFlow() fun setShareSourceApp( packageName: String?, packageManager: PackageManager, ) { if (packageName == null) { + logger.w { "Share app packageName is null" } shareSourceAppPackageName.value = null - _shareSourceAppName.value = null - _shareSourceAppIcon.value = null + _shareSourceUiState.value = ShareSourceUiState() return } shareSourceAppPackageName.value = packageName - _shareSourceAppName.value = - packageName.let { - try { - val info = packageManager.getApplicationInfo(it, 0) - packageManager.getApplicationLabel(info).toString() - } catch (e: PackageManager.NameNotFoundException) { - it - } - } - _shareSourceAppIcon.value = - packageName.let { - try { - packageManager.getApplicationIcon(it).toBitmap().asImageBitmap() - } catch (e: PackageManager.NameNotFoundException) { - null - } - } + _shareSourceUiState.value = + ShareSourceUiState( + name = + packageName.let { + try { + val info = packageManager.getApplicationInfo(it, 0) + packageManager.getApplicationLabel(info).toString() + } catch (e: PackageManager.NameNotFoundException) { + logger.w(e) { "Failed to resolve share app name" } + it + } + }, + icon = + packageName.let { + try { + packageManager.getApplicationIcon(it).toBitmap().asImageBitmap() + } catch (e: PackageManager.NameNotFoundException) { + logger.w(e) { "Failed to resolve share app icon" } + null + } + }, + ) } - private val _playResult = MutableStateFlow(null) - val playResult = _playResult.asStateFlow() + private val _ocrResultState = MutableStateFlow(OcrResultState.Waiting) + val ocrResultState = _ocrResultState.asStateFlow() + + @OptIn(ExperimentalCoroutinesApi::class) + val display = + _ocrResultState + .flatMapLatest { state -> + val playResult = (state as? OcrResultState.Done)?.playResult + if (playResult == null) { + flowOf(null) + } else { + combine( + difficultyWithSongRepo.find(playResult.songId, playResult.ratingClass), + chartInfoRepo.find(playResult.songId, playResult.ratingClass), + ) { dws, info -> + dws?.let { DifficultyWithSongDisplay(it, info) } + } + } + }.stateIn(viewModelScope, SharingStarted.Lazily, null) + + private var sharedImageUri: Uri? = null - private val _exception = MutableStateFlow(null) - val exception = _exception.asStateFlow() + /** Single entry for Activity */ + fun onImageShared(uri: Uri) { + sharedImageUri = uri - private var lastImageUri: Uri? = null + viewModelScope.launch { + val decodedBitmap = + try { + withContext(Dispatchers.IO) { decodeBitmap(uri) } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _ocrResultState.value = OcrResultState.Failed(Exception("Error reading image", e)) + return@launch + } - /** Re-runs OCR on the last shared image, e.g. after the dependencies were set up. */ - fun retryOcr(context: Context) { - val imageUri = lastImageUri ?: return - _exception.value = null - viewModelScope.launch { startOcr(imageUri, context) } + bitmap.value = decodedBitmap + startOcr(uri) + } } - fun setScore(playResult: PlayResult?) { - _playResult.value = playResult + fun retryOcr() { + val imageUri = sharedImageUri ?: return + viewModelScope.launch { startOcr(imageUri) } } - fun setException(e: Exception) { - _exception.value = e + fun setPlayResult(playResult: PlayResult) { + _ocrResultState.value = OcrResultState.Done(playResult) } - private val _scoreSaved = MutableStateFlow(false) - val scoreSaved = _scoreSaved.asStateFlow() + private val _playResultSaveState = MutableStateFlow(PlayResultSaveState()) + val playResultSaveState = _playResultSaveState.asStateFlow() - suspend fun saveScore() { - if (playResult.value != null) { - playResultRepo.upsert(playResult.value!!) - _scoreSaved.value = true - } + suspend fun savePlayResult() { + val playResult = (_ocrResultState.value as? OcrResultState.Done)?.playResult ?: return + playResultRepo.upsert(playResult) + _playResultSaveState.value = _playResultSaveState.value.copy(saved = true) } private val _imageSaved = MutableStateFlow(false) val imageSaved = _imageSaved.asStateFlow() - fun saveImage(context: Context) { + fun saveImage() { val bitmap = bitmap.value if (bitmap != null && !imageSaved.value) { _imageSaved.value = SaveBitmapToGallery(bitmap).saveJpg( - context, + appContext, "ocr-from-share-${Clock.System.now().toEpochMilliseconds()}", ) } } - private val _scoreCached = MutableStateFlow(false) - val scoreCached = _scoreCached.asStateFlow() - - suspend fun cacheScore(context: Context) { - val score = playResult.value - if (score != null) { - val ocrHistory = - OcrHistory.fromArcaeaScore( - playResult = score, - sourcePackageName = shareSourceAppPackageName.value, - storeDate = Clock.System.now(), - ) - val id = ocrHistoryRepo.insert(ocrHistory) - - val bitmap = bitmap.value - if (bitmap != null) { - val parentDir = OcrPaths().fromShareImageCacheDir - if (!SystemFileSystem.exists(parentDir)) { - SystemFileSystem.createDirectories(parentDir) - logger.i { "Created image cache dir" } - } + @Suppress("unused") + suspend fun cachePlayResult() { + val playResult = (_ocrResultState.value as? OcrResultState.Done)?.playResult ?: return + val ocrHistory = + OcrHistory.fromArcaeaScore( + playResult = playResult, + sourcePackageName = shareSourceAppPackageName.value, + storeDate = Clock.System.now(), + ) + val id = ocrHistoryRepo.insert(ocrHistory) - val cacheImagePath = parentDir / "$id.jpg" - SystemFileSystem.sink(cacheImagePath).buffered().use { sink -> - bitmap.compress(Bitmap.CompressFormat.JPEG, 100, sink.asOutputStream()) - } + val bitmap = bitmap.value + if (bitmap != null) { + val parentDir = OcrPaths().fromShareImageCacheDir + if (!SystemFileSystem.exists(parentDir)) { + SystemFileSystem.createDirectories(parentDir) + logger.i { "Created image cache dir" } } - _scoreCached.value = true + val cacheImagePath = parentDir / "$id.jpg" + SystemFileSystem.sink(cacheImagePath).buffered().use { sink -> + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, sink.asOutputStream()) + } } - } - suspend fun startOcr( - imageUri: Uri, - context: Context, - ) { - logger.i { "OCR from share request: $imageUri" } - lastImageUri = imageUri + _playResultSaveState.value = _playResultSaveState.value.copy(cached = true) + } - val ortSession = DeviceOcrOnnxHelper.createOrtSession(context) + private suspend fun startOcr(uri: Uri) { + logger.i { "OCR from share request: $uri" } + _ocrResultState.value = OcrResultState.Waiting withContext(Dispatchers.IO) { + val ortSession = DeviceOcrOnnxHelper.createOrtSession(appContext) + try { val imageHashesSQLiteDatabase = OcrDependencyLoader.imageHashesSQLiteDatabase() @@ -216,24 +270,24 @@ class OcrFromShareViewModel( imageHashesSQLiteDatabase.use { sqliteDb -> val imageHashesDatabase = ImageHashesDatabase(sqliteDb) DeviceOcrHelper.ocrImage( - imageUri, + uri, imageHashesDatabase, ortSession = ortSession, ) } val playResult = DeviceOcrHelper.ocrResultToPlayResult( - imageUri, - context, + uri, + appContext, ocrResult, fallbackDate = Clock.System.now(), ) - _playResult.value = playResult - _exception.value = null + _ocrResultState.value = OcrResultState.Done(playResult) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { - _playResult.value = null - _exception.value = e + _ocrResultState.value = OcrResultState.Failed(e) logger.e(e) { "Error occurred during OCR" } } finally { @@ -241,4 +295,8 @@ class OcrFromShareViewModel( } } } + + private fun decodeBitmap(uri: Uri): Bitmap = + appContext.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it) } + ?: throw IOException("Decoded bitmap is null") } diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 86e74189..d43b1234 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -101,8 +101,9 @@ 等待 OCR 结果…… 保存图片 - 暂存 - 成绩已保存 + 暂存 + 记录已保存 + OCR 出错 重新识别 返回上一应用 留在 %s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 03b73110..e0b3c0b3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -117,8 +117,9 @@ CC BY-SA 3.0 Waiting for OCR… Save Image - Cache - Score saved + Cache + Play Saved + OCR Error Retry OCR Return to previous app Stay in %s From e75cce087f8edfb11eeb43a272122ee7f95d444c Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 4 Nov 2026 03:34:36 +0800 Subject: [PATCH 03/19] ocr from share save image rework --- ...tmapToGallery.kt => SaveImageToGallery.kt} | 83 +++++++------- .../ocrfromshare/OcrFromShareActions.kt | 4 +- .../ocrfromshare/OcrFromShareViewModel.kt | 105 +++++++++++++++--- 3 files changed, 129 insertions(+), 63 deletions(-) rename app/src/main/java/xyz/sevive/arcaeaoffline/permissions/storage/{SaveBitmapToGallery.kt => SaveImageToGallery.kt} (51%) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/permissions/storage/SaveBitmapToGallery.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/permissions/storage/SaveImageToGallery.kt similarity index 51% rename from app/src/main/java/xyz/sevive/arcaeaoffline/permissions/storage/SaveBitmapToGallery.kt rename to app/src/main/java/xyz/sevive/arcaeaoffline/permissions/storage/SaveImageToGallery.kt index 0e7b0ba5..4eb8da90 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/permissions/storage/SaveBitmapToGallery.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/permissions/storage/SaveImageToGallery.kt @@ -4,52 +4,46 @@ import android.Manifest import android.content.ContentValues import android.content.Context import android.content.pm.PackageManager -import android.graphics.Bitmap import android.os.Build import android.os.Environment import android.provider.MediaStore import androidx.core.content.ContextCompat import co.touchlab.kermit.Logger import java.io.File -import java.io.FileOutputStream -import kotlin.time.Clock -private fun checkSaveToGalleryPermission(context: Context): Boolean { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) return true +/** Writes image bytes into the public Pictures gallery. */ +object SaveImageToGallery { + const val DIRECTORY = "Arcaea Offline" + const val LOG_TAG = "SaveImageToGallery" - val checkPermission = - ContextCompat.checkSelfPermission( - context, - Manifest.permission.WRITE_EXTERNAL_STORAGE, - ) - return checkPermission == PackageManager.PERMISSION_GRANTED -} + private val logger = Logger.withTag(LOG_TAG) -class SaveBitmapToGallery( - private val bitmap: Bitmap, -) { - companion object { - const val DIRECTORY = "Arcaea Offline" - const val TAG = "SaveBitmapToGallery" + fun checkPermission(context: Context): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) return true - fun checkPermission(context: Context): Boolean = checkSaveToGalleryPermission(context) + val checkPermission = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.WRITE_EXTERNAL_STORAGE, + ) + return checkPermission == PackageManager.PERMISSION_GRANTED } - private val logger = Logger.withTag(TAG) - /** * https://stackoverflow.com/a/73554532/16484891 * * CC BY-SA 4.0 * @see link + * @param dateTakenMillis The image's logical date in epoch millis, for gallery apps use it for + * timeline ordering on API 29+. */ - private fun saveBitmap( + fun saveImageBytes( context: Context, - filename: String, - compressFormat: Bitmap.CompressFormat, - quality: Int, + fileName: String, + bytes: ByteArray, + dateTakenMillis: Long, ): Boolean { - logger.i { "Saving $filename to gallery" } + logger.i { "Saving $fileName to gallery" } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { if (!checkPermission(context)) return false @@ -63,18 +57,31 @@ class SaveBitmapToGallery( if (!parentDir.exists()) { logger.i { "Creating parent dir, success: ${parentDir.mkdirs()}" } } - FileOutputStream(File(parentDir, filename)).use { - bitmap.compress(compressFormat, quality, it) + + // Q+ MediaProvider renames duplicate display names itself. + // Older API would silently overwrite, so pick a free name instead. + var target = File(parentDir, fileName) + if (target.exists()) { + val dot = fileName.lastIndexOf('.') + val base = if (dot < 0) fileName else fileName.substring(0, dot) + val ext = if (dot < 0) "" else fileName.substring(dot) + + var index = 1 + while (target.exists()) { + target = File(parentDir, "$base ($index)$ext") + index++ + } } + target.outputStream().use { it.write(bytes) } } else { val values = ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, filename) - // put(MediaStore.MediaColumns.MIME_TYPE, mimeType) + put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) put( MediaStore.MediaColumns.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/$DIRECTORY", ) + put(MediaStore.Images.Media.DATE_TAKEN, dateTakenMillis) put(MediaStore.MediaColumns.IS_PENDING, 1) } @@ -82,7 +89,7 @@ class SaveBitmapToGallery( val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) uri ?.let { resolver.openOutputStream(it) } - ?.use { bitmap.compress(compressFormat, quality, it) } + ?.use { it.write(bytes) } values.clear() values.put(MediaStore.MediaColumns.IS_PENDING, 0) @@ -91,18 +98,4 @@ class SaveBitmapToGallery( return true } - - private fun getDefaultFileBaseName(): String = "arcaea-offline-${Clock.System.now().toEpochMilliseconds()}" - - fun saveJpg( - context: Context, - fileBaseName: String = getDefaultFileBaseName(), - quality: Int = 100, - ) = saveBitmap(context, "$fileBaseName.jpg", Bitmap.CompressFormat.JPEG, quality) - - fun savePng( - context: Context, - fileBaseName: String = getDefaultFileBaseName(), - quality: Int = 100, - ) = saveBitmap(context, "$fileBaseName.png", Bitmap.CompressFormat.PNG, quality) } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareActions.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareActions.kt index e2d5e508..dff0c884 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareActions.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareActions.kt @@ -32,7 +32,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch import xyz.sevive.arcaeaoffline.R import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidator -import xyz.sevive.arcaeaoffline.permissions.storage.SaveBitmapToGallery +import xyz.sevive.arcaeaoffline.permissions.storage.SaveImageToGallery import xyz.sevive.arcaeaoffline.ui.activities.ocrfromshare.OcrFromShareViewModel.OcrResultState import xyz.sevive.arcaeaoffline.ui.common.PermissionRequiredDialog import xyz.sevive.arcaeaoffline.ui.components.IconRow @@ -129,7 +129,7 @@ internal fun OcrFromShareActions(ocrFromShareViewModel: OcrFromShareViewModel) { OutlinedButton( onClick = { - if (SaveBitmapToGallery.checkPermission(context)) { + if (SaveImageToGallery.checkPermission(context)) { handleSavePicture() } else { showSavePicturePermissionRequiredDialog = true diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt index 30b35920..5d838874 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt @@ -11,6 +11,11 @@ import androidx.core.graphics.drawable.toBitmap import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger +import de.stefan_oltmann.kim.Kim +import de.stefan_oltmann.kim.common.ImageWriteException +import de.stefan_oltmann.kim.input.ByteArrayByteReader +import de.stefan_oltmann.kim.model.MediaFormat +import de.stefan_oltmann.kim.model.MetadataUpdate import io.github.vinceglb.filekit.utils.div import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -25,6 +30,10 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.format.char +import kotlinx.datetime.toLocalDateTime import kotlinx.io.asOutputStream import kotlinx.io.buffered import kotlinx.io.files.SystemFileSystem @@ -40,12 +49,14 @@ import xyz.sevive.arcaeaoffline.database.repositories.OcrHistoryRepository import xyz.sevive.arcaeaoffline.helpers.DeviceOcrHelper import xyz.sevive.arcaeaoffline.helpers.OcrDependencyLoader import xyz.sevive.arcaeaoffline.helpers.OcrDependencyStatusBuilder -import xyz.sevive.arcaeaoffline.permissions.storage.SaveBitmapToGallery +import xyz.sevive.arcaeaoffline.permissions.storage.SaveImageToGallery import xyz.sevive.arcaeaoffline.ui.components.DifficultyWithSongDisplay import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyCrnnModelStatusUiState import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyImageHashesDatabaseStatusUiState import java.io.IOException +import kotlin.concurrent.Volatile import kotlin.time.Clock +import kotlin.time.Instant class OcrFromShareViewModel( private val appContext: Context, @@ -82,6 +93,15 @@ class OcrFromShareViewModel( val cached: Boolean = false, ) + /** The image this share session is about, captured once at [onImageShared]. */ + @Suppress("ArrayInDataClass") + data class SharedImage( + val uri: Uri, + val sharedAt: Instant, + val sourceTakenAt: Instant?, + val bytes: ByteArray, + ) + companion object { const val LOG_TAG = "OcrFromShareViewModel" } @@ -173,16 +193,32 @@ class OcrFromShareViewModel( } }.stateIn(viewModelScope, SharingStarted.Lazily, null) - private var sharedImageUri: Uri? = null + private var sharedImage: SharedImage? = null /** Single entry for Activity */ fun onImageShared(uri: Uri) { - sharedImageUri = uri + val sharedAt = Clock.System.now() viewModelScope.launch { val decodedBitmap = try { - withContext(Dispatchers.IO) { decodeBitmap(uri) } + withContext(Dispatchers.IO) { + val bytes = + appContext.contentResolver.openInputStream(uri)?.use { it.readBytes() } + ?: throw IOException("Cannot open shared image stream") + + val decoded = + BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + ?: throw IOException("Decoded bitmap is null") + + val sourceTakenAt = + runCatching { + DeviceOcrHelper.readImageDateFromExif(ByteArrayByteReader(bytes)) + }.getOrNull() + + sharedImage = SharedImage(uri, sharedAt, sourceTakenAt, bytes) + decoded + } } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -196,7 +232,7 @@ class OcrFromShareViewModel( } fun retryOcr() { - val imageUri = sharedImageUri ?: return + val imageUri = sharedImage?.uri ?: return viewModelScope.launch { startOcr(imageUri) } } @@ -216,14 +252,42 @@ class OcrFromShareViewModel( private val _imageSaved = MutableStateFlow(false) val imageSaved = _imageSaved.asStateFlow() + @Volatile + private var imageSaveInFlight = false + fun saveImage() { - val bitmap = bitmap.value - if (bitmap != null && !imageSaved.value) { - _imageSaved.value = - SaveBitmapToGallery(bitmap).saveJpg( - appContext, - "ocr-from-share-${Clock.System.now().toEpochMilliseconds()}", - ) + val shared = sharedImage ?: return + if (imageSaveInFlight || imageSaved.value) return + imageSaveInFlight = true + + viewModelScope.launch(Dispatchers.IO) { + try { + val dateTaken = shared.sourceTakenAt ?: shared.sharedAt + val fileName = + "ocr-from-share-" + + imageFileDateFormat.format(dateTaken.toLocalDateTime(TimeZone.currentSystemDefault())) + + "." + imageFileExtension(shared.bytes) + + val bytes = + if (shared.sourceTakenAt != null) { + shared.bytes + } else { + try { + Kim.update( + shared.bytes, + MetadataUpdate.TakenDate(shared.sharedAt.toEpochMilliseconds()), + ) + } catch (e: ImageWriteException) { + logger.w(e) { "Writing EXIF taken date failed, saving without it" } + shared.bytes + } + } + + _imageSaved.value = + SaveImageToGallery.saveImageBytes(appContext, fileName, bytes, dateTaken.toEpochMilliseconds()) + } finally { + imageSaveInFlight = false + } } } @@ -295,8 +359,17 @@ class OcrFromShareViewModel( } } } - - private fun decodeBitmap(uri: Uri): Bitmap = - appContext.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it) } - ?: throw IOException("Decoded bitmap is null") } + +private val imageFileDateFormat = + LocalDateTime.Format { + year() + monthNumber() + day() + char('_') + hour() + minute() + second() + } + +private fun imageFileExtension(bytes: ByteArray): String = MediaFormat.detect(bytes)?.fileNameExtensions?.firstOrNull() ?: "jpg" From 2f7c3e729ad8f4ab8d22f212aba88cd2c8ed7530 Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 4 Nov 2026 23:32:34 +0800 Subject: [PATCH 04/19] rework ocr from share screen ui --- .../arcaeaoffline/OcrFromShareActivity.kt | 6 - .../ocrfromshare/AppIconLabelButton.kt | 56 ---- .../ocrfromshare/OcrFromShareActions.kt | 15 +- .../OcrFromShareOcrDependencyStatusCard.kt | 142 ++++++++-- .../ocrfromshare/OcrFromShareOcrResult.kt | 79 +++--- .../ocrfromshare/OcrFromShareScreen.kt | 264 ++++++++++++------ .../ocrfromshare/OcrFromShareViewModel.kt | 73 ++--- app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 9 files changed, 374 insertions(+), 263 deletions(-) delete mode 100644 app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/AppIconLabelButton.kt diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/OcrFromShareActivity.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/OcrFromShareActivity.kt index dc702b04..732fcfea 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/OcrFromShareActivity.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/OcrFromShareActivity.kt @@ -6,8 +6,6 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi -import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.core.content.IntentCompat import io.github.vinceglb.filekit.FileKit import io.github.vinceglb.filekit.dialogs.init @@ -21,7 +19,6 @@ import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme class OcrFromShareActivity : ComponentActivity() { private val viewModel by viewModel() - @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) @@ -33,11 +30,8 @@ class OcrFromShareActivity : ComponentActivity() { viewModel.setShareSourceApp(getSourcePackageName(), packageManager) setContent { - val windowSizeClass = calculateWindowSizeClass(this) - ArcaeaOfflineTheme { OcrFromShareScreen( - windowSizeClass, onReturnToShareApp = { finishAffinity() }, onStayInApp = { val intent = Intent(this@OcrFromShareActivity, MainActivity::class.java) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/AppIconLabelButton.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/AppIconLabelButton.kt deleted file mode 100644 index bf2cad45..00000000 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/AppIconLabelButton.kt +++ /dev/null @@ -1,56 +0,0 @@ -package xyz.sevive.arcaeaoffline.ui.activities.ocrfromshare - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonColors -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import xyz.sevive.arcaeaoffline.ui.theme.spacing - -@Composable -internal fun AppIconLabelButton( - onClick: () -> Unit, - appIcon: @Composable () -> Unit, - appLabel: @Composable () -> Unit, - modifier: Modifier = Modifier, - actionText: (@Composable () -> Unit)? = null, - colors: ButtonColors = ButtonDefaults.buttonColors(), - trailingIcon: @Composable () -> Unit = { Icon(Icons.AutoMirrored.Default.ArrowForward, null) }, -) { - Button( - onClick = onClick, - modifier = modifier, - colors = colors, - shape = RoundedCornerShape(20.dp), - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), - verticalAlignment = Alignment.CenterVertically, - ) { - appIcon() - - Column( - Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.iconTextGap), - ) { - appLabel() - - if (actionText != null) { - actionText() - } - } - - trailingIcon() - } - } -} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareActions.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareActions.kt index dff0c884..c4d51f47 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareActions.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareActions.kt @@ -141,9 +141,18 @@ internal fun OcrFromShareActions(ocrFromShareViewModel: OcrFromShareViewModel) { contentColor = MaterialTheme.colorScheme.secondary, ), ) { - IconRow { - Icon(Icons.Default.FileDownload, null) - Text(stringResource(R.string.ocr_from_share_save_image_button)) + AnimatedContent(imageSaved) { + if (it) { + IconRow { + Icon(Icons.Default.Check, null) + Text(stringResource(R.string.ocr_from_share_image_saved)) + } + } else { + IconRow { + Icon(Icons.Default.FileDownload, null) + Text(stringResource(R.string.ocr_from_share_save_image_button)) + } + } } } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrDependencyStatusCard.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrDependencyStatusCard.kt index 28bfddd1..ac25a496 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrDependencyStatusCard.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrDependencyStatusCard.kt @@ -1,52 +1,134 @@ package xyz.sevive.arcaeaoffline.ui.activities.ocrfromshare -import android.content.Intent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.Api +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.CloudDownload +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.stringResource -import xyz.sevive.arcaeaoffline.MainActivity +import androidx.compose.ui.unit.dp import xyz.sevive.arcaeaoffline.R +import xyz.sevive.arcaeaoffline.helpers.OcrDependencyStatus import xyz.sevive.arcaeaoffline.helpers.needsSetup +import xyz.sevive.arcaeaoffline.ui.components.BaseSettingsItem import xyz.sevive.arcaeaoffline.ui.components.TextItem import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyCrnnModelStatusViewer import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyImageHashesDatabaseStatusViewer -import xyz.sevive.arcaeaoffline.ui.navigation.OcrSubScreen +import xyz.sevive.arcaeaoffline.ui.theme.spacing + +private fun OcrFromShareViewModel.OcrDependencyViewersUiState.allDependenciesReady(): Boolean = + imageHashesDatabase.statusDetail.status() == OcrDependencyStatus.OK && + crnnModel.statusDetail.status() == OcrDependencyStatus.OK @Composable internal fun OcrFromShareOcrDependencyStatusCard( - uiState: OcrFromShareViewModel.OcrDependencyViewersUiState, + uiState: OcrFromShareViewModel.OcrDependencyViewersUiState?, + onNavigateToDependencies: () -> Unit, modifier: Modifier = Modifier, ) { - val context = LocalContext.current - - Card(modifier) { - OcrDependencyImageHashesDatabaseStatusViewer(uiState = uiState.imageHashesDatabase) - OcrDependencyCrnnModelStatusViewer(uiState = uiState.crnnModel) - - if (uiState.imageHashesDatabase.statusDetail.needsSetup()) { - TextItem( - title = stringResource(R.string.ocr_guide_image_hashes_database_title), - content = stringResource(R.string.ocr_guide_image_hashes_database_content), - leadingIcon = Icons.Default.CloudDownload, - trailingIcon = Icons.AutoMirrored.Filled.ArrowForward, - onClick = { - // User should expect returning to this activity after setting up, - // so do not finish activity here. - // CLEAR_TOP tells the running instance to recreate and re-read the - // route extra for jumping. - val intent = - Intent(context, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - putExtra(MainActivity.EXTRA_INITIAL_ROUTE, OcrSubScreen.Dependencies.route) - } - context.startActivity(intent) - }, - ) + val allReady = uiState?.allDependenciesReady() ?: false + var expanded by rememberSaveable(allReady) { mutableStateOf(!allReady) } + + // Smooths the loading-to-loaded swap and any later content size change. + Card(modifier.animateContentSize()) { + OcrFromShareOcrDependencyStatusCardHeader( + uiState = uiState, + expanded = expanded, + onClick = uiState?.let { { expanded = !expanded } }, + ) + + uiState?.let { + AnimatedVisibility(visible = expanded) { + Column { + OcrDependencyImageHashesDatabaseStatusViewer(uiState = it.imageHashesDatabase) + OcrDependencyCrnnModelStatusViewer(uiState = it.crnnModel) + + if (it.imageHashesDatabase.statusDetail.needsSetup()) { + TextItem( + title = stringResource(R.string.ocr_guide_image_hashes_database_title), + content = stringResource(R.string.ocr_guide_image_hashes_database_content), + leadingIcon = Icons.Default.CloudDownload, + trailingIcon = Icons.AutoMirrored.Filled.ArrowForward, + onClick = onNavigateToDependencies, + ) + } + } + } } } } + +@Composable +private fun OcrFromShareOcrDependencyStatusCardHeader( + uiState: OcrFromShareViewModel.OcrDependencyViewersUiState?, + expanded: Boolean, + onClick: (() -> Unit)?, +) { + val allReady = uiState?.allDependenciesReady() ?: false + val chevronRotation by animateFloatAsState( + targetValue = if (expanded) 0f else -90f, + label = "DependencyCardChevronRotation", + ) + + BaseSettingsItem( + title = { Text(stringResource(R.string.ocr_dependencies_title)) }, + onClick = onClick, + leadingSlot = { + Icon( + Icons.Default.Api, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingSlot = { + Row( + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + if (uiState == null) { + CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp) + } else if (allReady) { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } else { + Icon( + Icons.Default.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + } + + Icon( + Icons.Default.ExpandMore, + contentDescription = null, + modifier = Modifier.graphicsLayer { rotationZ = chevronRotation }, + ) + } + }, + ) +} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrResult.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrResult.kt index a4a27711..29801b3d 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrResult.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrResult.kt @@ -1,7 +1,9 @@ package xyz.sevive.arcaeaoffline.ui.activities.ocrfromshare import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -90,50 +92,53 @@ internal fun OcrFromShareOcrResultContent( playResult?.let { ArcaeaPlayResultValidator.validate(playResult = it, chartInfo = display?.chartInfo) }.orEmpty() } - if (playResult != null) { - Row(verticalAlignment = Alignment.Bottom) { - ArcaeaPlayResultCard( - playResult = playResult, - modifier = Modifier.weight(1f), - difficultyWithSong = display?.difficultyWithSong, - chartInfo = display?.chartInfo, - warnings = warnings, - ) + // Size changes between the waiting, error, and result branches animate instead of jumping. + Box(Modifier.animateContentSize()) { + if (playResult != null) { + Row(verticalAlignment = Alignment.Bottom) { + ArcaeaPlayResultCard( + playResult = playResult, + modifier = Modifier.weight(1f), + difficultyWithSong = display?.difficultyWithSong, + chartInfo = display?.chartInfo, + warnings = warnings, + ) - AnimatedVisibility(visible = !playResultSaved && !playResultCached) { - IconButton(onClick = { showPlayResultEditorDialog = true }) { - Icon( - Icons.Default.Edit, - contentDescription = stringResource(R.string.general_edit), - ) + AnimatedVisibility(visible = !playResultSaved && !playResultCached) { + IconButton(onClick = { showPlayResultEditorDialog = true }) { + Icon( + Icons.Default.Edit, + contentDescription = stringResource(R.string.general_edit), + ) + } } } - } - } else if (exception != null) { - Column { - CompositionLocalProvider( - LocalContentColor provides MaterialTheme.colorScheme.error, - ) { - Text( - stringResource(R.string.ocr_from_share_error), - style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), - ) - Text(exception.toString()) - } + } else if (exception != null) { + Column { + CompositionLocalProvider( + LocalContentColor provides MaterialTheme.colorScheme.error, + ) { + Text( + stringResource(R.string.ocr_from_share_error), + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + ) + Text(exception.toString()) + } - Spacer(Modifier.height(MaterialTheme.spacing.sm)) + Spacer(Modifier.height(MaterialTheme.spacing.sm)) - FilledTonalButton(onClick = onRetryOcr) { - IconRow { - Icon(Icons.Default.Refresh, contentDescription = null) - Text(stringResource(R.string.ocr_from_share_retry_ocr)) + FilledTonalButton(onClick = onRetryOcr) { + IconRow { + Icon(Icons.Default.Refresh, contentDescription = null) + Text(stringResource(R.string.ocr_from_share_retry_ocr)) + } } } - } - } else { - Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.xxs)) { - Text(stringResource(R.string.ocr_from_share_waiting_result)) - LinearProgressIndicator(gapSize = 0.dp) + } else { + Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.xxs)) { + Text(stringResource(R.string.ocr_from_share_waiting_result)) + LinearProgressIndicator(gapSize = 0.dp) + } } } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt index 13ed4a95..2338f3aa 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt @@ -1,35 +1,63 @@ package xyz.sevive.arcaeaoffline.ui.activities.ocrfromshare +import android.content.Intent +import android.net.Uri +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.Apps -import androidx.compose.material.icons.filled.Dashboard -import androidx.compose.material3.ButtonDefaults +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.outlined.Image import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.windowsizeclass.WindowSizeClass -import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect 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.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.repeatOnLifecycle +import androidx.window.core.layout.WindowSizeClass +import com.github.panpf.sketch.AsyncImage +import com.github.panpf.sketch.PainterState +import com.github.panpf.sketch.rememberAsyncImageState +import xyz.sevive.arcaeaoffline.MainActivity import xyz.sevive.arcaeaoffline.R +import xyz.sevive.arcaeaoffline.helpers.secondaryItemAlpha +import xyz.sevive.arcaeaoffline.ui.components.ImagePreviewDialogFullscreen +import xyz.sevive.arcaeaoffline.ui.components.TextItem +import xyz.sevive.arcaeaoffline.ui.navigation.OcrSubScreen import xyz.sevive.arcaeaoffline.ui.theme.spacing @Composable @@ -39,27 +67,21 @@ internal fun OcrFromShareReturnToShareAppButton( modifier: Modifier = Modifier, ) { val shareSource by ocrFromShareViewModel.shareSourceUiState.collectAsStateWithLifecycle() - val iconSize = Icons.Default.Apps.defaultHeight - AppIconLabelButton( - onClick = { onReturnToShareApp() }, - appIcon = { - val icon = shareSource.icon - if (icon != null) { - Image(icon, null, Modifier.size(iconSize)) - } else { - Icon(Icons.Default.Apps, null, Modifier.size(iconSize)) - } + TextItem( + onClick = onReturnToShareApp, + title = shareSource.name ?: stringResource(R.string.ocr_from_share_return_to_share_source), + content = if (shareSource.name != null) stringResource(R.string.ocr_from_share_return_to_share_source) else null, + leadingSlot = { + shareSource.icon?.let { + Image(it, null, Modifier.size(iconSize)) + } ?: Icon(Icons.Default.Apps, null, Modifier.size(iconSize)) + }, + trailingSlot = { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) }, - appLabel = { shareSource.name?.let { Text(it) } }, - actionText = { Text(stringResource(R.string.ocr_from_share_return_to_share_source)) }, modifier = modifier, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.primary, - ), ) } @@ -68,24 +90,17 @@ internal fun OcrFromShareStayInAppButton( onStayInApp: () -> Unit, modifier: Modifier = Modifier, ) { - AppIconLabelButton( - onClick = { onStayInApp() }, - appIcon = { - Icon(Icons.Default.Dashboard, null) - }, - appLabel = { - Text( - String.format( - stringResource(R.string.ocr_from_share_stay_in_app), - stringResource(R.string.app_name), - ), - ) - }, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + TextItem( + onClick = onStayInApp, + title = + String.format( + stringResource(R.string.ocr_from_share_stay_in_app), + stringResource(R.string.app_name), ), + leadingSlot = { Icon(Icons.Default.Home, null) }, + trailingSlot = { + Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null) + }, modifier = modifier, ) } @@ -94,9 +109,10 @@ internal fun OcrFromShareStayInAppButton( internal fun OcrFromShareScreenContentMedium( onReturnToShareApp: () -> Unit, onStayInApp: () -> Unit, + onNavigateToDependencies: () -> Unit, viewModel: OcrFromShareViewModel, ) { - val imageBitmap by viewModel.imageBitmap.collectAsStateWithLifecycle() + val sharedImageUri by viewModel.sharedImageUri.collectAsStateWithLifecycle() val ocrDependencyViewersUiState by viewModel.ocrDependencyViewersUiState.collectAsStateWithLifecycle() // Reload on every resume: returning from the app's dependency setup changes the status. @@ -120,32 +136,24 @@ internal fun OcrFromShareScreenContentMedium( Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), ) { - OcrFromShareOcrDependencyStatusCard(ocrDependencyViewersUiState) + OcrFromShareOcrDependencyStatusCard(ocrDependencyViewersUiState, onNavigateToDependencies) - if (imageBitmap != null) { - Image(imageBitmap!!, null) - } + OcrFromShareImage(sharedImageUri, Modifier.weight(1f)) } - LazyColumn( - Modifier.weight(1f), + Column( + Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), ) { - item { - OcrFromShareOcrResult(viewModel) - } + OcrFromShareOcrResult(viewModel) - item { - OcrFromShareActions(viewModel) - } + OcrFromShareActions(viewModel) - item { - OcrFromShareReturnToShareAppButton(onReturnToShareApp, viewModel) - } + OcrFromShareReturnToShareAppButton(onReturnToShareApp, viewModel) - item { - OcrFromShareStayInAppButton(onStayInApp) - } + OcrFromShareStayInAppButton(onStayInApp) } } } @@ -155,9 +163,10 @@ internal fun OcrFromShareScreenContentMedium( fun OcrFromShareScreenCompact( onReturnToShareApp: () -> Unit, onStayInApp: () -> Unit, + onNavigateToDependencies: () -> Unit, viewModel: OcrFromShareViewModel, ) { - val imageBitmap by viewModel.imageBitmap.collectAsStateWithLifecycle() + val sharedImageUri by viewModel.sharedImageUri.collectAsStateWithLifecycle() val ocrDependencyViewersUiState by viewModel.ocrDependencyViewersUiState.collectAsStateWithLifecycle() // Reload on every resume: returning from the app's dependency setup changes the status. @@ -169,67 +178,146 @@ fun OcrFromShareScreenCompact( } Scaffold(topBar = { OcrFromShareTopBar() }) { innerPadding -> - LazyColumn( + Column( modifier = Modifier .fillMaxSize() - .consumeWindowInsets(innerPadding), - contentPadding = - PaddingValues( - top = innerPadding.calculateTopPadding() + MaterialTheme.spacing.pagePadding, - bottom = innerPadding.calculateBottomPadding() + MaterialTheme.spacing.pagePadding, + .consumeWindowInsets(innerPadding) + .verticalScroll(rememberScrollState()) + .padding( + top = innerPadding.calculateTopPadding() + MaterialTheme.spacing.pagePadding, + bottom = innerPadding.calculateBottomPadding() + MaterialTheme.spacing.pagePadding, + ), + ) { + Column( + Modifier.padding( start = MaterialTheme.spacing.pagePadding, end = MaterialTheme.spacing.pagePadding, ), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), - ) { - item { - OcrFromShareOcrDependencyStatusCard(ocrDependencyViewersUiState) - } + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), + ) { + OcrFromShareOcrDependencyStatusCard(ocrDependencyViewersUiState, onNavigateToDependencies) - if (imageBitmap != null) { - item { - Image(imageBitmap!!, null) - } - } + OcrFromShareImage( + sharedImageUri, + Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f), + ) - item { OcrFromShareOcrResult(viewModel) - } - item { OcrFromShareActions(viewModel) } - item { - OcrFromShareReturnToShareAppButton(onReturnToShareApp, viewModel) - } + OcrFromShareReturnToShareAppButton(onReturnToShareApp, viewModel) - item { - OcrFromShareStayInAppButton(onStayInApp) - } + OcrFromShareStayInAppButton(onStayInApp) } } } @Composable fun OcrFromShareScreen( - windowSizeClass: WindowSizeClass, onReturnToShareApp: () -> Unit, onStayInApp: () -> Unit, viewModel: OcrFromShareViewModel, ) { - if (windowSizeClass.widthSizeClass >= WindowWidthSizeClass.Medium) { + val context = LocalContext.current + val navigateToDependencies = { + // User should expect returning to this activity after setting up, + // so do not finish activity here. + // CLEAR_TOP tells the running instance to recreate and re-read the + // route extra for jumping. + val intent = + Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtra(MainActivity.EXTRA_INITIAL_ROUTE, OcrSubScreen.Dependencies.route) + } + context.startActivity(intent) + } + + val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass + if (windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND)) { OcrFromShareScreenContentMedium( onReturnToShareApp = onReturnToShareApp, onStayInApp = onStayInApp, + onNavigateToDependencies = navigateToDependencies, viewModel = viewModel, ) } else { OcrFromShareScreenCompact( onReturnToShareApp = onReturnToShareApp, onStayInApp = onStayInApp, + onNavigateToDependencies = navigateToDependencies, viewModel = viewModel, ) } } + +@Composable +private fun OcrFromShareImage( + uri: Uri?, + modifier: Modifier = Modifier, +) { + var showImagePreview by rememberSaveable { mutableStateOf(false) } + val imageState = rememberAsyncImageState() + + if (showImagePreview) { + uri?.let { + ImagePreviewDialogFullscreen( + onDismissRequest = { showImagePreview = false }, + uri = it, + ) + } + } + + Box(modifier.clickable(enabled = uri != null) { showImagePreview = true }) { + if (uri == null) { + // placeholder box to keep layout stable + Box( + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Outlined.Image, + contentDescription = null, + Modifier + .size(48.dp) + .secondaryItemAlpha(), + ) + } + } else { + if (imageState.painterState == null || imageState.painterState is PainterState.Loading) { + ImageSkeleton(Modifier.fillMaxSize()) + } + + AsyncImage( + uri = uri.toString(), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + state = imageState, + ) + } + } +} + +@Composable +private fun ImageSkeleton(modifier: Modifier = Modifier) { + val alpha by + rememberInfiniteTransition(label = "ImageSkeleton") + .animateFloat( + initialValue = 0.3f, + targetValue = 1f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "ImageSkeletonAlpha", + ) + + Box(modifier.background(MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = alpha))) +} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt index 5d838874..cecb946d 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt @@ -2,8 +2,6 @@ package xyz.sevive.arcaeaoffline.ui.activities.ocrfromshare import android.content.Context import android.content.pm.PackageManager -import android.graphics.Bitmap -import android.graphics.BitmapFactory import android.net.Uri import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap @@ -26,7 +24,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -108,9 +105,13 @@ class OcrFromShareViewModel( private val logger = Logger.withTag(LOG_TAG) - private val _ocrDependencyViewersUiState = MutableStateFlow(OcrDependencyViewersUiState()) + private val _ocrDependencyViewersUiState = MutableStateFlow(null) val ocrDependencyViewersUiState = _ocrDependencyViewersUiState.asStateFlow() + init { + reloadOcrDependencyViewersUiState() + } + fun reloadOcrDependencyViewersUiState() { viewModelScope.launch(Dispatchers.IO) { val imageHashesDatabase = OcrDependencyStatusBuilder.imageHashesDatabase() @@ -124,14 +125,6 @@ class OcrFromShareViewModel( } } - private val bitmap = MutableStateFlow(null) - val imageBitmap = - bitmap.map { it?.asImageBitmap() }.stateIn( - viewModelScope, - SharingStarted.Lazily, - null, - ) - private val shareSourceAppPackageName = MutableStateFlow(null) private val _shareSourceUiState = MutableStateFlow(ShareSourceUiState()) @@ -195,38 +188,35 @@ class OcrFromShareViewModel( private var sharedImage: SharedImage? = null + private val _sharedImageUri = MutableStateFlow(null) + val sharedImageUri = _sharedImageUri.asStateFlow() + /** Single entry for Activity */ fun onImageShared(uri: Uri) { val sharedAt = Clock.System.now() viewModelScope.launch { - val decodedBitmap = - try { - withContext(Dispatchers.IO) { - val bytes = - appContext.contentResolver.openInputStream(uri)?.use { it.readBytes() } - ?: throw IOException("Cannot open shared image stream") - - val decoded = - BitmapFactory.decodeByteArray(bytes, 0, bytes.size) - ?: throw IOException("Decoded bitmap is null") - - val sourceTakenAt = - runCatching { - DeviceOcrHelper.readImageDateFromExif(ByteArrayByteReader(bytes)) - }.getOrNull() - - sharedImage = SharedImage(uri, sharedAt, sourceTakenAt, bytes) - decoded - } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - _ocrResultState.value = OcrResultState.Failed(Exception("Error reading image", e)) - return@launch + try { + withContext(Dispatchers.IO) { + val bytes = + appContext.contentResolver.openInputStream(uri)?.use { it.readBytes() } + ?: throw IOException("Cannot open shared image stream") + + val sourceTakenAt = + runCatching { + DeviceOcrHelper.readImageDateFromExif(ByteArrayByteReader(bytes)) + }.getOrNull() + + sharedImage = SharedImage(uri, sharedAt, sourceTakenAt, bytes) + _sharedImageUri.value = uri } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _ocrResultState.value = OcrResultState.Failed(Exception("Error reading image", e)) + return@launch + } - bitmap.value = decodedBitmap startOcr(uri) } } @@ -302,18 +292,15 @@ class OcrFromShareViewModel( ) val id = ocrHistoryRepo.insert(ocrHistory) - val bitmap = bitmap.value - if (bitmap != null) { + sharedImage?.let { shared -> val parentDir = OcrPaths().fromShareImageCacheDir if (!SystemFileSystem.exists(parentDir)) { SystemFileSystem.createDirectories(parentDir) logger.i { "Created image cache dir" } } - val cacheImagePath = parentDir / "$id.jpg" - SystemFileSystem.sink(cacheImagePath).buffered().use { sink -> - bitmap.compress(Bitmap.CompressFormat.JPEG, 100, sink.asOutputStream()) - } + val cacheImagePath = parentDir / "$id.${imageFileExtension(shared.bytes)}" + SystemFileSystem.sink(cacheImagePath).buffered().use { it.asOutputStream().write(shared.bytes) } } _playResultSaveState.value = _playResultSaveState.value.copy(cached = true) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index d43b1234..cfd3d9d8 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -101,6 +101,7 @@ 等待 OCR 结果…… 保存图片 + 图片已保存 暂存 记录已保存 OCR 出错 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e0b3c0b3..2ff4c569 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -117,6 +117,7 @@ CC BY-SA 3.0 Waiting for OCR… Save Image + Image Saved Cache Play Saved OCR Error From 2940203bce8fbe019b5b5a1d1236439886b71def Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 4 Nov 2026 23:34:06 +0800 Subject: [PATCH 05/19] comment rewrite --- .../main/java/xyz/sevive/arcaeaoffline/MainScreen.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/MainScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/MainScreen.kt index fcfffb97..e39532d9 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/MainScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/MainScreen.kt @@ -33,8 +33,8 @@ internal fun mainNavControllerNavigateToRoute( @Composable fun MainScreen( - initialNavigateRoute: String? = null, modifier: Modifier = Modifier, + initialNavigateRoute: String? = null, ) { val navController = rememberNavController() @@ -47,10 +47,10 @@ fun MainScreen( val tabRoute = route.substringBefore('/') if (MainScreen.entries.none { it.route == tabRoute }) return@LaunchedEffect - // A fresh tab entry, not the restoreState helper: a restored entry would bring back - // its saved detail pane instead of the requested initial one. The start destination - // is popped (state saved for later tab switches) so back exits the task to the - // source activity instead of walking through Home. + // Navigate with a fresh tab stack: + // 1. Omit restoreState: prevents restoring a stale nested/detail destination instead of this fresh entry. + // 2. popUpTo(startRoute, inclusive = true): replaces the initial root so pressing Back exits directly + // to the caller activity, while saving the start destination's state for subsequent tab switches. val startRoute = navController.graph.startDestinationRoute ?: return@LaunchedEffect navController.navigate(tabRoute) { popUpTo(startRoute) { @@ -74,7 +74,7 @@ fun MainScreen( icon = { Icon(it.icon(), contentDescription = null) }, label = { Text(stringResource(it.title)) }, alwaysShowLabel = true, - selected = if (currentRouteRoot != null) currentRouteRoot[0] == it.route else false, + selected = currentRouteRoot != null && currentRouteRoot[0] == it.route, onClick = { mainNavControllerNavigateToRoute(navController, it.route) }, ) } From 813e7389d408c53c8ff7199b2dde64eb86a52312 Mon Sep 17 00:00:00 2001 From: 283375 Date: Thu, 5 Nov 2026 01:00:12 +0800 Subject: [PATCH 06/19] ocr from share state hoisting --- .../ocrfromshare/OcrFromShareActions.kt | 131 +++++++------- .../ocrfromshare/OcrFromShareOcrResult.kt | 75 ++++---- .../ocrfromshare/OcrFromShareScreen.kt | 164 +++++++++++++----- .../ocrfromshare/OcrFromShareViewModel.kt | 16 ++ 4 files changed, 240 insertions(+), 146 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareActions.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareActions.kt index c4d51f47..a05b2179 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareActions.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareActions.kt @@ -1,9 +1,5 @@ package xyz.sevive.arcaeaoffline.ui.activities.ocrfromshare -import android.Manifest -import android.widget.Toast -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -18,61 +14,36 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface 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.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import kotlinx.coroutines.launch +import androidx.compose.ui.tooling.preview.Preview import xyz.sevive.arcaeaoffline.R -import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidator -import xyz.sevive.arcaeaoffline.permissions.storage.SaveImageToGallery -import xyz.sevive.arcaeaoffline.ui.activities.ocrfromshare.OcrFromShareViewModel.OcrResultState -import xyz.sevive.arcaeaoffline.ui.common.PermissionRequiredDialog +import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidatorScoreIsZeroWarning +import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidatorWarning import xyz.sevive.arcaeaoffline.ui.components.IconRow import xyz.sevive.arcaeaoffline.ui.components.SaveWithWarningsDialog +import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme import xyz.sevive.arcaeaoffline.ui.theme.spacing @Composable -internal fun OcrFromShareActions(ocrFromShareViewModel: OcrFromShareViewModel) { - val coroutineScope = rememberCoroutineScope() - - val context = LocalContext.current - - val handleSavePicture = { ocrFromShareViewModel.saveImage() } - val requestWriteStoragePermissionLauncher = - rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission(), - ) { if (it) handleSavePicture() } - - var showSavePicturePermissionRequiredDialog by rememberSaveable { mutableStateOf(false) } - if (showSavePicturePermissionRequiredDialog) { - PermissionRequiredDialog( - onDismiss = { showSavePicturePermissionRequiredDialog = false }, - onConfirm = { - requestWriteStoragePermissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) - showSavePicturePermissionRequiredDialog = false - }, - functionName = stringResource(R.string.permission_function_save_image), - permissions = listOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), - ) - } - - val resultState by ocrFromShareViewModel.ocrResultState.collectAsStateWithLifecycle() - val playResult = (resultState as? OcrResultState.Done)?.playResult - val imageSaved by ocrFromShareViewModel.imageSaved.collectAsStateWithLifecycle() - val saveState by ocrFromShareViewModel.playResultSaveState.collectAsStateWithLifecycle() - val display by ocrFromShareViewModel.display.collectAsStateWithLifecycle() - val warnings = - remember(playResult, display) { - playResult?.let { ArcaeaPlayResultValidator.validate(playResult = it, chartInfo = display?.chartInfo) }.orEmpty() - } +internal fun OcrFromShareActions( + hasPlayResult: Boolean, + playResultSaveState: OcrFromShareViewModel.PlayResultSaveState, + imageSaved: Boolean, + warnings: List, + onSavePlayResult: () -> Unit, + onCachePlayResult: () -> Unit, + onSaveImage: () -> Unit, +) { + val playResultSaved = playResultSaveState.saved + val playResultCached = playResultSaveState.cached + val canSavePlayResult = hasPlayResult && !playResultSaved && !playResultCached var showSaveWithWarningsDialog by rememberSaveable { mutableStateOf(false) } if (showSaveWithWarningsDialog) { @@ -80,7 +51,7 @@ internal fun OcrFromShareActions(ocrFromShareViewModel: OcrFromShareViewModel) { warnings = warnings, onConfirm = { showSaveWithWarningsDialog = false - coroutineScope.launch { ocrFromShareViewModel.savePlayResult() } + onSavePlayResult() }, onDismissRequest = { showSaveWithWarningsDialog = false }, ) @@ -91,14 +62,14 @@ internal fun OcrFromShareActions(ocrFromShareViewModel: OcrFromShareViewModel) { Button( onClick = { if (warnings.isEmpty()) { - coroutineScope.launch { ocrFromShareViewModel.savePlayResult() } + onSavePlayResult() } else { showSaveWithWarningsDialog = true } }, - enabled = playResult != null && !saveState.saved && !saveState.cached, + enabled = canSavePlayResult, ) { - AnimatedContent(saveState.saved) { + AnimatedContent(playResultSaved) { if (it) { IconRow { Icon(Icons.Default.Check, null) @@ -113,13 +84,7 @@ internal fun OcrFromShareActions(ocrFromShareViewModel: OcrFromShareViewModel) { } } - OutlinedButton( - onClick = { - // TODO: coroutineScope.launch { ocrFromShareViewModel.cachePlayResult(context) } - Toast.makeText(context, "TODO", Toast.LENGTH_SHORT).show() - }, - enabled = playResult != null && !saveState.saved && !saveState.cached, - ) { + OutlinedButton(onClick = onCachePlayResult, enabled = canSavePlayResult) { IconRow { Icon(Icons.Default.Archive, null) Text(stringResource(R.string.ocr_from_share_cache_play_result_button)) @@ -128,13 +93,7 @@ internal fun OcrFromShareActions(ocrFromShareViewModel: OcrFromShareViewModel) { } OutlinedButton( - onClick = { - if (SaveImageToGallery.checkPermission(context)) { - handleSavePicture() - } else { - showSavePicturePermissionRequiredDialog = true - } - }, + onClick = onSaveImage, enabled = !imageSaved, colors = ButtonDefaults.outlinedButtonColors( @@ -157,3 +116,47 @@ internal fun OcrFromShareActions(ocrFromShareViewModel: OcrFromShareViewModel) { } } } + +@Preview(name = "Actions", showBackground = true) +@Composable +private fun PreviewActions() { + ArcaeaOfflineTheme { + Surface { + OcrFromShareActions( + hasPlayResult = true, + playResultSaveState = + OcrFromShareViewModel.PlayResultSaveState( + saved = false, + cached = false, + ), + imageSaved = false, + warnings = listOf(ArcaeaPlayResultValidatorScoreIsZeroWarning), + onSavePlayResult = {}, + onCachePlayResult = {}, + onSaveImage = {}, + ) + } + } +} + +@Preview(name = "Actions saved", showBackground = true) +@Composable +private fun PreviewActionsSaved() { + ArcaeaOfflineTheme { + Surface { + OcrFromShareActions( + hasPlayResult = true, + playResultSaveState = + OcrFromShareViewModel.PlayResultSaveState( + saved = true, + cached = false, + ), + imageSaved = true, + warnings = emptyList(), + onSavePlayResult = {}, + onCachePlayResult = {}, + onSaveImage = {}, + ) + } + } +} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrResult.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrResult.kt index 29801b3d..0f672372 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrResult.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrResult.kt @@ -23,7 +23,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider 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 @@ -32,7 +31,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import xyz.sevive.arcaeaoffline.R import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultClearType import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultModifier @@ -40,8 +38,7 @@ import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass import xyz.sevive.arcaeaoffline.core.database.entities.ChartInfo import xyz.sevive.arcaeaoffline.core.database.entities.DifficultyWithSong import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult -import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidator -import xyz.sevive.arcaeaoffline.ui.activities.ocrfromshare.OcrFromShareViewModel.OcrResultState +import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidatorWarning import xyz.sevive.arcaeaoffline.ui.components.ArcaeaPlayResultCard import xyz.sevive.arcaeaoffline.ui.components.ArcaeaPlayResultEditorDialog import xyz.sevive.arcaeaoffline.ui.components.DifficultyWithSongDisplay @@ -51,32 +48,17 @@ import xyz.sevive.arcaeaoffline.ui.theme.spacing import kotlin.time.Instant @Composable -internal fun OcrFromShareOcrResult(viewModel: OcrFromShareViewModel) { - val resultState by viewModel.ocrResultState.collectAsStateWithLifecycle() - val display by viewModel.display.collectAsStateWithLifecycle() - val saveState by viewModel.playResultSaveState.collectAsStateWithLifecycle() - - OcrFromShareOcrResultContent( - playResult = (resultState as? OcrResultState.Done)?.playResult, - onSetPlayResult = { viewModel.setPlayResult(it) }, - display = display, - playResultSaved = saveState.saved, - playResultCached = saveState.cached, - exception = (resultState as? OcrResultState.Failed)?.exception, - onRetryOcr = { viewModel.retryOcr() }, - ) -} - -@Composable -internal fun OcrFromShareOcrResultContent( +internal fun OcrFromShareOcrResult( playResult: PlayResult?, onSetPlayResult: (PlayResult) -> Unit, display: DifficultyWithSongDisplay?, - playResultSaved: Boolean, - playResultCached: Boolean, + warnings: List, + playResultSaveState: OcrFromShareViewModel.PlayResultSaveState, exception: Exception?, onRetryOcr: () -> Unit, ) { + val playResultSaved = playResultSaveState.saved + val playResultCached = playResultSaveState.cached var showPlayResultEditorDialog by rememberSaveable { mutableStateOf(false) } if (showPlayResultEditorDialog && playResult != null) { @@ -87,11 +69,6 @@ internal fun OcrFromShareOcrResultContent( ) } - val warnings = - remember(playResult, display) { - playResult?.let { ArcaeaPlayResultValidator.validate(playResult = it, chartInfo = display?.chartInfo) }.orEmpty() - } - // Size changes between the waiting, error, and result branches animate instead of jumping. Box(Modifier.animateContentSize()) { if (playResult != null) { @@ -182,12 +159,16 @@ private fun previewDisplay(): DifficultyWithSongDisplay = private fun PreviewOcrResultWithScore() { ArcaeaOfflineTheme { Surface { - OcrFromShareOcrResultContent( + OcrFromShareOcrResult( playResult = previewPlayResult(), display = previewDisplay(), + warnings = emptyList(), exception = null, - playResultSaved = false, - playResultCached = false, + playResultSaveState = + OcrFromShareViewModel.PlayResultSaveState( + saved = false, + cached = false, + ), onSetPlayResult = {}, onRetryOcr = {}, ) @@ -200,12 +181,16 @@ private fun PreviewOcrResultWithScore() { private fun PreviewOcrResultScoreSaved() { ArcaeaOfflineTheme { Surface { - OcrFromShareOcrResultContent( + OcrFromShareOcrResult( playResult = previewPlayResult(), display = previewDisplay(), + warnings = emptyList(), exception = null, - playResultSaved = true, - playResultCached = false, + playResultSaveState = + OcrFromShareViewModel.PlayResultSaveState( + saved = true, + cached = false, + ), onSetPlayResult = {}, onRetryOcr = {}, ) @@ -218,12 +203,16 @@ private fun PreviewOcrResultScoreSaved() { private fun PreviewOcrResultError() { ArcaeaOfflineTheme { Surface { - OcrFromShareOcrResultContent( + OcrFromShareOcrResult( playResult = null, display = null, + warnings = emptyList(), exception = IllegalStateException("Wow error"), - playResultSaved = false, - playResultCached = false, + playResultSaveState = + OcrFromShareViewModel.PlayResultSaveState( + saved = false, + cached = false, + ), onSetPlayResult = {}, onRetryOcr = {}, ) @@ -236,12 +225,16 @@ private fun PreviewOcrResultError() { private fun PreviewOcrResultWaiting() { ArcaeaOfflineTheme { Surface { - OcrFromShareOcrResultContent( + OcrFromShareOcrResult( playResult = null, display = null, + warnings = emptyList(), exception = null, - playResultSaved = false, - playResultCached = false, + playResultSaveState = + OcrFromShareViewModel.PlayResultSaveState( + saved = false, + cached = false, + ), onSetPlayResult = {}, onRetryOcr = {}, ) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt index 2338f3aa..7ef5c6f5 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt @@ -1,7 +1,11 @@ package xyz.sevive.arcaeaoffline.ui.activities.ocrfromshare +import android.Manifest import android.content.Intent import android.net.Uri +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat @@ -14,6 +18,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.consumeWindowInsets @@ -36,7 +41,10 @@ import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.movableContentOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -52,21 +60,24 @@ import androidx.window.core.layout.WindowSizeClass import com.github.panpf.sketch.AsyncImage import com.github.panpf.sketch.PainterState import com.github.panpf.sketch.rememberAsyncImageState +import kotlinx.coroutines.launch import xyz.sevive.arcaeaoffline.MainActivity import xyz.sevive.arcaeaoffline.R import xyz.sevive.arcaeaoffline.helpers.secondaryItemAlpha +import xyz.sevive.arcaeaoffline.permissions.storage.SaveImageToGallery +import xyz.sevive.arcaeaoffline.ui.activities.ocrfromshare.OcrFromShareViewModel.OcrResultState +import xyz.sevive.arcaeaoffline.ui.common.PermissionRequiredDialog import xyz.sevive.arcaeaoffline.ui.components.ImagePreviewDialogFullscreen import xyz.sevive.arcaeaoffline.ui.components.TextItem import xyz.sevive.arcaeaoffline.ui.navigation.OcrSubScreen import xyz.sevive.arcaeaoffline.ui.theme.spacing @Composable -internal fun OcrFromShareReturnToShareAppButton( +private fun OcrFromShareReturnToShareAppButton( + shareSource: OcrFromShareViewModel.ShareSourceUiState, onReturnToShareApp: () -> Unit, - ocrFromShareViewModel: OcrFromShareViewModel, modifier: Modifier = Modifier, ) { - val shareSource by ocrFromShareViewModel.shareSourceUiState.collectAsStateWithLifecycle() val iconSize = Icons.Default.Apps.defaultHeight TextItem( @@ -86,7 +97,7 @@ internal fun OcrFromShareReturnToShareAppButton( } @Composable -internal fun OcrFromShareStayInAppButton( +private fun OcrFromShareStayInAppButton( onStayInApp: () -> Unit, modifier: Modifier = Modifier, ) { @@ -106,23 +117,16 @@ internal fun OcrFromShareStayInAppButton( } @Composable -internal fun OcrFromShareScreenContentMedium( +private fun OcrFromShareScreenExpanded( + sharedImageUri: Uri?, + dependencyUiState: OcrFromShareViewModel.OcrDependencyViewersUiState?, + shareSource: OcrFromShareViewModel.ShareSourceUiState, onReturnToShareApp: () -> Unit, onStayInApp: () -> Unit, onNavigateToDependencies: () -> Unit, - viewModel: OcrFromShareViewModel, + resultSection: @Composable ColumnScope.() -> Unit, + actionsSection: @Composable ColumnScope.() -> Unit, ) { - val sharedImageUri by viewModel.sharedImageUri.collectAsStateWithLifecycle() - val ocrDependencyViewersUiState by viewModel.ocrDependencyViewersUiState.collectAsStateWithLifecycle() - - // Reload on every resume: returning from the app's dependency setup changes the status. - val lifecycleOwner = LocalLifecycleOwner.current - LaunchedEffect(lifecycleOwner) { - lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.reloadOcrDependencyViewersUiState() - } - } - Scaffold(topBar = { OcrFromShareTopBar() }) { innerPadding -> Row( Modifier @@ -136,7 +140,7 @@ internal fun OcrFromShareScreenContentMedium( Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), ) { - OcrFromShareOcrDependencyStatusCard(ocrDependencyViewersUiState, onNavigateToDependencies) + OcrFromShareOcrDependencyStatusCard(dependencyUiState, onNavigateToDependencies) OcrFromShareImage(sharedImageUri, Modifier.weight(1f)) } @@ -147,11 +151,11 @@ internal fun OcrFromShareScreenContentMedium( .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), ) { - OcrFromShareOcrResult(viewModel) + resultSection() - OcrFromShareActions(viewModel) + actionsSection() - OcrFromShareReturnToShareAppButton(onReturnToShareApp, viewModel) + OcrFromShareReturnToShareAppButton(shareSource, onReturnToShareApp) OcrFromShareStayInAppButton(onStayInApp) } @@ -160,23 +164,16 @@ internal fun OcrFromShareScreenContentMedium( } @Composable -fun OcrFromShareScreenCompact( +private fun OcrFromShareScreenCompact( + sharedImageUri: Uri?, + dependencyUiState: OcrFromShareViewModel.OcrDependencyViewersUiState?, + shareSource: OcrFromShareViewModel.ShareSourceUiState, onReturnToShareApp: () -> Unit, onStayInApp: () -> Unit, onNavigateToDependencies: () -> Unit, - viewModel: OcrFromShareViewModel, + resultSection: @Composable ColumnScope.() -> Unit, + actionsSection: @Composable ColumnScope.() -> Unit, ) { - val sharedImageUri by viewModel.sharedImageUri.collectAsStateWithLifecycle() - val ocrDependencyViewersUiState by viewModel.ocrDependencyViewersUiState.collectAsStateWithLifecycle() - - // Reload on every resume: returning from the app's dependency setup changes the status. - val lifecycleOwner = LocalLifecycleOwner.current - LaunchedEffect(lifecycleOwner) { - lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.reloadOcrDependencyViewersUiState() - } - } - Scaffold(topBar = { OcrFromShareTopBar() }) { innerPadding -> Column( modifier = @@ -196,7 +193,7 @@ fun OcrFromShareScreenCompact( ), verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), ) { - OcrFromShareOcrDependencyStatusCard(ocrDependencyViewersUiState, onNavigateToDependencies) + OcrFromShareOcrDependencyStatusCard(dependencyUiState, onNavigateToDependencies) OcrFromShareImage( sharedImageUri, @@ -205,12 +202,12 @@ fun OcrFromShareScreenCompact( .aspectRatio(16f / 9f), ) - OcrFromShareOcrResult(viewModel) + resultSection() - OcrFromShareActions(viewModel) + actionsSection() } - OcrFromShareReturnToShareAppButton(onReturnToShareApp, viewModel) + OcrFromShareReturnToShareAppButton(shareSource, onReturnToShareApp) OcrFromShareStayInAppButton(onStayInApp) } @@ -224,6 +221,8 @@ fun OcrFromShareScreen( viewModel: OcrFromShareViewModel, ) { val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + val navigateToDependencies = { // User should expect returning to this activity after setting up, // so do not finish activity here. @@ -237,20 +236,103 @@ fun OcrFromShareScreen( context.startActivity(intent) } + val sharedImageUri by viewModel.sharedImageUri.collectAsStateWithLifecycle() + val dependencyUiState by viewModel.ocrDependencyViewersUiState.collectAsStateWithLifecycle() + val shareSource by viewModel.shareSourceUiState.collectAsStateWithLifecycle() + val resultState by viewModel.ocrResultState.collectAsStateWithLifecycle() + val playResultSaveState by viewModel.playResultSaveState.collectAsStateWithLifecycle() + val imageSaved by viewModel.imageSaved.collectAsStateWithLifecycle() + val warnings by viewModel.warnings.collectAsStateWithLifecycle() + val display by viewModel.display.collectAsStateWithLifecycle() + + val resultSection: @Composable ColumnScope.() -> Unit = + remember { + movableContentOf { + OcrFromShareOcrResult( + playResult = (resultState as? OcrResultState.Done)?.playResult, + onSetPlayResult = { viewModel.setPlayResult(it) }, + display = display, + warnings = warnings, + playResultSaveState = playResultSaveState, + exception = (resultState as? OcrResultState.Failed)?.exception, + onRetryOcr = { viewModel.retryOcr() }, + ) + } + } + + val actionsSection: @Composable ColumnScope.() -> Unit = + remember { + movableContentOf { + val handleSaveImage = { viewModel.saveImage() } + val requestWriteStoragePermissionLauncher = + rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { if (it) handleSaveImage() } + + var showSavePicturePermissionRequiredDialog by rememberSaveable { mutableStateOf(false) } + if (showSavePicturePermissionRequiredDialog) { + PermissionRequiredDialog( + onDismiss = { showSavePicturePermissionRequiredDialog = false }, + onConfirm = { + requestWriteStoragePermissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) + showSavePicturePermissionRequiredDialog = false + }, + functionName = stringResource(R.string.permission_function_save_image), + permissions = listOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), + ) + } + + OcrFromShareActions( + hasPlayResult = resultState is OcrResultState.Done, + playResultSaveState = playResultSaveState, + imageSaved = imageSaved, + warnings = warnings, + onSavePlayResult = { coroutineScope.launch { viewModel.savePlayResult() } }, + onCachePlayResult = { + // TODO: coroutineScope.launch { viewModel.cachePlayResult(context) } + Toast.makeText(context, "TODO", Toast.LENGTH_SHORT).show() + }, + onSaveImage = { + if (SaveImageToGallery.checkPermission(context)) { + handleSaveImage() + } else { + showSavePicturePermissionRequiredDialog = true + } + }, + ) + } + } + + // Reload on every resume: returning from the app's dependency setup changes the status. + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(lifecycleOwner) { + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.reloadOcrDependencyViewersUiState() + } + } + val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass if (windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND)) { - OcrFromShareScreenContentMedium( + OcrFromShareScreenExpanded( + sharedImageUri = sharedImageUri, + dependencyUiState = dependencyUiState, + shareSource = shareSource, onReturnToShareApp = onReturnToShareApp, onStayInApp = onStayInApp, onNavigateToDependencies = navigateToDependencies, - viewModel = viewModel, + resultSection = resultSection, + actionsSection = actionsSection, ) } else { OcrFromShareScreenCompact( + sharedImageUri = sharedImageUri, + dependencyUiState = dependencyUiState, + shareSource = shareSource, onReturnToShareApp = onReturnToShareApp, onStayInApp = onStayInApp, onNavigateToDependencies = navigateToDependencies, - viewModel = viewModel, + resultSection = resultSection, + actionsSection = actionsSection, ) } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt index cecb946d..7adb2677 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -43,6 +44,7 @@ import xyz.sevive.arcaeaoffline.core.ocr.device.DeviceOcrOnnxHelper import xyz.sevive.arcaeaoffline.data.OcrPaths import xyz.sevive.arcaeaoffline.database.entities.OcrHistory import xyz.sevive.arcaeaoffline.database.repositories.OcrHistoryRepository +import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidator import xyz.sevive.arcaeaoffline.helpers.DeviceOcrHelper import xyz.sevive.arcaeaoffline.helpers.OcrDependencyLoader import xyz.sevive.arcaeaoffline.helpers.OcrDependencyStatusBuilder @@ -186,6 +188,20 @@ class OcrFromShareViewModel( } }.stateIn(viewModelScope, SharingStarted.Lazily, null) + @OptIn(ExperimentalCoroutinesApi::class) + val warnings = + _ocrResultState + .flatMapLatest { state -> + val playResult = (state as? OcrResultState.Done)?.playResult + if (playResult == null) { + flowOf(emptyList()) + } else { + display.map { d -> + ArcaeaPlayResultValidator.validate(playResult = playResult, chartInfo = d?.chartInfo) + } + } + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + private var sharedImage: SharedImage? = null private val _sharedImageUri = MutableStateFlow(null) From 32edf19fdef08914f03f7f631acd4e8a9ce5d7dd Mon Sep 17 00:00:00 2001 From: 283375 Date: Thu, 5 Nov 2026 01:20:29 +0800 Subject: [PATCH 07/19] ocr from share landscape condition change --- .../ocrfromshare/OcrFromShareScreen.kt | 37 ++++++++++++------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt index 7ef5c6f5..a8057d13 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt @@ -2,6 +2,7 @@ package xyz.sevive.arcaeaoffline.ui.activities.ocrfromshare import android.Manifest import android.content.Intent +import android.content.res.Configuration import android.net.Uri import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult @@ -37,7 +38,6 @@ import androidx.compose.material.icons.outlined.Image import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -48,7 +48,10 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.BiasAlignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -56,7 +59,6 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.repeatOnLifecycle -import androidx.window.core.layout.WindowSizeClass import com.github.panpf.sketch.AsyncImage import com.github.panpf.sketch.PainterState import com.github.panpf.sketch.rememberAsyncImageState @@ -127,7 +129,9 @@ private fun OcrFromShareScreenExpanded( resultSection: @Composable ColumnScope.() -> Unit, actionsSection: @Composable ColumnScope.() -> Unit, ) { - Scaffold(topBar = { OcrFromShareTopBar() }) { innerPadding -> + // Top bar removed since it takes a certain amount of height but contains no useful information + // Especially useless on landscape phones + Scaffold { innerPadding -> Row( Modifier .fillMaxSize() @@ -136,14 +140,14 @@ private fun OcrFromShareScreenExpanded( .padding(MaterialTheme.spacing.pagePadding), horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.pagePadding), ) { - Column( + OcrFromShareImage( + sharedImageUri, Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), - ) { - OcrFromShareOcrDependencyStatusCard(dependencyUiState, onNavigateToDependencies) - - OcrFromShareImage(sharedImageUri, Modifier.weight(1f)) - } + contentScale = ContentScale.Crop, + // Bias the crop window left for max recall to be visible + // Right side contains nearly useless partner image for OCR data correction + alignment = BiasAlignment(-0.42f, 0f), + ) Column( Modifier @@ -151,6 +155,8 @@ private fun OcrFromShareScreenExpanded( .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), ) { + OcrFromShareOcrDependencyStatusCard(dependencyUiState, onNavigateToDependencies) + resultSection() actionsSection() @@ -303,7 +309,8 @@ fun OcrFromShareScreen( } } - // Reload on every resume: returning from the app's dependency setup changes the status. + // Reload on every resume to possibly reflect the latest status + // e.g. returning from the app's dependency setup val lifecycleOwner = LocalLifecycleOwner.current LaunchedEffect(lifecycleOwner) { lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -311,8 +318,8 @@ fun OcrFromShareScreen( } } - val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass - if (windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND)) { + val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + if (landscape) { OcrFromShareScreenExpanded( sharedImageUri = sharedImageUri, dependencyUiState = dependencyUiState, @@ -341,6 +348,8 @@ fun OcrFromShareScreen( private fun OcrFromShareImage( uri: Uri?, modifier: Modifier = Modifier, + contentScale: ContentScale = ContentScale.Fit, + alignment: Alignment = Alignment.Center, ) { var showImagePreview by rememberSaveable { mutableStateOf(false) } val imageState = rememberAsyncImageState() @@ -381,6 +390,8 @@ private fun OcrFromShareImage( contentDescription = null, modifier = Modifier.fillMaxSize(), state = imageState, + contentScale = contentScale, + alignment = alignment, ) } } From 11377bc61a069d185b922c0098f34a7616524846 Mon Sep 17 00:00:00 2001 From: 283375 Date: Thu, 5 Nov 2026 02:03:38 +0800 Subject: [PATCH 08/19] tweak ocr from share buttons --- .../ocrfromshare/OcrFromShareActions.kt | 123 +++++++--------- .../ocrfromshare/OcrFromShareScreen.kt | 132 ++++++++++++++---- app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 4 files changed, 160 insertions(+), 97 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareActions.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareActions.kt index a05b2179..3607905f 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareActions.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareActions.kt @@ -1,19 +1,15 @@ package xyz.sevive.arcaeaoffline.ui.activities.ocrfromshare import androidx.compose.animation.AnimatedContent -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Archive -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.FileDownload import androidx.compose.material.icons.filled.Save -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -21,25 +17,23 @@ 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.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import xyz.sevive.arcaeaoffline.R import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidatorScoreIsZeroWarning import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidatorWarning -import xyz.sevive.arcaeaoffline.ui.components.IconRow import xyz.sevive.arcaeaoffline.ui.components.SaveWithWarningsDialog import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme -import xyz.sevive.arcaeaoffline.ui.theme.spacing @Composable internal fun OcrFromShareActions( hasPlayResult: Boolean, playResultSaveState: OcrFromShareViewModel.PlayResultSaveState, - imageSaved: Boolean, warnings: List, onSavePlayResult: () -> Unit, onCachePlayResult: () -> Unit, - onSaveImage: () -> Unit, ) { val playResultSaved = playResultSaveState.saved val playResultCached = playResultSaveState.cached @@ -57,61 +51,56 @@ internal fun OcrFromShareActions( ) } - Column { - Row(horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm)) { - Button( - onClick = { - if (warnings.isEmpty()) { - onSavePlayResult() - } else { - showSaveWithWarningsDialog = true - } - }, - enabled = canSavePlayResult, - ) { - AnimatedContent(playResultSaved) { - if (it) { - IconRow { - Icon(Icons.Default.Check, null) - Text(stringResource(R.string.ocr_from_share_play_result_saved)) - } - } else { - IconRow { - Icon(Icons.Default.Save, null) - Text(stringResource(R.string.general_save)) - } - } - } - } - - OutlinedButton(onClick = onCachePlayResult, enabled = canSavePlayResult) { - IconRow { - Icon(Icons.Default.Archive, null) - Text(stringResource(R.string.ocr_from_share_cache_play_result_button)) - } + // The whole row morphs into a single saved segment once the play result is saved. + AnimatedContent(playResultSaved) { saved -> + if (saved) { + SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) { + // Selected and disabled renders through the disabled-active colors + // The alpha-faded defaults would wash the primary out + SegmentedButton( + selected = true, + onClick = {}, + enabled = false, + shape = SegmentedButtonDefaults.itemShape(index = 0, count = 1), + colors = + SegmentedButtonDefaults.colors( + disabledActiveContainerColor = Color.Transparent, + disabledActiveBorderColor = MaterialTheme.colorScheme.primary, + disabledActiveContentColor = MaterialTheme.colorScheme.primary, + ), + label = { Text(stringResource(R.string.ocr_from_share_play_result_saved)) }, + ) } - } + } else { + SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) { + SegmentedButton( + selected = false, + onClick = { + if (warnings.isEmpty()) { + onSavePlayResult() + } else { + showSaveWithWarningsDialog = true + } + }, + enabled = canSavePlayResult, + shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), + colors = + SegmentedButtonDefaults.colors( + inactiveContainerColor = MaterialTheme.colorScheme.primary, + inactiveContentColor = MaterialTheme.colorScheme.onPrimary, + ), + icon = { Icon(Icons.Default.Save, null) }, + label = { Text(stringResource(R.string.general_save)) }, + ) - OutlinedButton( - onClick = onSaveImage, - enabled = !imageSaved, - colors = - ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.secondary, - ), - ) { - AnimatedContent(imageSaved) { - if (it) { - IconRow { - Icon(Icons.Default.Check, null) - Text(stringResource(R.string.ocr_from_share_image_saved)) - } - } else { - IconRow { - Icon(Icons.Default.FileDownload, null) - Text(stringResource(R.string.ocr_from_share_save_image_button)) - } - } + SegmentedButton( + selected = false, + onClick = onCachePlayResult, + enabled = canSavePlayResult, + shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), + icon = { Icon(Icons.Default.Archive, null) }, + label = { Text(stringResource(R.string.ocr_from_share_cache_play_result_button)) }, + ) } } } @@ -129,11 +118,9 @@ private fun PreviewActions() { saved = false, cached = false, ), - imageSaved = false, warnings = listOf(ArcaeaPlayResultValidatorScoreIsZeroWarning), onSavePlayResult = {}, onCachePlayResult = {}, - onSaveImage = {}, ) } } @@ -151,11 +138,9 @@ private fun PreviewActionsSaved() { saved = true, cached = false, ), - imageSaved = true, warnings = emptyList(), onSavePlayResult = {}, onCachePlayResult = {}, - onSaveImage = {}, ) } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt index a8057d13..5062c5b1 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt @@ -7,6 +7,7 @@ import android.net.Uri import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat @@ -16,6 +17,7 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -33,11 +35,17 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.Apps +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.FileDownload +import androidx.compose.material.icons.filled.Fullscreen import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.outlined.Image +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilledTonalButton 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.LaunchedEffect import androidx.compose.runtime.getValue @@ -69,6 +77,7 @@ import xyz.sevive.arcaeaoffline.helpers.secondaryItemAlpha import xyz.sevive.arcaeaoffline.permissions.storage.SaveImageToGallery import xyz.sevive.arcaeaoffline.ui.activities.ocrfromshare.OcrFromShareViewModel.OcrResultState import xyz.sevive.arcaeaoffline.ui.common.PermissionRequiredDialog +import xyz.sevive.arcaeaoffline.ui.components.IconRow import xyz.sevive.arcaeaoffline.ui.components.ImagePreviewDialogFullscreen import xyz.sevive.arcaeaoffline.ui.components.TextItem import xyz.sevive.arcaeaoffline.ui.navigation.OcrSubScreen @@ -121,6 +130,8 @@ private fun OcrFromShareStayInAppButton( @Composable private fun OcrFromShareScreenExpanded( sharedImageUri: Uri?, + imageSaved: Boolean, + onSaveImage: () -> Unit, dependencyUiState: OcrFromShareViewModel.OcrDependencyViewersUiState?, shareSource: OcrFromShareViewModel.ShareSourceUiState, onReturnToShareApp: () -> Unit, @@ -147,6 +158,8 @@ private fun OcrFromShareScreenExpanded( // Bias the crop window left for max recall to be visible // Right side contains nearly useless partner image for OCR data correction alignment = BiasAlignment(-0.42f, 0f), + imageSaved = imageSaved, + onSaveImage = onSaveImage, ) Column( @@ -172,6 +185,8 @@ private fun OcrFromShareScreenExpanded( @Composable private fun OcrFromShareScreenCompact( sharedImageUri: Uri?, + imageSaved: Boolean, + onSaveImage: () -> Unit, dependencyUiState: OcrFromShareViewModel.OcrDependencyViewersUiState?, shareSource: OcrFromShareViewModel.ShareSourceUiState, onReturnToShareApp: () -> Unit, @@ -206,6 +221,8 @@ private fun OcrFromShareScreenCompact( Modifier .fillMaxWidth() .aspectRatio(16f / 9f), + imageSaved = imageSaved, + onSaveImage = onSaveImage, ) resultSection() @@ -266,45 +283,45 @@ fun OcrFromShareScreen( } } + val handleSaveImage = { viewModel.saveImage() } + val requestWriteStoragePermissionLauncher = + rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { if (it) handleSaveImage() } + + var showSavePicturePermissionRequiredDialog by rememberSaveable { mutableStateOf(false) } + if (showSavePicturePermissionRequiredDialog) { + PermissionRequiredDialog( + onDismiss = { showSavePicturePermissionRequiredDialog = false }, + onConfirm = { + requestWriteStoragePermissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) + showSavePicturePermissionRequiredDialog = false + }, + functionName = stringResource(R.string.permission_function_save_image), + permissions = listOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), + ) + } + + val onSaveImage = { + if (SaveImageToGallery.checkPermission(context)) { + handleSaveImage() + } else { + showSavePicturePermissionRequiredDialog = true + } + } + val actionsSection: @Composable ColumnScope.() -> Unit = remember { movableContentOf { - val handleSaveImage = { viewModel.saveImage() } - val requestWriteStoragePermissionLauncher = - rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission(), - ) { if (it) handleSaveImage() } - - var showSavePicturePermissionRequiredDialog by rememberSaveable { mutableStateOf(false) } - if (showSavePicturePermissionRequiredDialog) { - PermissionRequiredDialog( - onDismiss = { showSavePicturePermissionRequiredDialog = false }, - onConfirm = { - requestWriteStoragePermissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) - showSavePicturePermissionRequiredDialog = false - }, - functionName = stringResource(R.string.permission_function_save_image), - permissions = listOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), - ) - } - OcrFromShareActions( hasPlayResult = resultState is OcrResultState.Done, playResultSaveState = playResultSaveState, - imageSaved = imageSaved, warnings = warnings, onSavePlayResult = { coroutineScope.launch { viewModel.savePlayResult() } }, onCachePlayResult = { // TODO: coroutineScope.launch { viewModel.cachePlayResult(context) } Toast.makeText(context, "TODO", Toast.LENGTH_SHORT).show() }, - onSaveImage = { - if (SaveImageToGallery.checkPermission(context)) { - handleSaveImage() - } else { - showSavePicturePermissionRequiredDialog = true - } - }, ) } } @@ -322,6 +339,8 @@ fun OcrFromShareScreen( if (landscape) { OcrFromShareScreenExpanded( sharedImageUri = sharedImageUri, + imageSaved = imageSaved, + onSaveImage = onSaveImage, dependencyUiState = dependencyUiState, shareSource = shareSource, onReturnToShareApp = onReturnToShareApp, @@ -333,6 +352,8 @@ fun OcrFromShareScreen( } else { OcrFromShareScreenCompact( sharedImageUri = sharedImageUri, + imageSaved = imageSaved, + onSaveImage = onSaveImage, dependencyUiState = dependencyUiState, shareSource = shareSource, onReturnToShareApp = onReturnToShareApp, @@ -350,6 +371,8 @@ private fun OcrFromShareImage( modifier: Modifier = Modifier, contentScale: ContentScale = ContentScale.Fit, alignment: Alignment = Alignment.Center, + imageSaved: Boolean, + onSaveImage: () -> Unit, ) { var showImagePreview by rememberSaveable { mutableStateOf(false) } val imageState = rememberAsyncImageState() @@ -363,7 +386,17 @@ private fun OcrFromShareImage( } } - Box(modifier.clickable(enabled = uri != null) { showImagePreview = true }) { + // Image tap and the fullscreen button share an interaction source + // The press ripple draws on the button rather than the whole image + val imageInteractionSource = remember { MutableInteractionSource() } + + Box( + modifier.clickable( + interactionSource = imageInteractionSource, + indication = null, + enabled = uri != null, + ) { showImagePreview = true }, + ) { if (uri == null) { // placeholder box to keep layout stable Box( @@ -393,6 +426,49 @@ private fun OcrFromShareImage( contentScale = contentScale, alignment = alignment, ) + + Column( + Modifier + .align(Alignment.BottomStart) + .padding(MaterialTheme.spacing.sm), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.xxs), + ) { + FilledTonalButton( + onClick = { showImagePreview = true }, + interactionSource = imageInteractionSource, + ) { + IconRow { + Icon(Icons.Default.Fullscreen, null) + Text(stringResource(R.string.ocr_from_share_fullscreen)) + } + } + + FilledTonalButton( + onClick = onSaveImage, + enabled = !imageSaved, + // Disabled colors are explicit since the defaults are alpha-faded and the + // image would show through the button + colors = + ButtonDefaults.filledTonalButtonColors( + disabledContainerColor = MaterialTheme.colorScheme.secondaryContainer, + disabledContentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + ) { + AnimatedContent(imageSaved) { saved -> + if (saved) { + IconRow { + Icon(Icons.Default.Check, null) + Text(stringResource(R.string.ocr_from_share_image_saved)) + } + } else { + IconRow { + Icon(Icons.Default.FileDownload, null) + Text(stringResource(R.string.ocr_from_share_save_image_button)) + } + } + } + } + } } } } diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index cfd3d9d8..f07d510f 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -100,6 +100,7 @@ 等待 OCR 结果…… + 全屏 保存图片 图片已保存 暂存 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2ff4c569..77b161ef 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -116,6 +116,7 @@ CC BY-SA 3.0 Waiting for OCR… + Fullscreen Save Image Image Saved Cache From 3403132b1cbba021acbe4e96d7d9b083d23d74d4 Mon Sep 17 00:00:00 2001 From: 283375 Date: Thu, 5 Nov 2026 23:44:43 +0800 Subject: [PATCH 09/19] ocr from share chart edit --- .../ocrfromshare/OcrFromShareOcrResult.kt | 48 +++++++++++++++++-- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrResult.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrResult.kt index 0f672372..a378a0f0 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrResult.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrResult.kt @@ -8,9 +8,12 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -39,8 +42,10 @@ import xyz.sevive.arcaeaoffline.core.database.entities.ChartInfo import xyz.sevive.arcaeaoffline.core.database.entities.DifficultyWithSong import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidatorWarning +import xyz.sevive.arcaeaoffline.ui.components.ArcaeaChartSelector import xyz.sevive.arcaeaoffline.ui.components.ArcaeaPlayResultCard import xyz.sevive.arcaeaoffline.ui.components.ArcaeaPlayResultEditorDialog +import xyz.sevive.arcaeaoffline.ui.components.BasicAlertDialogSurface import xyz.sevive.arcaeaoffline.ui.components.DifficultyWithSongDisplay import xyz.sevive.arcaeaoffline.ui.components.IconRow import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme @@ -69,6 +74,22 @@ internal fun OcrFromShareOcrResult( ) } + var showChartSelector by rememberSaveable { mutableStateOf(false) } + + if (showChartSelector && playResult != null) { + BasicAlertDialogSurface(onDismissRequest = { showChartSelector = false }) { + ArcaeaChartSelector( + songId = playResult.songId, + ratingClass = playResult.ratingClass, + onDifficultyChange = { + onSetPlayResult(playResult.copy(songId = it.songId, ratingClass = it.ratingClass)) + // do not close dialog here since the selector will fire its initial selection + // when the dialog opens + }, + ) + } + } + // Size changes between the waiting, error, and result branches animate instead of jumping. Box(Modifier.animateContentSize()) { if (playResult != null) { @@ -82,11 +103,28 @@ internal fun OcrFromShareOcrResult( ) AnimatedVisibility(visible = !playResultSaved && !playResultCached) { - IconButton(onClick = { showPlayResultEditorDialog = true }) { - Icon( - Icons.Default.Edit, - contentDescription = stringResource(R.string.general_edit), - ) + Box(Modifier.wrapContentSize(Alignment.TopEnd)) { + var showEditMenu by rememberSaveable { mutableStateOf(false) } + IconButton(onClick = { showEditMenu = true }) { + Icon(Icons.Default.Edit, contentDescription = null) + } + + DropdownMenu(expanded = showEditMenu, onDismissRequest = { showEditMenu = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.arcaea_play_result)) }, + onClick = { + showPlayResultEditorDialog = true + showEditMenu = false + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.arcaea_chart)) }, + onClick = { + showChartSelector = true + showEditMenu = false + }, + ) + } } } } From 61d06f039b41de350f645d32aed6b683f46416ed Mon Sep 17 00:00:00 2001 From: 283375 Date: Fri, 6 Nov 2026 01:02:42 +0800 Subject: [PATCH 10/19] save image exception handling --- .../permissions/storage/SaveImageToGallery.kt | 117 +++++++++++------- .../ocrfromshare/OcrFromShareScreen.kt | 22 +++- .../ocrfromshare/OcrFromShareViewModel.kt | 17 ++- app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 5 files changed, 110 insertions(+), 48 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/permissions/storage/SaveImageToGallery.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/permissions/storage/SaveImageToGallery.kt index 4eb8da90..9d53aa99 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/permissions/storage/SaveImageToGallery.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/permissions/storage/SaveImageToGallery.kt @@ -10,6 +10,7 @@ import android.provider.MediaStore import androidx.core.content.ContextCompat import co.touchlab.kermit.Logger import java.io.File +import java.io.IOException /** Writes image bytes into the public Pictures gallery. */ object SaveImageToGallery { @@ -36,66 +37,94 @@ object SaveImageToGallery { * @see link * @param dateTakenMillis The image's logical date in epoch millis, for gallery apps use it for * timeline ordering on API 29+. + * @throws IOException If writing the image fails, including MediaStore rejecting the entry. + * @throws SecurityException On API < 29 when [checkPermission] is false. */ fun saveImageBytes( context: Context, fileName: String, bytes: ByteArray, dateTakenMillis: Long, - ): Boolean { + ) { logger.i { "Saving $fileName to gallery" } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - if (!checkPermission(context)) return false + saveImageBytesPreQ(context, fileName, bytes) + } else { + saveImageBytesQPlus(context, fileName, bytes, dateTakenMillis) + } + } - val parentDir = - File( - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), - DIRECTORY, - ) + private fun saveImageBytesPreQ( + context: Context, + fileName: String, + bytes: ByteArray, + ) { + if (!checkPermission(context)) { + throw SecurityException("WRITE_EXTERNAL_STORAGE permission is not granted") + } + + val parentDir = + File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), + DIRECTORY, + ) + + if (!parentDir.exists()) { + logger.i { "Creating parent dir ${parentDir.path}, success: ${parentDir.mkdirs()}" } + } + + // Q+ MediaProvider renames duplicate display names itself. + // Older API would silently overwrite, so pick a free name instead. + var target = File(parentDir, fileName) + if (target.exists()) { + val dot = fileName.lastIndexOf('.') + val base = if (dot < 0) fileName else fileName.substring(0, dot) + val ext = if (dot < 0) "" else fileName.substring(dot) - if (!parentDir.exists()) { - logger.i { "Creating parent dir, success: ${parentDir.mkdirs()}" } + var index = 1 + while (target.exists()) { + target = File(parentDir, "$base ($index)$ext") + index++ } + } + target.outputStream().use { it.write(bytes) } + } - // Q+ MediaProvider renames duplicate display names itself. - // Older API would silently overwrite, so pick a free name instead. - var target = File(parentDir, fileName) - if (target.exists()) { - val dot = fileName.lastIndexOf('.') - val base = if (dot < 0) fileName else fileName.substring(0, dot) - val ext = if (dot < 0) "" else fileName.substring(dot) - - var index = 1 - while (target.exists()) { - target = File(parentDir, "$base ($index)$ext") - index++ - } + private fun saveImageBytesQPlus( + context: Context, + fileName: String, + bytes: ByteArray, + dateTakenMillis: Long, + ) { + val values = + ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) + put( + MediaStore.MediaColumns.RELATIVE_PATH, + "${Environment.DIRECTORY_PICTURES}/$DIRECTORY", + ) + put(MediaStore.Images.Media.DATE_TAKEN, dateTakenMillis) + put(MediaStore.MediaColumns.IS_PENDING, 1) } - target.outputStream().use { it.write(bytes) } - } else { - val values = - ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) - put( - MediaStore.MediaColumns.RELATIVE_PATH, - "${Environment.DIRECTORY_PICTURES}/$DIRECTORY", - ) - put(MediaStore.Images.Media.DATE_TAKEN, dateTakenMillis) - put(MediaStore.MediaColumns.IS_PENDING, 1) - } - val resolver = context.contentResolver - val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) - uri - ?.let { resolver.openOutputStream(it) } - ?.use { it.write(bytes) } + val resolver = context.contentResolver + val uri = + resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) + ?: throw IOException("Failed to create a MediaStore entry for $fileName") - values.clear() - values.put(MediaStore.MediaColumns.IS_PENDING, 0) - uri?.also { resolver.update(it, values, null, null) } - } + val outputStream = + resolver.openOutputStream(uri) + ?: run { + // Nothing was written, removing the pending placeholder + resolver.delete(uri, null, null) + throw IOException("Failed to open the MediaStore entry for writing") + } + + outputStream.use { it.write(bytes) } - return true + values.clear() + values.put(MediaStore.MediaColumns.IS_PENDING, 0) + resolver.update(uri, values, null, null) } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt index 5062c5b1..55f47334 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt @@ -45,6 +45,8 @@ import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -137,12 +139,13 @@ private fun OcrFromShareScreenExpanded( onReturnToShareApp: () -> Unit, onStayInApp: () -> Unit, onNavigateToDependencies: () -> Unit, + snackbarHost: @Composable () -> Unit, resultSection: @Composable ColumnScope.() -> Unit, actionsSection: @Composable ColumnScope.() -> Unit, ) { // Top bar removed since it takes a certain amount of height but contains no useful information // Especially useless on landscape phones - Scaffold { innerPadding -> + Scaffold(snackbarHost = snackbarHost) { innerPadding -> Row( Modifier .fillMaxSize() @@ -192,10 +195,14 @@ private fun OcrFromShareScreenCompact( onReturnToShareApp: () -> Unit, onStayInApp: () -> Unit, onNavigateToDependencies: () -> Unit, + snackbarHost: @Composable () -> Unit, resultSection: @Composable ColumnScope.() -> Unit, actionsSection: @Composable ColumnScope.() -> Unit, ) { - Scaffold(topBar = { OcrFromShareTopBar() }) { innerPadding -> + Scaffold( + topBar = { OcrFromShareTopBar() }, + snackbarHost = snackbarHost, + ) { innerPadding -> Column( modifier = Modifier @@ -335,6 +342,15 @@ fun OcrFromShareScreen( } } + val snackbarHostState = remember { SnackbarHostState() } + LaunchedEffect(viewModel, lifecycleOwner) { + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.events.collect { event -> + snackbarHostState.showSnackbar(event.asString(context.resources), withDismissAction = true) + } + } + } + val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE if (landscape) { OcrFromShareScreenExpanded( @@ -346,6 +362,7 @@ fun OcrFromShareScreen( onReturnToShareApp = onReturnToShareApp, onStayInApp = onStayInApp, onNavigateToDependencies = navigateToDependencies, + snackbarHost = { SnackbarHost(snackbarHostState) }, resultSection = resultSection, actionsSection = actionsSection, ) @@ -359,6 +376,7 @@ fun OcrFromShareScreen( onReturnToShareApp = onReturnToShareApp, onStayInApp = onStayInApp, onNavigateToDependencies = navigateToDependencies, + snackbarHost = { SnackbarHost(snackbarHostState) }, resultSection = resultSection, actionsSection = actionsSection, ) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt index 7adb2677..dd3fcc69 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt @@ -35,6 +35,7 @@ import kotlinx.datetime.toLocalDateTime import kotlinx.io.asOutputStream import kotlinx.io.buffered import kotlinx.io.files.SystemFileSystem +import xyz.sevive.arcaeaoffline.R import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult import xyz.sevive.arcaeaoffline.core.database.repositories.ChartInfoRepository import xyz.sevive.arcaeaoffline.core.database.repositories.DifficultyWithSongRepository @@ -52,6 +53,8 @@ import xyz.sevive.arcaeaoffline.permissions.storage.SaveImageToGallery import xyz.sevive.arcaeaoffline.ui.components.DifficultyWithSongDisplay import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyCrnnModelStatusUiState import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyImageHashesDatabaseStatusUiState +import xyz.sevive.arcaeaoffline.ui.helpers.UiEvent +import xyz.sevive.arcaeaoffline.ui.helpers.UiEventChannel import java.io.IOException import kotlin.concurrent.Volatile import kotlin.time.Clock @@ -107,6 +110,9 @@ class OcrFromShareViewModel( private val logger = Logger.withTag(LOG_TAG) + private val eventChannel = UiEventChannel() + val events = eventChannel.events + private val _ocrDependencyViewersUiState = MutableStateFlow(null) val ocrDependencyViewersUiState = _ocrDependencyViewersUiState.asStateFlow() @@ -289,8 +295,15 @@ class OcrFromShareViewModel( } } - _imageSaved.value = - SaveImageToGallery.saveImageBytes(appContext, fileName, bytes, dateTaken.toEpochMilliseconds()) + SaveImageToGallery.saveImageBytes(appContext, fileName, bytes, dateTaken.toEpochMilliseconds()) + _imageSaved.value = true + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.e(e) { "Error occurred while saving image" } + eventChannel.send( + UiEvent.StringRes(R.string.ocr_from_share_image_save_failed, listOf(e.toString())), + ) } finally { imageSaveInFlight = false } diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index f07d510f..cdcb3acd 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -103,6 +103,7 @@ 全屏 保存图片 图片已保存 + 图片保存失败 (%s) 暂存 记录已保存 OCR 出错 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 77b161ef..3f0c5974 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -119,6 +119,7 @@ CC BY-SA 3.0 Fullscreen Save Image Image Saved + Failed to save image (%s) Cache Play Saved OCR Error From 8f4aff498603eaa2fa3d36b9ff3c35a50a6376f2 Mon Sep 17 00:00:00 2001 From: 283375 Date: Fri, 6 Nov 2026 01:34:59 +0800 Subject: [PATCH 11/19] add image preview failing status component --- .../ocrfromshare/OcrFromShareScreen.kt | 52 ++++++++++++------- gradle/libs.versions.toml | 4 +- 2 files changed, 36 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt index 55f47334..d741a8fa 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt @@ -39,10 +39,12 @@ import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.FileDownload import androidx.compose.material.icons.filled.Fullscreen import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.outlined.BrokenImage import androidx.compose.material.icons.outlined.Image import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost @@ -60,9 +62,12 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.BiasAlignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle @@ -75,7 +80,7 @@ import com.github.panpf.sketch.rememberAsyncImageState import kotlinx.coroutines.launch import xyz.sevive.arcaeaoffline.MainActivity import xyz.sevive.arcaeaoffline.R -import xyz.sevive.arcaeaoffline.helpers.secondaryItemAlpha +import xyz.sevive.arcaeaoffline.helpers.SECONDARY_ALPHA import xyz.sevive.arcaeaoffline.permissions.storage.SaveImageToGallery import xyz.sevive.arcaeaoffline.ui.activities.ocrfromshare.OcrFromShareViewModel.OcrResultState import xyz.sevive.arcaeaoffline.ui.common.PermissionRequiredDialog @@ -251,6 +256,7 @@ fun OcrFromShareScreen( viewModel: OcrFromShareViewModel, ) { val context = LocalContext.current + val res = LocalResources.current val coroutineScope = rememberCoroutineScope() val navigateToDependencies = { @@ -346,7 +352,7 @@ fun OcrFromShareScreen( LaunchedEffect(viewModel, lifecycleOwner) { lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.events.collect { event -> - snackbarHostState.showSnackbar(event.asString(context.resources), withDismissAction = true) + snackbarHostState.showSnackbar(event.asString(res), withDismissAction = true) } } } @@ -416,26 +422,19 @@ private fun OcrFromShareImage( ) { showImagePreview = true }, ) { if (uri == null) { - // placeholder box to keep layout stable - Box( - Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainer), - contentAlignment = Alignment.Center, - ) { - Icon( - Icons.Outlined.Image, - contentDescription = null, - Modifier - .size(48.dp) - .secondaryItemAlpha(), - ) - } + ImagePlaceholder(Icons.Outlined.Image, LocalContentColor.current.copy(alpha = SECONDARY_ALPHA)) } else { - if (imageState.painterState == null || imageState.painterState is PainterState.Loading) { + val painterState = imageState.painterState + + if (painterState == null || painterState is PainterState.Loading) { ImageSkeleton(Modifier.fillMaxSize()) } + // TODO: this does not actually invoke, sketch or our issue? + if (painterState is PainterState.Error) { + ImagePlaceholder(Icons.Outlined.BrokenImage, MaterialTheme.colorScheme.error) + } + AsyncImage( uri = uri.toString(), contentDescription = null, @@ -491,6 +490,23 @@ private fun OcrFromShareImage( } } +/** Solid placeholder with a centered icon, shown while the uri is absent or its image failed to load. */ +@Composable +private fun ImagePlaceholder( + icon: ImageVector, + tint: Color, + modifier: Modifier = Modifier, +) { + Box( + modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer), + contentAlignment = Alignment.Center, + ) { + Icon(icon, contentDescription = null, Modifier.size(48.dp), tint = tint) + } +} + @Composable private fun ImageSkeleton(modifier: Modifier = Modifier) { val alpha by diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d5cb2d3b..9047e3cc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,8 +21,8 @@ apache-commons-compress = "1.28.0" io-sentry-sentryAndroid = "8.17.0" markdown-renderer = "0.41.0" -panpf-sketch = "4.5.0-beta05" -panpf-zoomimage = "1.5.0-beta02" +panpf-sketch = "4.6.0" +panpf-zoomimage = "1.6.0" github-cheonjaeung-gridlayout = "2.7.4" aboutlibraries = "14.2.1" kermit = "2.1.0" From 6a5322df1e740ef72b69cdb181ce660621c939ce Mon Sep 17 00:00:00 2001 From: 283375 Date: Fri, 6 Nov 2026 01:46:03 +0800 Subject: [PATCH 12/19] extract DifficultyWithSongDisplay --- .../ocrfromshare/OcrFromShareOcrResult.kt | 2 +- .../ocrfromshare/OcrFromShareViewModel.kt | 2 +- .../ui/components/ArcaeaChartCard.kt | 11 +---------- .../ui/helpers/DifficultyWithSongDisplay.kt | 18 ++++++++++++++++++ 4 files changed, 21 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/DifficultyWithSongDisplay.kt diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrResult.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrResult.kt index a378a0f0..61dfd2e2 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrResult.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrResult.kt @@ -46,8 +46,8 @@ import xyz.sevive.arcaeaoffline.ui.components.ArcaeaChartSelector import xyz.sevive.arcaeaoffline.ui.components.ArcaeaPlayResultCard import xyz.sevive.arcaeaoffline.ui.components.ArcaeaPlayResultEditorDialog import xyz.sevive.arcaeaoffline.ui.components.BasicAlertDialogSurface -import xyz.sevive.arcaeaoffline.ui.components.DifficultyWithSongDisplay import xyz.sevive.arcaeaoffline.ui.components.IconRow +import xyz.sevive.arcaeaoffline.ui.helpers.DifficultyWithSongDisplay import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme import xyz.sevive.arcaeaoffline.ui.theme.spacing import kotlin.time.Instant diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt index dd3fcc69..2e95402e 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt @@ -50,9 +50,9 @@ import xyz.sevive.arcaeaoffline.helpers.DeviceOcrHelper import xyz.sevive.arcaeaoffline.helpers.OcrDependencyLoader import xyz.sevive.arcaeaoffline.helpers.OcrDependencyStatusBuilder import xyz.sevive.arcaeaoffline.permissions.storage.SaveImageToGallery -import xyz.sevive.arcaeaoffline.ui.components.DifficultyWithSongDisplay import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyCrnnModelStatusUiState import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyImageHashesDatabaseStatusUiState +import xyz.sevive.arcaeaoffline.ui.helpers.DifficultyWithSongDisplay import xyz.sevive.arcaeaoffline.ui.helpers.UiEvent import xyz.sevive.arcaeaoffline.ui.helpers.UiEventChannel import java.io.IOException diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaChartCard.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaChartCard.kt index 8ab49c0e..268d80c0 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaChartCard.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaChartCard.kt @@ -44,19 +44,10 @@ import xyz.sevive.arcaeaoffline.core.database.entities.DifficultyWithSong import xyz.sevive.arcaeaoffline.core.database.repositories.ChartInfoRepository import xyz.sevive.arcaeaoffline.core.database.repositories.DifficultyWithSongRepository import xyz.sevive.arcaeaoffline.ui.helpers.ArcaeaFormatters +import xyz.sevive.arcaeaoffline.ui.helpers.DifficultyWithSongDisplay import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme import xyz.sevive.arcaeaoffline.ui.theme.spacing -// Display data resolved as one atomic unit. Swapping the pair together is -// what keeps the card consistent (title/level text and constant change in -// the same frame) and lets the card's AnimatedContent keep its old -> new -// transition: the previous pair is held while the next one is resolving, -// instead of dropping the card to its placeholder. -data class DifficultyWithSongDisplay( - val difficultyWithSong: DifficultyWithSong, - val chartInfo: ChartInfo?, -) - @Composable internal fun rememberArcaeaChartDisplay( songId: String?, diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/DifficultyWithSongDisplay.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/DifficultyWithSongDisplay.kt new file mode 100644 index 00000000..f8975521 --- /dev/null +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/DifficultyWithSongDisplay.kt @@ -0,0 +1,18 @@ +package xyz.sevive.arcaeaoffline.ui.helpers + +import xyz.sevive.arcaeaoffline.core.database.entities.ChartInfo +import xyz.sevive.arcaeaoffline.core.database.entities.DifficultyWithSong + +/** + * Display data for one chart, resolved as one atomic unit. + * + * The chart card relies on the pair swapping together: title/level text and constant stay + * consistent within one frame, and the previous pair is held while the next one is resolving, + * so the card cross-fades instead of dropping to its placeholder. + * + * [chartInfo] is null when the chart is absent from the local chart database. + */ +data class DifficultyWithSongDisplay( + val difficultyWithSong: DifficultyWithSong, + val chartInfo: ChartInfo?, +) From 6e274ddefead36905b617db0da0c77487b6bc41d Mon Sep 17 00:00:00 2001 From: 283375 Date: Fri, 6 Nov 2026 01:53:19 +0800 Subject: [PATCH 13/19] fix initial route issues --- .../main/java/xyz/sevive/arcaeaoffline/MainScreen.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/MainScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/MainScreen.kt index e39532d9..2d2509a0 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/MainScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/MainScreen.kt @@ -8,6 +8,9 @@ import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffo import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect 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.res.stringResource import androidx.navigation.NavController @@ -42,8 +45,14 @@ fun MainScreen( val currentRoute = navBackStackEntry?.destination?.route val currentRouteRoot = currentRoute?.split("/") + // The intent extra survives activity recreation (rotation, process-death restore, etc.), + // so consuming the initial route to prevent unexpected extra navigations. + // A new jump recreates the activity via CLEAR_TOP, which starts with fresh state. + var initialRouteConsumed by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(initialNavigateRoute) { val route = initialNavigateRoute ?: return@LaunchedEffect + if (initialRouteConsumed) return@LaunchedEffect val tabRoute = route.substringBefore('/') if (MainScreen.entries.none { it.route == tabRoute }) return@LaunchedEffect @@ -59,6 +68,8 @@ fun MainScreen( } launchSingleTop = true } + + initialRouteConsumed = true } val navigationSuiteColors = From 26f3f0400f424f2f0f9c2f503693ce09e5556a54 Mon Sep 17 00:00:00 2001 From: 283375 Date: Fri, 6 Nov 2026 03:01:22 +0800 Subject: [PATCH 14/19] wrap ort session with use --- .../ocrfromshare/OcrFromShareViewModel.kt | 44 +++++++++---------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt index 2e95402e..9a5d2fce 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt @@ -340,38 +340,36 @@ class OcrFromShareViewModel( _ocrResultState.value = OcrResultState.Waiting withContext(Dispatchers.IO) { - val ortSession = DeviceOcrOnnxHelper.createOrtSession(appContext) - try { - val imageHashesSQLiteDatabase = - OcrDependencyLoader.imageHashesSQLiteDatabase() - - val ocrResult = - imageHashesSQLiteDatabase.use { sqliteDb -> - val imageHashesDatabase = ImageHashesDatabase(sqliteDb) - DeviceOcrHelper.ocrImage( + DeviceOcrOnnxHelper.createOrtSession(appContext).use { ortSession -> + val imageHashesSQLiteDatabase = + OcrDependencyLoader.imageHashesSQLiteDatabase() + + val ocrResult = + imageHashesSQLiteDatabase.use { sqliteDb -> + val imageHashesDatabase = ImageHashesDatabase(sqliteDb) + DeviceOcrHelper.ocrImage( + uri, + imageHashesDatabase, + ortSession = ortSession, + ) + } + val playResult = + DeviceOcrHelper.ocrResultToPlayResult( uri, - imageHashesDatabase, - ortSession = ortSession, + appContext, + ocrResult, + fallbackDate = Clock.System.now(), ) - } - val playResult = - DeviceOcrHelper.ocrResultToPlayResult( - uri, - appContext, - ocrResult, - fallbackDate = Clock.System.now(), - ) - - _ocrResultState.value = OcrResultState.Done(playResult) + + _ocrResultState.value = OcrResultState.Done(playResult) + } } catch (e: CancellationException) { throw e } catch (e: Exception) { _ocrResultState.value = OcrResultState.Failed(e) logger.e(e) { "Error occurred during OCR" } - } finally { - ortSession.close() } } } From 21a768438fe99df9456f3dcd800cdbe5b0eb68a8 Mon Sep 17 00:00:00 2001 From: 283375 Date: Fri, 6 Nov 2026 03:16:44 +0800 Subject: [PATCH 15/19] MediaStore exception and mime type handling --- .../permissions/storage/SaveImageToGallery.kt | 38 ++++++++++++------- .../ocrfromshare/OcrFromShareViewModel.kt | 10 ++++- 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/permissions/storage/SaveImageToGallery.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/permissions/storage/SaveImageToGallery.kt index 9d53aa99..c9b0a2f7 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/permissions/storage/SaveImageToGallery.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/permissions/storage/SaveImageToGallery.kt @@ -37,6 +37,7 @@ object SaveImageToGallery { * @see link * @param dateTakenMillis The image's logical date in epoch millis, for gallery apps use it for * timeline ordering on API 29+. + * @param mimeType Override MediaStore's mime type prediction * @throws IOException If writing the image fails, including MediaStore rejecting the entry. * @throws SecurityException On API < 29 when [checkPermission] is false. */ @@ -45,13 +46,14 @@ object SaveImageToGallery { fileName: String, bytes: ByteArray, dateTakenMillis: Long, + mimeType: String, ) { logger.i { "Saving $fileName to gallery" } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { saveImageBytesPreQ(context, fileName, bytes) } else { - saveImageBytesQPlus(context, fileName, bytes, dateTakenMillis) + saveImageBytesQPlus(context, fileName, bytes, dateTakenMillis, mimeType) } } @@ -88,7 +90,13 @@ object SaveImageToGallery { index++ } } - target.outputStream().use { it.write(bytes) } + try { + target.outputStream().use { it.write(bytes) } + } catch (e: Exception) { + // A failed save must not leave a partial file behind + target.delete() + throw e + } } private fun saveImageBytesQPlus( @@ -96,10 +104,12 @@ object SaveImageToGallery { fileName: String, bytes: ByteArray, dateTakenMillis: Long, + mimeType: String, ) { val values = ContentValues().apply { put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) + put(MediaStore.MediaColumns.MIME_TYPE, mimeType) put( MediaStore.MediaColumns.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/$DIRECTORY", @@ -113,18 +123,20 @@ object SaveImageToGallery { resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) ?: throw IOException("Failed to create a MediaStore entry for $fileName") - val outputStream = - resolver.openOutputStream(uri) - ?: run { - // Nothing was written, removing the pending placeholder - resolver.delete(uri, null, null) - throw IOException("Failed to open the MediaStore entry for writing") - } + try { + val outputStream = + resolver.openOutputStream(uri) + ?: throw IOException("Failed to open the MediaStore entry for writing") - outputStream.use { it.write(bytes) } + outputStream.use { it.write(bytes) } - values.clear() - values.put(MediaStore.MediaColumns.IS_PENDING, 0) - resolver.update(uri, values, null, null) + values.clear() + values.put(MediaStore.MediaColumns.IS_PENDING, 0) + resolver.update(uri, values, null, null) + } catch (e: Exception) { + // A failed save must not leave the IS_PENDING placeholder behind + resolver.delete(uri, null, null) + throw e + } } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt index 9a5d2fce..f60c91ea 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt @@ -295,7 +295,13 @@ class OcrFromShareViewModel( } } - SaveImageToGallery.saveImageBytes(appContext, fileName, bytes, dateTaken.toEpochMilliseconds()) + SaveImageToGallery.saveImageBytes( + appContext, + fileName, + bytes, + dateTaken.toEpochMilliseconds(), + imageFileMimeType(shared.bytes), + ) _imageSaved.value = true } catch (e: CancellationException) { throw e @@ -387,3 +393,5 @@ private val imageFileDateFormat = } private fun imageFileExtension(bytes: ByteArray): String = MediaFormat.detect(bytes)?.fileNameExtensions?.firstOrNull() ?: "jpg" + +private fun imageFileMimeType(bytes: ByteArray): String = MediaFormat.detect(bytes)?.mimeType ?: "image/jpeg" From aca3732fb0ccb25f708bfb4f790c5ec3b33f7add Mon Sep 17 00:00:00 2001 From: 283375 Date: Fri, 6 Nov 2026 03:35:37 +0800 Subject: [PATCH 16/19] fix retry and activity recreate handling --- .../ocrfromshare/OcrFromShareScreen.kt | 6 +- .../ocrfromshare/OcrFromShareViewModel.kt | 79 ++++++++++++------- app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 4 files changed, 56 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt index d741a8fa..3f7efd2b 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt @@ -56,7 +56,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.movableContentOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -257,7 +256,6 @@ fun OcrFromShareScreen( ) { val context = LocalContext.current val res = LocalResources.current - val coroutineScope = rememberCoroutineScope() val navigateToDependencies = { // User should expect returning to this activity after setting up, @@ -330,9 +328,9 @@ fun OcrFromShareScreen( hasPlayResult = resultState is OcrResultState.Done, playResultSaveState = playResultSaveState, warnings = warnings, - onSavePlayResult = { coroutineScope.launch { viewModel.savePlayResult() } }, + onSavePlayResult = { viewModel.savePlayResult() }, onCachePlayResult = { - // TODO: coroutineScope.launch { viewModel.cachePlayResult(context) } + // TODO: viewModel.cachePlayResult(context) Toast.makeText(context, "TODO", Toast.LENGTH_SHORT).show() }, ) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt index f60c91ea..e0bd5b97 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt @@ -215,37 +215,43 @@ class OcrFromShareViewModel( /** Single entry for Activity */ fun onImageShared(uri: Uri) { + // The Activity re-delivers the share intent on recreation, so holding a flag here + // to prevent dropping the user's edits and the saved state. + if (_sharedImageUri.value == uri) return + + _sharedImageUri.value = uri + viewModelScope.launch { readImageAndStartOcr(uri) } + } + + private suspend fun readImageAndStartOcr(uri: Uri) { val sharedAt = Clock.System.now() - viewModelScope.launch { - try { - withContext(Dispatchers.IO) { - val bytes = - appContext.contentResolver.openInputStream(uri)?.use { it.readBytes() } - ?: throw IOException("Cannot open shared image stream") - - val sourceTakenAt = - runCatching { - DeviceOcrHelper.readImageDateFromExif(ByteArrayByteReader(bytes)) - }.getOrNull() - - sharedImage = SharedImage(uri, sharedAt, sourceTakenAt, bytes) - _sharedImageUri.value = uri - } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - _ocrResultState.value = OcrResultState.Failed(Exception("Error reading image", e)) - return@launch - } + try { + withContext(Dispatchers.IO) { + val bytes = + appContext.contentResolver.openInputStream(uri)?.use { it.readBytes() } + ?: throw IOException("Cannot open shared image stream") - startOcr(uri) + val sourceTakenAt = + runCatching { + DeviceOcrHelper.readImageDateFromExif(ByteArrayByteReader(bytes)) + }.getOrNull() + + sharedImage = SharedImage(uri, sharedAt, sourceTakenAt, bytes) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _ocrResultState.value = OcrResultState.Failed(Exception("Error reading image", e)) + return } + + startOcr(uri) } fun retryOcr() { - val imageUri = sharedImage?.uri ?: return - viewModelScope.launch { startOcr(imageUri) } + val imageUri = _sharedImageUri.value ?: return + viewModelScope.launch { readImageAndStartOcr(imageUri) } } fun setPlayResult(playResult: PlayResult) { @@ -255,10 +261,29 @@ class OcrFromShareViewModel( private val _playResultSaveState = MutableStateFlow(PlayResultSaveState()) val playResultSaveState = _playResultSaveState.asStateFlow() - suspend fun savePlayResult() { + @Volatile + private var playResultSaveInFlight = false + + fun savePlayResult() { + if (playResultSaveInFlight || playResultSaveState.value.saved) return val playResult = (_ocrResultState.value as? OcrResultState.Done)?.playResult ?: return - playResultRepo.upsert(playResult) - _playResultSaveState.value = _playResultSaveState.value.copy(saved = true) + playResultSaveInFlight = true + + viewModelScope.launch { + try { + playResultRepo.upsert(playResult) + _playResultSaveState.value = _playResultSaveState.value.copy(saved = true) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.e(e) { "Error occurred while saving play result" } + eventChannel.send( + UiEvent.StringRes(R.string.ocr_from_share_play_result_save_failed, listOf(e.toString())), + ) + } finally { + playResultSaveInFlight = false + } + } } private val _imageSaved = MutableStateFlow(false) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index cdcb3acd..67779483 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -106,6 +106,7 @@ 图片保存失败 (%s) 暂存 记录已保存 + 记录保存失败 (%s) OCR 出错 重新识别 返回上一应用 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3f0c5974..ff82281e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -122,6 +122,7 @@ CC BY-SA 3.0 Failed to save image (%s) Cache Play Saved + Failed to save play result (%s) OCR Error Retry OCR Return to previous app From 9b77353e1de1bdb6fd8ded4deac0a8b955341470 Mon Sep 17 00:00:00 2001 From: 283375 Date: Fri, 6 Nov 2026 04:24:24 +0800 Subject: [PATCH 17/19] mediastore fix --- .../permissions/storage/SaveImageToGallery.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/permissions/storage/SaveImageToGallery.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/permissions/storage/SaveImageToGallery.kt index c9b0a2f7..a98c4847 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/permissions/storage/SaveImageToGallery.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/permissions/storage/SaveImageToGallery.kt @@ -132,10 +132,13 @@ object SaveImageToGallery { values.clear() values.put(MediaStore.MediaColumns.IS_PENDING, 0) - resolver.update(uri, values, null, null) + if (resolver.update(uri, values, null, null) == 0) { + throw IOException("Failed to finalize the MediaStore entry for $fileName") + } } catch (e: Exception) { // A failed save must not leave the IS_PENDING placeholder behind - resolver.delete(uri, null, null) + runCatching { resolver.delete(uri, null, null) } + .onFailure { logger.w(it) { "Failed to remove the pending MediaStore entry" } } throw e } } From 0bf05beb7a2668a5010e1b422226419a9d716967 Mon Sep 17 00:00:00 2001 From: 283375 Date: Fri, 6 Nov 2026 04:26:10 +0800 Subject: [PATCH 18/19] delete dead import --- .../ui/activities/ocrfromshare/OcrFromShareScreen.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt index 3f7efd2b..cdcf8b5d 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt @@ -76,7 +76,6 @@ import androidx.lifecycle.repeatOnLifecycle import com.github.panpf.sketch.AsyncImage import com.github.panpf.sketch.PainterState import com.github.panpf.sketch.rememberAsyncImageState -import kotlinx.coroutines.launch import xyz.sevive.arcaeaoffline.MainActivity import xyz.sevive.arcaeaoffline.R import xyz.sevive.arcaeaoffline.helpers.SECONDARY_ALPHA From e70234f176f038d963b179abbd599427c5f32e89 Mon Sep 17 00:00:00 2001 From: 283375 Date: Fri, 6 Nov 2026 04:47:40 +0800 Subject: [PATCH 19/19] share request state and retry waiting --- .../ocrfromshare/OcrFromShareScreen.kt | 3 +- .../ocrfromshare/OcrFromShareViewModel.kt | 32 +++++++++++-------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt index cdcf8b5d..1e43a819 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt @@ -269,7 +269,8 @@ fun OcrFromShareScreen( context.startActivity(intent) } - val sharedImageUri by viewModel.sharedImageUri.collectAsStateWithLifecycle() + val shareRequest by viewModel.shareRequest.collectAsStateWithLifecycle() + val sharedImageUri = shareRequest?.uri val dependencyUiState by viewModel.ocrDependencyViewersUiState.collectAsStateWithLifecycle() val shareSource by viewModel.shareSourceUiState.collectAsStateWithLifecycle() val resultState by viewModel.ocrResultState.collectAsStateWithLifecycle() diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt index e0bd5b97..d5e7f08d 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt @@ -95,6 +95,12 @@ class OcrFromShareViewModel( val cached: Boolean = false, ) + /** The share as delivered by the Activity, captured once at [onImageShared] and reused by retries. */ + data class ShareRequest( + val uri: Uri, + val sharedAt: Instant, + ) + /** The image this share session is about, captured once at [onImageShared]. */ @Suppress("ArrayInDataClass") data class SharedImage( @@ -210,26 +216,27 @@ class OcrFromShareViewModel( private var sharedImage: SharedImage? = null - private val _sharedImageUri = MutableStateFlow(null) - val sharedImageUri = _sharedImageUri.asStateFlow() + private val _shareRequest = MutableStateFlow(null) + val shareRequest = _shareRequest.asStateFlow() /** Single entry for Activity */ fun onImageShared(uri: Uri) { // The Activity re-delivers the share intent on recreation, so holding a flag here // to prevent dropping the user's edits and the saved state. - if (_sharedImageUri.value == uri) return + if (_shareRequest.value?.uri == uri) return - _sharedImageUri.value = uri - viewModelScope.launch { readImageAndStartOcr(uri) } + val request = ShareRequest(uri, Clock.System.now()) + _shareRequest.value = request + viewModelScope.launch { readImageAndStartOcr(request) } } - private suspend fun readImageAndStartOcr(uri: Uri) { - val sharedAt = Clock.System.now() + private suspend fun readImageAndStartOcr(request: ShareRequest) { + _ocrResultState.value = OcrResultState.Waiting try { withContext(Dispatchers.IO) { val bytes = - appContext.contentResolver.openInputStream(uri)?.use { it.readBytes() } + appContext.contentResolver.openInputStream(request.uri)?.use { it.readBytes() } ?: throw IOException("Cannot open shared image stream") val sourceTakenAt = @@ -237,7 +244,7 @@ class OcrFromShareViewModel( DeviceOcrHelper.readImageDateFromExif(ByteArrayByteReader(bytes)) }.getOrNull() - sharedImage = SharedImage(uri, sharedAt, sourceTakenAt, bytes) + sharedImage = SharedImage(request.uri, request.sharedAt, sourceTakenAt, bytes) } } catch (e: CancellationException) { throw e @@ -246,12 +253,12 @@ class OcrFromShareViewModel( return } - startOcr(uri) + startOcr(request.uri) } fun retryOcr() { - val imageUri = _sharedImageUri.value ?: return - viewModelScope.launch { readImageAndStartOcr(imageUri) } + val request = _shareRequest.value ?: return + viewModelScope.launch { readImageAndStartOcr(request) } } fun setPlayResult(playResult: PlayResult) { @@ -368,7 +375,6 @@ class OcrFromShareViewModel( private suspend fun startOcr(uri: Uri) { logger.i { "OCR from share request: $uri" } - _ocrResultState.value = OcrResultState.Waiting withContext(Dispatchers.IO) { try {