From af34b01b9a22240ba9e3d057a12fa2b3e905df9d Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Wed, 18 Feb 2026 19:29:31 +0100 Subject: [PATCH 01/33] Initial crop feature implementation --- .../github/chrisimx/scanbridge/CropScreen.kt | 179 +++++++++++ .../chrisimx/scanbridge/ScanBridgeNavHost.kt | 17 + .../chrisimx/scanbridge/ScanningScreen.kt | 118 ++++--- .../data/model/ESCLScanSettingsState.kt | 20 +- .../chrisimx/scanbridge/data/model/Session.kt | 26 +- .../ui/ScanSettingsComposableViewModel.kt | 18 +- .../scanbridge/data/ui/ScanningScreenData.kt | 22 +- .../data/ui/ScanningScreenViewModel.kt | 61 +--- .../scanbridge/uicomponents/CroppableImage.kt | 301 ++++++++++++++++++ .../scanbridge/util/ESCLKtExtensions.kt | 32 +- app/src/main/res/drawable/outline_crop_24.xml | 5 + .../main/res/drawable/outline_pan_zoom_24.xml | 5 + app/src/main/res/values/strings.xml | 5 + 13 files changed, 676 insertions(+), 133 deletions(-) create mode 100644 app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt create mode 100644 app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt create mode 100644 app/src/main/res/drawable/outline_crop_24.xml create mode 100644 app/src/main/res/drawable/outline_pan_zoom_24.xml diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt b/app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt new file mode 100644 index 0000000..62d1b74 --- /dev/null +++ b/app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt @@ -0,0 +1,179 @@ +package io.github.chrisimx.scanbridge + +import android.content.Context +import android.graphics.BitmapFactory +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconToggleButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import io.github.chrisimx.scanbridge.data.model.Session +import io.github.chrisimx.scanbridge.stores.SessionsStore +import io.github.chrisimx.scanbridge.uicomponents.CroppableAsyncImage +import io.github.chrisimx.scanbridge.uicomponents.dialog.LoadingDialog +import io.github.chrisimx.scanbridge.util.clearAndNavigateTo +import io.github.chrisimx.scanbridge.util.cropWithRect +import io.github.chrisimx.scanbridge.util.getEditedImageName +import io.github.chrisimx.scanbridge.util.saveAsJPEG +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.saket.telephoto.zoomable.EnabledZoomGestures +import me.saket.telephoto.zoomable.rememberZoomableState +import me.saket.telephoto.zoomable.zoomable + +suspend fun finishCrop(cropRect: Rect, file: String): File = withContext(Dispatchers.IO) { + val sourceBitmap = BitmapFactory.decodeFile(file) + val croppedBitmap = sourceBitmap.cropWithRect(cropRect) + + val file = File(file) + val croppedFile = File(file.parent, file.getEditedImageName()) + croppedBitmap.saveAsJPEG(croppedFile) + + return@withContext croppedFile +} + +private fun updateSessionFile( + originalSession: Session, + pageIdx: Int, + croppedFile: File, + originalImageFile: String, + context: Context, + sessionID: String +) { + val scannedPages = originalSession.scannedPages.toMutableList() + val tempPages = originalSession.tmpFiles.toMutableList() + + val oldMetaData = scannedPages.removeAt(pageIdx) + scannedPages.add( + pageIdx, + oldMetaData.copy(filePath = croppedFile.absolutePath) + ) + + tempPages.add(originalImageFile) + + val editedSession = originalSession.copy(scannedPages = scannedPages, tmpFiles = tempPages) + SessionsStore.saveSession(editedSession, context, sessionID) +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun CropScreen(sessionID: String, pageIdx: Int, returnRoute: BaseRoute, navController: NavController) { + val context = LocalContext.current + + var zoomEnabled by remember { mutableStateOf(false) } + val zoomableState = rememberZoomableState() + val coroutineScope = rememberCoroutineScope() + var currentRect by remember { mutableStateOf(Rect(0f, 0f, 1f, 1f)) } + + var processing: Boolean by remember { mutableStateOf(false) } + + val originalSession: Session? = remember { SessionsStore.loadSession(context, sessionID) } + + if (originalSession == null) { + navController.clearAndNavigateTo(StartUpScreenRoute) + return + } + + val originalImageFile = remember { originalSession.scannedPages[pageIdx].filePath } + + BackHandler { + navController.clearAndNavigateTo(returnRoute) + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + bottomBar = { + CropScreenBottomBar( + zoomEnabled, + { zoomEnabled = it } + ) { + coroutineScope.launch(Dispatchers.Main) { + if (processing) return@launch + + processing = true + val croppedFile = finishCrop(currentRect, originalImageFile) + updateSessionFile(originalSession, pageIdx, croppedFile, originalImageFile, context, sessionID) + navController.clearAndNavigateTo(returnRoute) + processing = false + } + } + } + + ) { innerPadding -> + if (processing) { + LoadingDialog(text = R.string.cropping) + } + + Box( + modifier = Modifier + .fillMaxSize() + .zoomable( + zoomableState, + gestures = if (zoomEnabled) EnabledZoomGestures.ZoomAndPan else EnabledZoomGestures.None + ), + contentAlignment = Alignment.CenterHorizontally.plus(Alignment.CenterVertically) + ) { + CroppableAsyncImage( + modifier = Modifier + .padding(innerPadding) + .padding(40.dp), + imageModel = originalImageFile, + contentDescription = stringResource(R.string.desc_scanned_page), + additionalTouchAreaAround = 100.dp, + handleTouchRadius = 60.dp + ) { + currentRect = it + } + } + } +} + +@Composable +fun CropScreenBottomBar(zoomEnabled: Boolean, setZoomEnabled: (Boolean) -> Unit, onSaveRequest: () -> Unit) { + BottomAppBar( + actions = { + IconToggleButton(zoomEnabled, onCheckedChange = setZoomEnabled) { + Icon( + painterResource(R.drawable.outline_pan_zoom_24), + stringResource(R.string.activate_zoom_gestures) + ) + } + }, + floatingActionButton = @Composable { + ExtendedFloatingActionButton( + onClick = onSaveRequest, + modifier = Modifier.testTag("crop_finish"), + icon = { + Icon( + painter = painterResource(R.drawable.outline_crop_24), + contentDescription = stringResource(R.string.crop) + ) + }, + text = { Text(stringResource(R.string.crop_button)) } + ) + } + ) +} diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt b/app/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt index 440e65c..4c7a1e6 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt @@ -35,6 +35,7 @@ import io.github.chrisimx.scanbridge.util.doTempFilesExist import io.ktor.http.Url import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json import timber.log.Timber @Serializable @@ -48,11 +49,22 @@ object StartUpScreenRoute : BaseRoute @SerialName("ScannerRoute") data class ScannerRoute(val scannerName: String, val scannerURL: String, val sessionID: String) : BaseRoute +@Serializable +@SerialName("CropImageRoute") +data class CropImageRoute(val sessionID: String, val pageIdx: Int, val returnRoute: String) : BaseRoute + fun NavBackStackEntry.toTypedRoute(): BaseRoute? { Timber.d("Route changed to: ${destination.route}") return when (destination.route) { "StartUpScreenRoute" -> StartUpScreenRoute + "CropImageRoute/{sessionID}/{pageIdx}/{returnRoute}" -> { + val sessionID = arguments?.getString("sessionID") ?: return null + val pageIdx = arguments?.getInt("pageIdx") ?: return null + val returnRouteString = arguments?.getString("returnRoute") ?: return null + CropImageRoute(sessionID, pageIdx, returnRouteString) + } + "ScannerRoute/{scannerName}/{scannerURL}/{sessionID}" -> { val scannerName = arguments?.getString("scannerName") ?: return null val scannerURL = arguments?.getString("scannerURL") ?: return null @@ -81,6 +93,11 @@ fun ScanBridgeNavHost(navController: NavHostController, startDestination: Any) { TemporaryFileHandler() } } + composable { backStackEntry -> + val scannerRoute: CropImageRoute = backStackEntry.toRoute() + val returnRoute = Json.decodeFromString(scannerRoute.returnRoute) + CropScreen(scannerRoute.sessionID, scannerRoute.pageIdx, returnRoute, navController) + } composable { backStackEntry -> val scannerRoute: ScannerRoute = backStackEntry.toRoute() val debug = sharedPreferences.getBoolean("write_debug", false) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt b/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt index 73f026f..8d66afb 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt @@ -24,7 +24,6 @@ import android.app.Application import android.content.Context import android.content.Context.MODE_PRIVATE import android.content.Intent -import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.Uri import androidx.activity.compose.BackHandler @@ -100,17 +99,25 @@ import com.itextpdf.kernel.pdf.PdfDocument import com.itextpdf.kernel.pdf.PdfWriter import com.itextpdf.layout.Document import com.itextpdf.layout.element.Image +import io.github.chrisimx.esclkt.InputSource import io.github.chrisimx.esclkt.ScanRegion +import io.github.chrisimx.esclkt.inches import io.github.chrisimx.esclkt.millimeters import io.github.chrisimx.esclkt.threeHundredthsOfInch +import io.github.chrisimx.scanbridge.data.ui.ScanRelativeRotation import io.github.chrisimx.scanbridge.data.ui.ScanningScreenViewModel +import io.github.chrisimx.scanbridge.data.ui.toggleRotation import io.github.chrisimx.scanbridge.uicomponents.ExportSettingsPopup import io.github.chrisimx.scanbridge.uicomponents.FullScreenError import io.github.chrisimx.scanbridge.uicomponents.LoadingScreen import io.github.chrisimx.scanbridge.uicomponents.dialog.ConfirmCloseDialog import io.github.chrisimx.scanbridge.uicomponents.dialog.DeletionDialog import io.github.chrisimx.scanbridge.uicomponents.dialog.LoadingDialog +import io.github.chrisimx.scanbridge.util.clearAndNavigateTo +import io.github.chrisimx.scanbridge.util.getEditedImageName +import io.github.chrisimx.scanbridge.util.getMaxResolution import io.github.chrisimx.scanbridge.util.rotateBy90 +import io.github.chrisimx.scanbridge.util.saveAsJPEG import io.github.chrisimx.scanbridge.util.snackBarError import io.github.chrisimx.scanbridge.util.toReadableString import io.github.chrisimx.scanbridge.util.zipFiles @@ -123,6 +130,7 @@ import kotlin.concurrent.thread import kotlin.io.path.Path import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json import me.saket.telephoto.zoomable.ZoomSpec import me.saket.telephoto.zoomable.rememberZoomableState import me.saket.telephoto.zoomable.zoomable @@ -130,11 +138,6 @@ import timber.log.Timber private const val TAG = "ScanningScreen" -fun String.extractBaseFilename(): String? { - val regex = Regex("^scan-[a-f0-9-]+") - return regex.find(this)?.value -} - fun rotate(context: Context, scanningViewModel: ScanningScreenViewModel) { if (scanningViewModel.scanningScreenData.currentScansState.isEmpty()) { return @@ -143,7 +146,7 @@ fun rotate(context: Context, scanningViewModel: ScanningScreenViewModel) { val currentScans = scanningViewModel.scanningScreenData.currentScansState val currentPagePath = - currentScans[scanningViewModel.scanningScreenData.pagerState.currentPage].first + currentScans[scanningViewModel.scanningScreenData.pagerState.currentPage].filePath val currentPageFile = File(currentPagePath) Timber.tag(TAG).d("Decoding $currentPagePath") @@ -152,23 +155,21 @@ fun rotate(context: Context, scanningViewModel: ScanningScreenViewModel) { val rotatedBitmap = originalBitmap.rotateBy90() originalBitmap.recycle() - val baseFileName = currentPageFile.name.extractBaseFilename() - - val newFile = File(context.filesDir, "$baseFileName edit-${System.currentTimeMillis()}.jpg") + val editedImageName = currentPageFile.getEditedImageName() + val newFile = File(context.filesDir, editedImageName) Timber.tag(TAG).d("Saving rotated $currentPagePath") - newFile.outputStream().use { - rotatedBitmap.compress(Bitmap.CompressFormat.JPEG, 100, it) - } + rotatedBitmap.saveAsJPEG(newFile) Timber.tag(TAG).d("Finished saving rotated $currentPagePath") val index = scanningViewModel.scanningScreenData.pagerState.currentPage - val scanSettings = currentScans[index].second + val scanSettings = currentScans[index].originalScanSettings + val priorRotation = currentScans[index].rotation Timber.tag(TAG).d("Updating UI state after rotation") scanningViewModel.removeScanAtIndex(index) scanningViewModel.addTempFile(currentPageFile) - scanningViewModel.addScanAtIndex(newFile.absolutePath, scanSettings, index) + scanningViewModel.addScanAtIndex(newFile.absolutePath, scanSettings, priorRotation.toggleRotation(), index) scanningViewModel.setLoadingText(null) } @@ -205,7 +206,7 @@ fun doZipExport( var counter = 0 val digitsNeeded = scanningViewModel.scanningScreenData.currentScansState.size.toString().length zipFiles( - scanningViewModel.scanningScreenData.currentScansState.map { File(it.first) }, + scanningViewModel.scanningScreenData.currentScansState.map { File(it.filePath) }, zipOutputFile, { counter++ @@ -279,41 +280,41 @@ fun doPdfExport( Document(pdf).use { document -> chunk.forEachIndexed { i, scan -> val scanRegion = - scan.second.scanRegions?.regions?.first() ?: ScanRegion( + scan.originalScanSettings.scanRegions?.regions?.first() ?: ScanRegion( 297.millimeters().toThreeHundredthsOfInch(), 210.millimeters().toThreeHundredthsOfInch(), 0.threeHundredthsOfInch(), 0.threeHundredthsOfInch() ) - val imageData = ImageDataFactory.create(scan.first) - val scanRegionOrientation = scanRegion.height.value > scanRegion.width.value - val actualImageOrientation = imageData.height > imageData.width + val imageData = ImageDataFactory.create(scan.filePath) - var width72thInches = - scanRegion.width.toInches().value * 72.0 - var height72thInches = - scanRegion.height.toInches().value * 72.0 + val rotated = scan.rotation == ScanRelativeRotation.Rotated - if (actualImageOrientation != scanRegionOrientation) { - val tmp = width72thInches - width72thInches = height72thInches - } + val inputSource = scan.originalScanSettings.inputSource ?: InputSource.Platen + + val fallbackResolution = scanningViewModel.scanningScreenData.capabilities!!.getMaxResolution(inputSource) + val scannerXResolution = scan.originalScanSettings.xResolution ?: fallbackResolution.xResolution + val scannerYResolution = scan.originalScanSettings.yResolution ?: fallbackResolution.yResolution - val aspectRatio = imageData.width / imageData.height - height72thInches = width72thInches / aspectRatio + val rotationCorrectedXRes = if (rotated) scannerYResolution else scannerXResolution + val rotationCorrectedYRes = if (rotated) scannerXResolution else scannerYResolution + + // pts are 1/72th inch + val widthPts = (imageData.width / rotationCorrectedXRes.toFloat()).inches().toPoints().value + val heightPts = (imageData.height / rotationCorrectedYRes.toFloat()).inches().toPoints().value pdf.addNewPage( PageSize( - width72thInches.toFloat(), - height72thInches.toFloat() + widthPts.toFloat(), + heightPts.toFloat() ) ) val imageElem = Image(imageData) imageElem.setFixedPosition(i + 1, 0f, 0f) - imageElem.setHeight(height72thInches.toFloat()) - imageElem.setWidth(width72thInches.toFloat()) + imageElem.setHeight(heightPts.toFloat()) + imageElem.setWidth(widthPts.toFloat()) document.add(imageElem) @@ -556,10 +557,7 @@ fun ScanningScreen( if (!isLoaded) { BackHandler { - navController.navigate(StartUpScreenRoute) { - popUpTo(0) { inclusive = true } - launchSingleTop = true - } + navController.clearAndNavigateTo(StartUpScreenRoute) } Scaffold { innerPadding -> @@ -635,7 +633,7 @@ fun ScanningScreen( ) { innerPadding -> if (scanningViewModel.scanningScreenData.capabilities != null) { - ScanContent(innerPadding, scannerName, scanningViewModel, scope) + ScanContent(innerPadding, scannerName, scanningViewModel, scope, navController) } if (scanningViewModel.scanningScreenData.scanSettingsMenuOpen) { @@ -786,7 +784,7 @@ fun ScanningScreen( onConfirmed = { Timber.d("Deleting page") val index = scanningViewModel.scanningScreenData.pagerState.currentPage - Files.delete(Path(scanningViewModel.scanningScreenData.currentScansState[index].first)) + Files.delete(Path(scanningViewModel.scanningScreenData.currentScansState[index].filePath)) scanningViewModel.removeScanAtIndex(index) scanningViewModel.setDeletePageDialogShown(false) } @@ -802,16 +800,13 @@ fun ScanningScreen( onDismiss = { scanningViewModel.setConfirmDialogShown(false) }, onConfirmed = { scanningViewModel.scanningScreenData.currentScansState.forEach { - Files.delete(Path(it.first)) + Files.delete(Path(it.filePath)) } scanningViewModel.scanningScreenData.createdTempFiles.forEach(File::delete) scanningViewModel.scanningScreenData.currentScansState.clear() scanningViewModel.setConfirmDialogShown(false) - navController.navigate(StartUpScreenRoute) { - popUpTo(0) { inclusive = true } - launchSingleTop = true - } + navController.clearAndNavigateTo(StartUpScreenRoute) } ) } @@ -823,7 +818,8 @@ fun ScanContent( innerPadding: PaddingValues, scannerName: String, scanningViewModel: ScanningScreenViewModel, - coroutineScope: CoroutineScope + coroutineScope: CoroutineScope, + navController: NavHostController? = null ) { val pagerState = scanningViewModel.scanningScreenData.pagerState val context = LocalContext.current @@ -846,9 +842,11 @@ fun ScanContent( ) ) - if (scanningViewModel.scanningScreenData.currentScansState.size > pagerState.currentPage) { + val currentScans = scanningViewModel.scanningScreenData.currentScansState + + if (currentScans.size > pagerState.currentPage) { Text( - scanningViewModel.scanningScreenData.currentScansState[pagerState.currentPage].second.inputSource?.toReadableString( + currentScans[pagerState.currentPage].originalScanSettings.inputSource?.toReadableString( context ).toString() ) @@ -879,8 +877,9 @@ fun ScanContent( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { + val imagePath = scanningViewModel.scanningScreenData.currentScansState[page].filePath AsyncImage( - model = scanningViewModel.scanningScreenData.currentScansState[page].first, + model = imagePath, contentDescription = stringResource(R.string.desc_scanned_page), modifier = Modifier .zoomable(zoomState) @@ -944,6 +943,27 @@ fun ScanContent( ) ) } + IconButton(onClick = { + val currentPageIndex = pagerState.currentPage + navController?.currentBackStackEntry?.toTypedRoute()?.let { currentRoute -> + scanningViewModel.scanningScreenData.currentScansState.getOrNull(currentPageIndex)?.let { currentPage -> + navController.clearAndNavigateTo( + CropImageRoute( + scanningViewModel.scanningScreenData.sessionID, + currentPageIndex, + Json.encodeToString(currentRoute) + ) + ) + } + } + }) { + Icon( + painterResource(R.drawable.outline_crop_24), + contentDescription = stringResource( + R.string.crop + ) + ) + } IconButton(onClick = { scanningViewModel.swapTwoPages( pagerState.currentPage, diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/model/ESCLScanSettingsState.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/model/ESCLScanSettingsState.kt index dbbaf55..5eb7b17 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/model/ESCLScanSettingsState.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/model/ESCLScanSettingsState.kt @@ -20,16 +20,16 @@ import kotlinx.serialization.Serializable @Serializable data class MutableESCLScanSettingsState( + /** Specified in DPI **/ + private var xResolutionState: MutableState, + /** Specified in DPI **/ + private var yResolutionState: MutableState, private var versionState: MutableState, private var intentState: MutableState = mutableStateOf(null), private var scanRegionsState: MutableState = mutableStateOf(null), private var documentFormatExtState: MutableState = mutableStateOf(null), private var contentTypeState: MutableState = mutableStateOf(null), private var inputSourceState: MutableState = mutableStateOf(null), - /** Specified in DPI **/ - private var xResolutionState: MutableState = mutableStateOf(null), - /** Specified in DPI **/ - private var yResolutionState: MutableState = mutableStateOf(null), private var colorModeState: MutableState = mutableStateOf(null), private var colorSpaceState: MutableState = mutableStateOf(null), private var mediaTypeState: MutableState = mutableStateOf(null), @@ -156,8 +156,8 @@ data class ImmutableESCLScanSettingsState( val documentFormatExtState: State, val contentTypeState: State, val inputSourceState: State, - val xResolutionState: State, - val yResolutionState: State, + val xResolutionState: State, + val yResolutionState: State, val colorModeState: State, val colorSpaceState: State, val mediaTypeState: State, @@ -256,8 +256,8 @@ data class StatelessImmutableESCLScanSettingsState( val documentFormatExt: String?, val contentType: ContentTypeEnumOrRaw?, val inputSource: InputSource?, - val xResolution: UInt?, - val yResolution: UInt?, + val xResolution: UInt, + val yResolution: UInt, val colorMode: ColorModeEnumOrRaw?, val colorSpace: String?, val mediaType: String?, @@ -280,14 +280,14 @@ data class StatelessImmutableESCLScanSettingsState( val blankPageDetectionAndRemoval: Boolean? ) { fun toMutable(): MutableESCLScanSettingsState = MutableESCLScanSettingsState( + mutableStateOf(xResolution), + mutableStateOf(yResolution), mutableStateOf(version), mutableStateOf(intent), mutableStateOf(scanRegions?.toMutable()), mutableStateOf(documentFormatExt), mutableStateOf(contentType), mutableStateOf(inputSource), - mutableStateOf(xResolution), - mutableStateOf(yResolution), mutableStateOf(colorMode), mutableStateOf(colorSpace), mutableStateOf(mediaType), diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/model/Session.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/model/Session.kt index e915526..f28764b 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/model/Session.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/model/Session.kt @@ -1,12 +1,36 @@ package io.github.chrisimx.scanbridge.data.model import io.github.chrisimx.esclkt.ScanSettings +import io.github.chrisimx.scanbridge.data.ui.ScanMetadata import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json @Serializable data class Session( + val sessionID: String, + val scannedPages: List, + val scanSettings: StatelessImmutableESCLScanSettingsState?, + val tmpFiles: List +) { + companion object { + fun fromString(sessionFileString: String, json: Json): Session = try { + json.decodeFromString(sessionFileString) + } catch (_: Exception) { + val oldSessionVersion = json.decodeFromString(sessionFileString) + oldSessionVersion.migrateToNew() + } + } +} + +@Serializable +data class SessionOld( val sessionID: String, val scannedPages: List>, val scanSettings: StatelessImmutableESCLScanSettingsState?, val tmpFiles: List -) +) { + fun migrateToNew(): Session { + val scannedPages = this.scannedPages.map { ScanMetadata(it.first, it.second) } + return Session(this.sessionID, scannedPages, this.scanSettings, this.tmpFiles) + } +} diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt index 78a6a64..285afed 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanSettingsComposableViewModel.kt @@ -50,17 +50,15 @@ class ScanSettingsComposableViewModel( fun revalidateSettings() { val scanSettingsState = _scanSettingsComposableData.scanSettingsState - if (scanSettingsState.xResolution != null && scanSettingsState.yResolution != null) { - val isResolutionSupported = _scanSettingsComposableData.supportedScanResolutions.discreteResolutions.contains( - DiscreteResolution(scanSettingsState.xResolution!!, scanSettingsState.yResolution!!) - ) - if (!isResolutionSupported) { - val highestScanResolution = _scanSettingsComposableData.supportedScanResolutions.discreteResolutions.maxBy { - it.xResolution * - it.yResolution - } - setResolution(highestScanResolution.xResolution, highestScanResolution.yResolution) + val isResolutionSupported = _scanSettingsComposableData.supportedScanResolutions.discreteResolutions.contains( + DiscreteResolution(scanSettingsState.xResolution, scanSettingsState.yResolution) + ) + if (!isResolutionSupported) { + val highestScanResolution = _scanSettingsComposableData.supportedScanResolutions.discreteResolutions.maxBy { + it.xResolution * + it.yResolution } + setResolution(highestScanResolution.xResolution, highestScanResolution.yResolution) } val intentSupported = scanSettingsState.intent?.let { _scanSettingsComposableData.intentOptions.contains(it) } diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt index 21fd6cd..9f7abf4 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt @@ -30,6 +30,24 @@ import io.github.chrisimx.esclkt.ESCLRequestClient import io.github.chrisimx.esclkt.ScanSettings import io.github.chrisimx.esclkt.ScannerCapabilities import java.io.File +import kotlinx.serialization.Serializable + +enum class ScanRelativeRotation { + Rotated, + Original +} + +fun ScanRelativeRotation.toggleRotation() = when (this) { + ScanRelativeRotation.Rotated -> ScanRelativeRotation.Original + ScanRelativeRotation.Original -> ScanRelativeRotation.Rotated +} + +@Serializable +data class ScanMetadata( + val filePath: String, + val originalScanSettings: ScanSettings, + val rotation: ScanRelativeRotation = ScanRelativeRotation.Original +) data class ScanningScreenData( val esclClient: ESCLRequestClient, @@ -47,7 +65,7 @@ data class ScanningScreenData( val exportOptionsPopupPosition: MutableState?> = mutableStateOf(null), val savePopupPosition: MutableState?> = mutableStateOf(null), val stateProgressStringRes: MutableState = mutableStateOf(null), - val stateCurrentScans: SnapshotStateList> = mutableStateListOf(), + val stateCurrentScans: SnapshotStateList = mutableStateListOf(), val createdTempFiles: MutableList = mutableListOf(), val pagerState: PagerState = PagerState { stateCurrentScans.size + if (scanJobRunning.value) 1 else 0 @@ -96,7 +114,7 @@ data class ImmutableScanningScreenData( private val sourceFileToSaveState: State, val createdTempFiles: List, val pagerState: PagerState, - val currentScansState: SnapshotStateList> + val currentScansState: SnapshotStateList ) { val confirmDialogShown by confirmDialogShownState val confirmPageDeleteDialogShown by confirmPageDeleteDialogShownState diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt index 630fdcd..76a2079 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt @@ -30,18 +30,15 @@ import androidx.lifecycle.viewModelScope import getTrustAllTM import io.github.chrisimx.esclkt.ESCLHttpCallResult import io.github.chrisimx.esclkt.ESCLRequestClient -import io.github.chrisimx.esclkt.Inches import io.github.chrisimx.esclkt.InputSource import io.github.chrisimx.esclkt.JobState -import io.github.chrisimx.esclkt.LengthUnit -import io.github.chrisimx.esclkt.Millimeters import io.github.chrisimx.esclkt.ScanJob import io.github.chrisimx.esclkt.ScanSettings import io.github.chrisimx.esclkt.ScannerCapabilities -import io.github.chrisimx.esclkt.ThreeHundredthsOfInch import io.github.chrisimx.scanbridge.R import io.github.chrisimx.scanbridge.data.model.Session import io.github.chrisimx.scanbridge.stores.DefaultScanSettingsStore +import io.github.chrisimx.scanbridge.stores.SessionsStore import io.github.chrisimx.scanbridge.util.calculateDefaultESCLScanSettingsState import io.github.chrisimx.scanbridge.util.getInputSourceCaps import io.github.chrisimx.scanbridge.util.getInputSourceOptions @@ -64,12 +61,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.decodeFromStream -import kotlinx.serialization.json.encodeToStream -import kotlinx.serialization.modules.SerializersModule -import kotlinx.serialization.modules.polymorphic -import kotlinx.serialization.modules.subclass import timber.log.Timber class ScanningScreenViewModel( @@ -113,18 +104,6 @@ class ScanningScreenViewModel( val scanningScreenData: ImmutableScanningScreenData get() = _scanningScreenData.toImmutable() - val json = Json { - serializersModule = SerializersModule { - polymorphic(LengthUnit::class) { - subclass(Inches::class) - subclass(Millimeters::class) - subclass(ThreeHundredthsOfInch::class) - } - } - classDiscriminator = "type" - prettyPrint = false - } - fun addTempFile(file: File) { _scanningScreenData.createdTempFiles.add(file) saveSessionFile() @@ -287,52 +266,28 @@ class ScanningScreenViewModel( } } - fun addScan(path: String, settings: ScanSettings) { - _scanningScreenData.stateCurrentScans.add(Pair(path, settings)) + fun addScan(path: String, settings: ScanSettings, rotation: ScanRelativeRotation) { + _scanningScreenData.stateCurrentScans.add(ScanMetadata(path, settings, rotation)) saveSessionFile() } - fun addScanAtIndex(path: String, settings: ScanSettings, index: Int) { - _scanningScreenData.stateCurrentScans.add(index, Pair(path, settings)) + fun addScanAtIndex(path: String, settings: ScanSettings, rotation: ScanRelativeRotation, index: Int) { + _scanningScreenData.stateCurrentScans.add(index, ScanMetadata(path, settings, rotation)) saveSessionFile() } @OptIn(ExperimentalSerializationApi::class) fun saveSessionFile(): String { - val path = application.applicationInfo.dataDir + "/files/" + scanningScreenData.sessionID + ".session" - val file = File(path) - val currentSessionState = Session( scanningScreenData.sessionID, scanningScreenData.currentScansState.toList(), scanningScreenData.scanSettingsVM?.getMutableScanSettingsComposableData()?.scanSettingsState?.toStateless(), scanningScreenData.createdTempFiles.map { it.absolutePath } ) - - val fos = file.outputStream() - - json.encodeToStream(currentSessionState, fos) - fos.close() - return path + return SessionsStore.saveSession(currentSessionState, application, scanningScreenData.sessionID) } @OptIn(ExperimentalSerializationApi::class) - fun loadSessionFile(): Session? { - val path = application.applicationInfo.dataDir + "/files/" + scanningScreenData.sessionID + ".session" - val file = File(path) - - if (!file.exists()) { - Timber.d("Could not find session file at $path") - return null - } - - val inputStream = file.inputStream() - - val storedSession = json.decodeFromStream(inputStream) - - inputStream.close() - - return storedSession - } + fun loadSessionFile(): Session? = SessionsStore.loadSession(application, scanningScreenData.sessionID) fun swapTwoPages(index1: Int, index2: Int) { if (index1 < 0 || @@ -614,7 +569,7 @@ class ScanningScreenViewModel( Timber.d("Cancelling job after error while trying to decode received page as bitmap: $deletionResult") return } - addScan(filePath.toString(), currentScanSettings) + addScan(filePath.toString(), currentScanSettings, ScanRelativeRotation.Original) } } diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt b/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt new file mode 100644 index 0000000..ad949c2 --- /dev/null +++ b/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt @@ -0,0 +1,301 @@ +package io.github.chrisimx.scanbridge.uicomponents + +import android.annotation.SuppressLint +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.toSize +import coil3.compose.AsyncImage +import io.github.chrisimx.scanbridge.R +import io.github.chrisimx.scanbridge.theme.ScanBridgeTheme +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import timber.log.Timber + +/** + * Determines if a given position is within a rect with a clearance for a given error (so that touch does not feel clunky). + * + * @param errorEpsilon This is the allowed touch area outside of the actual rectangle + */ +fun isPositionInRect(position: Offset, rect: Rect, errorEpsilon: Float = 40f): Boolean { + val isHorizontallyInRect = abs(position.x - rect.center.x) < (rect.width / 2) + errorEpsilon + val isVerticallyInRect = abs(position.y - rect.center.y) < (rect.height / 2) + errorEpsilon + + return isHorizontallyInRect && isVerticallyInRect +} + +enum class Edge { TOP, LEFT, BOTTOM, RIGHT } + +data class EdgeFlags(val top: Boolean = false, val left: Boolean = false, val bottom: Boolean = false, val right: Boolean = false) + +data class HandleSpec(val position: Offset, val draggedEdges: Set) { + val edgeFlags: EdgeFlags + get() = EdgeFlags( + top = Edge.TOP in draggedEdges, + left = Edge.LEFT in draggedEdges, + bottom = Edge.BOTTOM in draggedEdges, + right = Edge.RIGHT in draggedEdges + ) +} + +/** + * Create a HandleSpec based on the handle position and edges that should be moved when the handle is dragged + */ +fun Offset.asScalingHandle(vararg edge: Edge) = HandleSpec( + position = this, + draggedEdges = edge.toSet() +) + +fun Rect.applyResizeDrag(drag: Offset, size: IntSize, flags: EdgeFlags, density: Density, clearance: Dp = 30.dp): Rect = with(density) { + copy( + left = min(max(left + if (flags.left) drag.x else 0f, 0f), size.width - clearance.toPx()), + right = min(max(right + if (flags.right) drag.x else 0f, left + clearance.toPx()), size.width.toFloat()), + top = min(max(top + if (flags.top) drag.y else 0f, 0f), size.height - clearance.toPx()), + bottom = min(max(bottom + if (flags.bottom) drag.y else 0f, top + clearance.toPx()), size.height.toFloat()) + ) +} + +sealed class CropDragEvent { + data class ResizeHandleDragged(val idx: Int) : CropDragEvent() + object DraggedOutside : CropDragEvent() + object DraggedInside : CropDragEvent() +} + +@Composable +fun CropOverlay(modifier: Modifier, touchPaddingAroundInPx: Int, handleTouchRadius: Dp, onRectChange: (Rect) -> Unit = {}) { + var rect by remember { mutableStateOf(Rect(0f, 0f, 50f, 50f)) } + var size by remember { mutableStateOf(IntSize.Zero) } + val relativeRect by remember { + derivedStateOf { + Rect( + rect.left / size.width, + rect.top / size.height, + rect.right / size.width, + rect.bottom / size.height + ) + } + } + + val handles by remember { + derivedStateOf { + arrayOf( + rect.topLeft.asScalingHandle(Edge.TOP, Edge.LEFT), + rect.centerLeft.asScalingHandle(Edge.LEFT), + rect.bottomLeft.asScalingHandle(Edge.LEFT, Edge.BOTTOM), + rect.bottomCenter.asScalingHandle(Edge.BOTTOM), + rect.bottomRight.asScalingHandle(Edge.BOTTOM, Edge.RIGHT), + rect.centerRight.asScalingHandle(Edge.RIGHT), + rect.topRight.asScalingHandle(Edge.TOP, Edge.RIGHT), + rect.topCenter.asScalingHandle(Edge.TOP) + ) + } + } + + var lastDragEventType by remember { mutableStateOf(CropDragEvent.DraggedOutside) } + val density = LocalDensity.current + + val touchErrorClearance = with(density) { + touchPaddingAroundInPx.toDp() + } + + Canvas( + modifier = Modifier + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { offset -> + val offset = offset - Offset(touchPaddingAroundInPx.toFloat(), touchPaddingAroundInPx.toFloat()) + lastDragEventType = CropDragEvent.DraggedOutside + + if (!isPositionInRect(offset, rect, handleTouchRadius.toPx())) { + return@detectDragGestures + } + + lastDragEventType = CropDragEvent.DraggedInside + + handles.forEachIndexed { idx, handle -> + val distanceBetweenPointerAndHandle = (handle.position - offset).getDistanceSquared() + val isNear = distanceBetweenPointerAndHandle < handleTouchRadius.toPx() * handleTouchRadius.toPx() + + if (isNear) { + lastDragEventType = CropDragEvent.ResizeHandleDragged(idx) + } + } + }, + onDrag = { pointerInputChange, dragChange -> + val evenType = lastDragEventType + + when (evenType) { + CropDragEvent.DraggedInside -> { + val possibleXChange = if (rect.left + dragChange.x < 0) { + -rect.left + } else if (rect.right + dragChange.x > size.width) { + size.width - rect.right + } else { + dragChange.x + } + + val possibleYChange = if (rect.top + dragChange.y < 0) { + -rect.top + } else if (rect.bottom + dragChange.y > size.height) { + size.height - rect.bottom + } else { + dragChange.y + } + + rect = rect.translate(possibleXChange, possibleYChange) + } + + is CropDragEvent.ResizeHandleDragged -> { + val currentlyDraggedHandle = handles[evenType.idx] + val edgeFlags = currentlyDraggedHandle.edgeFlags + rect = rect.applyResizeDrag(dragChange, size, edgeFlags, density) + } + + CropDragEvent.DraggedOutside -> return@detectDragGestures + } + + onRectChange(relativeRect) + pointerInputChange.consume() + } + ) + }.padding(touchErrorClearance) + .onSizeChanged { + size = it + rect = Rect(Offset.Zero, size.toSize()) + }.then(modifier) + ) { + // Inner transparent fill + drawRect(Color.Cyan, rect.topLeft, rect.size, alpha = 0.1f) + + // Border + drawRect(Color.Cyan, rect.topLeft, rect.size, alpha = 0.5f, style = Stroke(width = 2.dp.toPx())) + + // Draw handles for resizing the rect + for (handlePoint in handles.map { it.position }) { + drawCircle(Color.Cyan, 5.dp.toPx(), handlePoint, style = Stroke(width = 2.dp.toPx())) + drawCircle(Color.Cyan, 5.dp.toPx(), handlePoint, alpha = 0.4f) + } + } +} + +enum class SlotsEnum { Main, Dependent } + +@Composable +fun MatchLargestChildBoxWithTouchErrorMargin( + modifier: Modifier = Modifier, + dependantAdditionalSpaceInPx: Int, + mainContent: @Composable () -> Unit, + dependentContent: @Composable () -> Unit +) { + SubcomposeLayout(modifier) { constraints -> + val mainPlaceables = subcompose(SlotsEnum.Main, mainContent).map { it.measure(constraints) } + + val maxSize = + mainPlaceables.fold(IntSize.Zero) { currentMax, placeable -> + IntSize( + width = maxOf(currentMax.width, placeable.width), + height = maxOf(currentMax.height, placeable.height) + ) + } + + val dependentConstraints = Constraints.fixed( + maxSize.width + 2 * dependantAdditionalSpaceInPx, + maxSize.height + 2 * dependantAdditionalSpaceInPx + ) + layout(maxSize.width, maxSize.height) { + mainPlaceables.forEach { it.placeRelative(0, 0) } + subcompose(SlotsEnum.Dependent, dependentContent) + .forEach { it.measure(dependentConstraints).placeRelative(-dependantAdditionalSpaceInPx, -dependantAdditionalSpaceInPx) } + } + } +} + +@Composable +fun CroppableAsyncImage( + modifier: Modifier = Modifier, + imageModel: Any?, + contentDescription: String?, + additionalTouchAreaAround: Dp, + handleTouchRadius: Dp, + cropRectChanged: (Rect) -> Unit +) { + val density = LocalDensity.current + val additionalTouchAreaAroundInPx = with(density) { + additionalTouchAreaAround.toPx().toInt() + } + + MatchLargestChildBoxWithTouchErrorMargin( + modifier = modifier, + dependantAdditionalSpaceInPx = additionalTouchAreaAroundInPx, + mainContent = { + AsyncImage( + model = imageModel, + contentDescription = contentDescription, + modifier = Modifier.fillMaxWidth() + ) + } + ) { + CropOverlay( + modifier = Modifier, + additionalTouchAreaAroundInPx, + handleTouchRadius + ) { + cropRectChanged(it) + } + } +} + +@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") +@Preview +@Composable +fun PreviewCropOverlay() { + Timber.plant(Timber.DebugTree()) + + Scaffold { innerPadding -> + ScanBridgeTheme { + val density = LocalDensity.current + var currentRect by remember { mutableStateOf(Rect(0f, 0f, 0f, 0f)) } + MatchLargestChildBoxWithTouchErrorMargin( + modifier = Modifier.padding(innerPadding).padding(top = 200.dp, start = 40.dp, end = 40.dp), + dependantAdditionalSpaceInPx = 200, + mainContent = { + Image( + modifier = Modifier.fillMaxWidth(), + painter = painterResource(R.drawable.icon_about_dialog), + contentDescription = "Hallo" + ) + } + ) { + CropOverlay(modifier = Modifier, 200, 50.dp) { + currentRect = it + } + } + } + } +} diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt b/app/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt index 1c82f93..aee1ed9 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/util/ESCLKtExtensions.kt @@ -22,6 +22,8 @@ package io.github.chrisimx.scanbridge.util import android.content.Context import android.icu.text.DecimalFormat import androidx.compose.runtime.mutableStateOf +import io.github.chrisimx.esclkt.ColorModeEnumOrRaw +import io.github.chrisimx.esclkt.DiscreteResolution import io.github.chrisimx.esclkt.EnumOrRaw import io.github.chrisimx.esclkt.InputSource import io.github.chrisimx.esclkt.InputSourceCaps @@ -67,23 +69,37 @@ fun InputSource.toReadableString(context: Context): String = when (this) { InputSource.Camera -> context.getString(R.string.camera) } -fun ScannerCapabilities.calculateDefaultESCLScanSettingsState(): MutableESCLScanSettingsState { - val inputSource = this.getInputSourceOptions().firstOrNull() ?: InputSource.Platen +fun ScannerCapabilities.getMaxResolution(inputSource: InputSource): DiscreteResolution { val inputCaps = this.getInputSourceCaps(inputSource) val maxResolution = inputCaps .settingProfiles.first() - .supportedResolutions.discreteResolutions.maxBy { it.xResolution } - val maxScanRegion = MutableScanRegionState( - heightState = mutableStateOf("max"), - widthState = mutableStateOf("max") - ) + .supportedResolutions.discreteResolutions.maxBy { it.xResolution * it.yResolution } + + return maxResolution +} +fun ScannerCapabilities.getBestColorMode(inputSource: InputSource): ColorModeEnumOrRaw? { + val inputCaps = this.getInputSourceCaps(inputSource) val chosenColorMode = inputCaps.settingProfiles.elementAtOrNull(0)?.colorModes?.maxByOrNull { when (it) { is EnumOrRaw.Known -> it.value.ordinal is EnumOrRaw.Unknown -> 0 } } + return chosenColorMode +} + +fun ScannerCapabilities.calculateDefaultESCLScanSettingsState(): MutableESCLScanSettingsState { + val inputSource = this.getInputSourceOptions().firstOrNull() ?: InputSource.Platen + + val maxResolution = getMaxResolution(inputSource) + + val maxScanRegion = MutableScanRegionState( + heightState = mutableStateOf("max"), + widthState = mutableStateOf("max") + ) + + val bestColorMode = getBestColorMode(inputSource) return MutableESCLScanSettingsState( versionState = mutableStateOf(this.interfaceVersion), @@ -91,7 +107,7 @@ fun ScannerCapabilities.calculateDefaultESCLScanSettingsState(): MutableESCLScan scanRegionsState = mutableStateOf(maxScanRegion), xResolutionState = mutableStateOf(maxResolution.xResolution), yResolutionState = mutableStateOf(maxResolution.yResolution), - colorModeState = mutableStateOf(chosenColorMode), + colorModeState = mutableStateOf(bestColorMode), documentFormatExtState = mutableStateOf("image/jpeg") ) } diff --git a/app/src/main/res/drawable/outline_crop_24.xml b/app/src/main/res/drawable/outline_crop_24.xml new file mode 100644 index 0000000..58ad9eb --- /dev/null +++ b/app/src/main/res/drawable/outline_crop_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/outline_pan_zoom_24.xml b/app/src/main/res/drawable/outline_pan_zoom_24.xml new file mode 100644 index 0000000..6bc993f --- /dev/null +++ b/app/src/main/res/drawable/outline_pan_zoom_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cbed9f9..94fbd74 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -102,4 +102,9 @@ F-Droid Play Store Save to File + Loading image… + Crop current page + Crop + Cropping page… + Activate zoom gestures \ No newline at end of file From f01a4ec7fd91d29f7d0e4dfcf50bd619d0717acc Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Tue, 17 Feb 2026 17:40:05 +0100 Subject: [PATCH 02/33] Adjust handle drag detection of CroppableImage --- .../github/chrisimx/scanbridge/CropScreen.kt | 3 +- .../scanbridge/uicomponents/CroppableImage.kt | 41 ++++++++++--------- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt b/app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt index 62d1b74..18d9522 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt @@ -41,6 +41,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import me.saket.telephoto.zoomable.EnabledZoomGestures +import me.saket.telephoto.zoomable.ZoomSpec import me.saket.telephoto.zoomable.rememberZoomableState import me.saket.telephoto.zoomable.zoomable @@ -84,7 +85,7 @@ fun CropScreen(sessionID: String, pageIdx: Int, returnRoute: BaseRoute, navContr val context = LocalContext.current var zoomEnabled by remember { mutableStateOf(false) } - val zoomableState = rememberZoomableState() + val zoomableState = rememberZoomableState(ZoomSpec(maxZoomFactor = 5f)) val coroutineScope = rememberCoroutineScope() var currentRect by remember { mutableStateOf(Rect(0f, 0f, 1f, 1f)) } diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt b/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt index ad949c2..2831efc 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt @@ -33,23 +33,10 @@ import androidx.compose.ui.unit.toSize import coil3.compose.AsyncImage import io.github.chrisimx.scanbridge.R import io.github.chrisimx.scanbridge.theme.ScanBridgeTheme -import kotlin.math.abs import kotlin.math.max import kotlin.math.min import timber.log.Timber -/** - * Determines if a given position is within a rect with a clearance for a given error (so that touch does not feel clunky). - * - * @param errorEpsilon This is the allowed touch area outside of the actual rectangle - */ -fun isPositionInRect(position: Offset, rect: Rect, errorEpsilon: Float = 40f): Boolean { - val isHorizontallyInRect = abs(position.x - rect.center.x) < (rect.width / 2) + errorEpsilon - val isVerticallyInRect = abs(position.y - rect.center.y) < (rect.height / 2) + errorEpsilon - - return isHorizontallyInRect && isVerticallyInRect -} - enum class Edge { TOP, LEFT, BOTTOM, RIGHT } data class EdgeFlags(val top: Boolean = false, val left: Boolean = false, val bottom: Boolean = false, val right: Boolean = false) @@ -64,6 +51,13 @@ data class HandleSpec(val position: Offset, val draggedEdges: Set) { ) } +fun Rect.deflate(x: Float, y: Float): Rect = copy( + left = min(left + x, left + width / 2), + right = max(right - x, right - width / 2), + top = min(top + y, top + height / 2), + bottom = max(bottom - y, bottom - height / 2) +) + /** * Create a HandleSpec based on the handle position and edges that should be moved when the handle is dragged */ @@ -132,19 +126,28 @@ fun CropOverlay(modifier: Modifier, touchPaddingAroundInPx: Int, handleTouchRadi val offset = offset - Offset(touchPaddingAroundInPx.toFloat(), touchPaddingAroundInPx.toFloat()) lastDragEventType = CropDragEvent.DraggedOutside - if (!isPositionInRect(offset, rect, handleTouchRadius.toPx())) { + if (!rect.inflate(handleTouchRadius.toPx()).contains(offset)) { return@detectDragGestures } lastDragEventType = CropDragEvent.DraggedInside - handles.forEachIndexed { idx, handle -> - val distanceBetweenPointerAndHandle = (handle.position - offset).getDistanceSquared() - val isNear = distanceBetweenPointerAndHandle < handleTouchRadius.toPx() * handleTouchRadius.toPx() + if (rect.deflate(rect.width / 4, rect.height / 4).contains(offset)) { + return@detectDragGestures + } - if (isNear) { - lastDragEventType = CropDragEvent.ResizeHandleDragged(idx) + val nearestHandle = handles + .mapIndexed { idx, handle -> + Pair( + idx, + (handle.position - offset).getDistance() + ) } + .filter { it.second < handleTouchRadius.toPx() } + .minByOrNull { it.second } + + if (nearestHandle != null) { + lastDragEventType = CropDragEvent.ResizeHandleDragged(nearestHandle.first) } }, onDrag = { pointerInputChange, dragChange -> From 2e32d214d92223688844448a4ed185c5aa324376 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Tue, 17 Feb 2026 18:43:13 +0100 Subject: [PATCH 03/33] Apply format --- .../github/chrisimx/scanbridge/ScannerDiscoveryBackend.kt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/ScannerDiscoveryBackend.kt b/app/src/main/java/io/github/chrisimx/scanbridge/ScannerDiscoveryBackend.kt index df426a9..256e5ef 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/ScannerDiscoveryBackend.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/ScannerDiscoveryBackend.kt @@ -156,11 +156,7 @@ class ScannerDiscovery( } } -private fun ScannerDiscovery.tryParseScannerUrl( - address: InetAddress, - serviceInfo: NsdServiceInfo, - rs: String -): Url? { +private fun ScannerDiscovery.tryParseScannerUrl(address: InetAddress, serviceInfo: NsdServiceInfo, rs: String): Url? { if (address.isLinkLocalAddress) { Timber.tag(TAG).d("Ignoring link local address: ${address.hostAddress}") return null From 1805f0ae13d8c6ec60cebf930d4af98d252e2ae4 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Wed, 18 Feb 2026 12:49:48 +0100 Subject: [PATCH 04/33] Move rotation to view model --- .../chrisimx/scanbridge/ScanningScreen.kt | 37 +--------------- .../scanbridge/data/ui/ScanningScreenData.kt | 6 ++- .../data/ui/ScanningScreenViewModel.kt | 42 +++++++++++++++++++ 3 files changed, 48 insertions(+), 37 deletions(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt b/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt index 8d66afb..4fbf695 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt @@ -138,41 +138,6 @@ import timber.log.Timber private const val TAG = "ScanningScreen" -fun rotate(context: Context, scanningViewModel: ScanningScreenViewModel) { - if (scanningViewModel.scanningScreenData.currentScansState.isEmpty()) { - return - } - scanningViewModel.setLoadingText(R.string.rotating_page) - - val currentScans = scanningViewModel.scanningScreenData.currentScansState - val currentPagePath = - currentScans[scanningViewModel.scanningScreenData.pagerState.currentPage].filePath - val currentPageFile = File(currentPagePath) - - Timber.tag(TAG).d("Decoding $currentPagePath") - val originalBitmap = BitmapFactory.decodeFile(currentPagePath) - Timber.tag(TAG).d("Rotating $currentPagePath") - val rotatedBitmap = originalBitmap.rotateBy90() - originalBitmap.recycle() - - val editedImageName = currentPageFile.getEditedImageName() - val newFile = File(context.filesDir, editedImageName) - - Timber.tag(TAG).d("Saving rotated $currentPagePath") - rotatedBitmap.saveAsJPEG(newFile) - - Timber.tag(TAG).d("Finished saving rotated $currentPagePath") - - val index = scanningViewModel.scanningScreenData.pagerState.currentPage - val scanSettings = currentScans[index].originalScanSettings - val priorRotation = currentScans[index].rotation - Timber.tag(TAG).d("Updating UI state after rotation") - scanningViewModel.removeScanAtIndex(index) - scanningViewModel.addTempFile(currentPageFile) - scanningViewModel.addScanAtIndex(newFile.absolutePath, scanSettings, priorRotation.toggleRotation(), index) - - scanningViewModel.setLoadingText(null) -} fun doZipExport( scanningViewModel: ScanningScreenViewModel, @@ -982,7 +947,7 @@ fun ScanContent( } IconButton(onClick = { thread { - rotate(context, scanningViewModel) + scanningViewModel.rotateCurrentPage() } }) { Icon( diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt index 9f7abf4..194026f 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt @@ -70,7 +70,8 @@ data class ScanningScreenData( val pagerState: PagerState = PagerState { stateCurrentScans.size + if (scanJobRunning.value) 1 else 0 }, - val sourceFileToSave: MutableState = mutableStateOf(null) + val sourceFileToSave: MutableState = mutableStateOf(null), + val isRotating: MutableState = mutableStateOf(false) ) { fun toImmutable() = ImmutableScanningScreenData( esclClient, @@ -89,6 +90,7 @@ data class ScanningScreenData( scanJobCancelling, stateProgressStringRes, sourceFileToSave, + isRotating, createdTempFiles, pagerState, stateCurrentScans @@ -112,6 +114,7 @@ data class ImmutableScanningScreenData( private val scanJobCancellingState: State, private val progressStringResState: State, private val sourceFileToSaveState: State, + private val isRotatingState: State, val createdTempFiles: List, val pagerState: PagerState, val currentScansState: SnapshotStateList @@ -130,4 +133,5 @@ data class ImmutableScanningScreenData( val exportOptionsPopupPosition by exportOptionsPopupPositionState val saveOptionsPopupPosition by saveOptionsPopupPositionState val sourceFileToSave by sourceFileToSaveState + val isRotating by isRotatingState } diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt index 76a2079..1b9f1f2 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt @@ -40,8 +40,11 @@ import io.github.chrisimx.scanbridge.data.model.Session import io.github.chrisimx.scanbridge.stores.DefaultScanSettingsStore import io.github.chrisimx.scanbridge.stores.SessionsStore import io.github.chrisimx.scanbridge.util.calculateDefaultESCLScanSettingsState +import io.github.chrisimx.scanbridge.util.getEditedImageName import io.github.chrisimx.scanbridge.util.getInputSourceCaps import io.github.chrisimx.scanbridge.util.getInputSourceOptions +import io.github.chrisimx.scanbridge.util.rotateBy90 +import io.github.chrisimx.scanbridge.util.saveAsJPEG import io.github.chrisimx.scanbridge.util.snackbarErrorRetrievingPage import io.github.chrisimx.scanbridge.util.toJobStateString import io.ktor.client.HttpClient @@ -170,6 +173,45 @@ class ScanningScreenViewModel( _scanningScreenData.errorString.value = value } + fun rotateCurrentPage() { + if (_scanningScreenData.stateCurrentScans.isEmpty() || _scanningScreenData.isRotating.value) { + return + } + _scanningScreenData.isRotating.value = true + setLoadingText(R.string.rotating_page) + + val currentScans = scanningScreenData.currentScansState + val currentPagePath = + currentScans[scanningScreenData.pagerState.currentPage].filePath + val currentPageFile = File(currentPagePath) + + Timber.d("Decoding $currentPagePath") + val originalBitmap = BitmapFactory.decodeFile(currentPagePath) + Timber.d("Rotating $currentPagePath") + val rotatedBitmap = originalBitmap.rotateBy90() + originalBitmap.recycle() + + val editedImageName = currentPageFile.getEditedImageName() + val newFile = File(application.filesDir, editedImageName) + + Timber.d("Saving rotated $currentPagePath") + rotatedBitmap.saveAsJPEG(newFile) + + Timber.d("Finished saving rotated $currentPagePath") + + val index = scanningScreenData.pagerState.currentPage + val scanSettings = currentScans[index].originalScanSettings + val priorRotation = currentScans[index].rotation + Timber.d("Updating UI state after rotation") + removeScanAtIndex(index) + addTempFile(currentPageFile) + addScanAtIndex(newFile.absolutePath, scanSettings, priorRotation.toggleRotation(), index) + + setLoadingText(null) + _scanningScreenData.isRotating.value = false + } + + fun setScannerCapabilities(caps: ScannerCapabilities) { _scanningScreenData.capabilities.value = caps val storedSession = loadSessionFile() From ee128ae5abee206247510d33aa7236f1300e4bec Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Wed, 18 Feb 2026 12:56:29 +0100 Subject: [PATCH 05/33] Remove unused import --- .../java/io/github/chrisimx/scanbridge/stores/SessionsStore.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/stores/SessionsStore.kt b/app/src/main/java/io/github/chrisimx/scanbridge/stores/SessionsStore.kt index f904804..c0420d1 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/stores/SessionsStore.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/stores/SessionsStore.kt @@ -1,7 +1,6 @@ package io.github.chrisimx.scanbridge.stores import android.content.Context -import androidx.lifecycle.application import io.github.chrisimx.esclkt.Inches import io.github.chrisimx.esclkt.LengthUnit import io.github.chrisimx.esclkt.Millimeters From 6a10e995b3fcfe2ee390e4d37b020d506e153051 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Wed, 18 Feb 2026 19:02:46 +0100 Subject: [PATCH 06/33] Add zoom pan to CroppableImage and fix CroppableImage for to high imges --- .../github/chrisimx/scanbridge/CropScreen.kt | 84 ++++++------ .../scanbridge/uicomponents/CroppableImage.kt | 123 +++++++++++++----- 2 files changed, 133 insertions(+), 74 deletions(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt b/app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt index 18d9522..e11c9c5 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt @@ -3,14 +3,15 @@ package io.github.chrisimx.scanbridge import android.content.Context import android.graphics.BitmapFactory import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.snap import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.BottomAppBar import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FabPosition import androidx.compose.material3.Icon -import androidx.compose.material3.IconToggleButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -40,6 +41,7 @@ import java.io.File import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import me.saket.telephoto.ExperimentalTelephotoApi import me.saket.telephoto.zoomable.EnabledZoomGestures import me.saket.telephoto.zoomable.ZoomSpec import me.saket.telephoto.zoomable.rememberZoomableState @@ -79,12 +81,11 @@ private fun updateSessionFile( SessionsStore.saveSession(editedSession, context, sessionID) } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalTelephotoApi::class) @Composable fun CropScreen(sessionID: String, pageIdx: Int, returnRoute: BaseRoute, navController: NavController) { val context = LocalContext.current - var zoomEnabled by remember { mutableStateOf(false) } val zoomableState = rememberZoomableState(ZoomSpec(maxZoomFactor = 5f)) val coroutineScope = rememberCoroutineScope() var currentRect by remember { mutableStateOf(Rect(0f, 0f, 1f, 1f)) } @@ -100,28 +101,38 @@ fun CropScreen(sessionID: String, pageIdx: Int, returnRoute: BaseRoute, navContr val originalImageFile = remember { originalSession.scannedPages[pageIdx].filePath } + val save: () -> Unit = { + coroutineScope.launch(Dispatchers.Main) { + if (processing) return@launch + + processing = true + val croppedFile = finishCrop(currentRect, originalImageFile) + updateSessionFile(originalSession, pageIdx, croppedFile, originalImageFile, context, sessionID) + navController.clearAndNavigateTo(returnRoute) + processing = false + } + } + BackHandler { navController.clearAndNavigateTo(returnRoute) } Scaffold( modifier = Modifier.fillMaxSize(), - bottomBar = { - CropScreenBottomBar( - zoomEnabled, - { zoomEnabled = it } - ) { - coroutineScope.launch(Dispatchers.Main) { - if (processing) return@launch - - processing = true - val croppedFile = finishCrop(currentRect, originalImageFile) - updateSessionFile(originalSession, pageIdx, croppedFile, originalImageFile, context, sessionID) - navController.clearAndNavigateTo(returnRoute) - processing = false - } - } - } + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = save, + modifier = Modifier.testTag("crop_finish"), + icon = { + Icon( + painter = painterResource(R.drawable.outline_crop_24), + contentDescription = stringResource(R.string.crop) + ) + }, + text = { Text(stringResource(R.string.crop_button)) } + ) + }, + floatingActionButtonPosition = FabPosition.Center ) { innerPadding -> if (processing) { @@ -133,7 +144,7 @@ fun CropScreen(sessionID: String, pageIdx: Int, returnRoute: BaseRoute, navContr .fillMaxSize() .zoomable( zoomableState, - gestures = if (zoomEnabled) EnabledZoomGestures.ZoomAndPan else EnabledZoomGestures.None + gestures = EnabledZoomGestures.ZoomOnly ), contentAlignment = Alignment.CenterHorizontally.plus(Alignment.CenterVertically) ) { @@ -144,37 +155,22 @@ fun CropScreen(sessionID: String, pageIdx: Int, returnRoute: BaseRoute, navContr imageModel = originalImageFile, contentDescription = stringResource(R.string.desc_scanned_page), additionalTouchAreaAround = 100.dp, - handleTouchRadius = 60.dp - ) { - currentRect = it - } + handleTouchRadius = 60.dp, + cropRectChanged = { currentRect = it }, + onPan = { + coroutineScope.launch { + zoomableState.panBy(it, snap()) + } + } + ) } } } @Composable -fun CropScreenBottomBar(zoomEnabled: Boolean, setZoomEnabled: (Boolean) -> Unit, onSaveRequest: () -> Unit) { +fun CropScreenBottomBar() { BottomAppBar( actions = { - IconToggleButton(zoomEnabled, onCheckedChange = setZoomEnabled) { - Icon( - painterResource(R.drawable.outline_pan_zoom_24), - stringResource(R.string.activate_zoom_gestures) - ) - } - }, - floatingActionButton = @Composable { - ExtendedFloatingActionButton( - onClick = onSaveRequest, - modifier = Modifier.testTag("crop_finish"), - icon = { - Icon( - painter = painterResource(R.drawable.outline_crop_24), - contentDescription = stringResource(R.string.crop) - ) - }, - text = { Text(stringResource(R.string.crop_button)) } - ) } ) } diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt b/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt index 2831efc..0453bad 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt @@ -5,7 +5,14 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf @@ -19,8 +26,10 @@ import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview @@ -30,7 +39,9 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.toSize -import coil3.compose.AsyncImage +import coil3.ImageLoader +import coil3.compose.SubcomposeAsyncImage +import coil3.request.CachePolicy import io.github.chrisimx.scanbridge.R import io.github.chrisimx.scanbridge.theme.ScanBridgeTheme import kotlin.math.max @@ -82,7 +93,13 @@ sealed class CropDragEvent { } @Composable -fun CropOverlay(modifier: Modifier, touchPaddingAroundInPx: Int, handleTouchRadius: Dp, onRectChange: (Rect) -> Unit = {}) { +fun CropOverlay( + modifier: Modifier, + touchPaddingAroundInPx: Int, + handleTouchRadius: Dp, + onRectChange: (Rect) -> Unit = {}, + onPan: (Offset) -> Unit = {} +) { var rect by remember { mutableStateOf(Rect(0f, 0f, 50f, 50f)) } var size by remember { mutableStateOf(IntSize.Zero) } val relativeRect by remember { @@ -127,6 +144,7 @@ fun CropOverlay(modifier: Modifier, touchPaddingAroundInPx: Int, handleTouchRadi lastDragEventType = CropDragEvent.DraggedOutside if (!rect.inflate(handleTouchRadius.toPx()).contains(offset)) { + onPan(offset) return@detectDragGestures } @@ -180,18 +198,23 @@ fun CropOverlay(modifier: Modifier, touchPaddingAroundInPx: Int, handleTouchRadi rect = rect.applyResizeDrag(dragChange, size, edgeFlags, density) } - CropDragEvent.DraggedOutside -> return@detectDragGestures + CropDragEvent.DraggedOutside -> { + onPan(dragChange) + return@detectDragGestures + } } onRectChange(relativeRect) pointerInputChange.consume() } ) - }.padding(touchErrorClearance) + } + .padding(touchErrorClearance) .onSizeChanged { size = it rect = Rect(Offset.Zero, size.toSize()) - }.then(modifier) + } + .then(modifier) ) { // Inner transparent fill drawRect(Color.Cyan, rect.topLeft, rect.size, alpha = 0.1f) @@ -213,17 +236,20 @@ enum class SlotsEnum { Main, Dependent } fun MatchLargestChildBoxWithTouchErrorMargin( modifier: Modifier = Modifier, dependantAdditionalSpaceInPx: Int, - mainContent: @Composable () -> Unit, + mainContent: @Composable (Constraints) -> Unit, dependentContent: @Composable () -> Unit ) { SubcomposeLayout(modifier) { constraints -> - val mainPlaceables = subcompose(SlotsEnum.Main, mainContent).map { it.measure(constraints) } + val mainPlaceables = subcompose( + SlotsEnum.Main, + { mainContent(Constraints()) } + ).map { it.measure(constraints) } val maxSize = mainPlaceables.fold(IntSize.Zero) { currentMax, placeable -> IntSize( - width = maxOf(currentMax.width, placeable.width), - height = maxOf(currentMax.height, placeable.height) + width = maxOf(currentMax.width, placeable.measuredWidth), + height = maxOf(currentMax.height, placeable.measuredHeight) ) } @@ -239,6 +265,7 @@ fun MatchLargestChildBoxWithTouchErrorMargin( } } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun CroppableAsyncImage( modifier: Modifier = Modifier, @@ -246,32 +273,66 @@ fun CroppableAsyncImage( contentDescription: String?, additionalTouchAreaAround: Dp, handleTouchRadius: Dp, - cropRectChanged: (Rect) -> Unit + cropRectChanged: (Rect) -> Unit, + onPan: (Offset) -> Unit ) { + val context = LocalContext.current val density = LocalDensity.current val additionalTouchAreaAroundInPx = with(density) { additionalTouchAreaAround.toPx().toInt() } - MatchLargestChildBoxWithTouchErrorMargin( + SubcomposeAsyncImage( + model = imageModel, + contentDescription = contentDescription, modifier = modifier, - dependantAdditionalSpaceInPx = additionalTouchAreaAroundInPx, - mainContent = { - AsyncImage( - model = imageModel, - contentDescription = contentDescription, - modifier = Modifier.fillMaxWidth() - ) - } - ) { - CropOverlay( - modifier = Modifier, - additionalTouchAreaAroundInPx, - handleTouchRadius - ) { - cropRectChanged(it) + imageLoader = ImageLoader.Builder(context) + .memoryCachePolicy(CachePolicy.DISABLED) + .diskCachePolicy(CachePolicy.DISABLED) + .build(), + loading = { + CircularProgressIndicator() + }, + success = { state -> + val localDensity = LocalDensity.current + + val intrinsicWidth = state.painter.intrinsicSize.width + val intrinsicHeight = state.painter.intrinsicSize.height + + val intrinsicWidthDp = with(localDensity) { + intrinsicWidth.toDp() + } + + val intrinsicHeightDp = with(localDensity) { + intrinsicHeight.toDp() + } + + MatchLargestChildBoxWithTouchErrorMargin( + modifier = Modifier + .requiredWidth(intrinsicWidthDp) + .requiredHeight(intrinsicHeightDp), + dependantAdditionalSpaceInPx = additionalTouchAreaAroundInPx, + mainContent = { + Image( + state.painter, + "", + Modifier + .requiredWidth(intrinsicWidthDp) + .requiredHeight(intrinsicHeightDp), + contentScale = ContentScale.None + ) + } + ) { + CropOverlay( + modifier = Modifier, + additionalTouchAreaAroundInPx, + handleTouchRadius, + onRectChange = cropRectChanged, + onPan = onPan + ) + } } - } + ) } @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @@ -285,7 +346,9 @@ fun PreviewCropOverlay() { val density = LocalDensity.current var currentRect by remember { mutableStateOf(Rect(0f, 0f, 0f, 0f)) } MatchLargestChildBoxWithTouchErrorMargin( - modifier = Modifier.padding(innerPadding).padding(top = 200.dp, start = 40.dp, end = 40.dp), + modifier = Modifier + .padding(innerPadding) + .padding(top = 200.dp, start = 40.dp, end = 40.dp), dependantAdditionalSpaceInPx = 200, mainContent = { Image( @@ -295,9 +358,9 @@ fun PreviewCropOverlay() { ) } ) { - CropOverlay(modifier = Modifier, 200, 50.dp) { + CropOverlay(modifier = Modifier, 200, 50.dp, onRectChange = { currentRect = it - } + }) } } } From 64291b25a3dca79d74585601abe4f3fa9ce6bc16 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Wed, 18 Feb 2026 19:03:16 +0100 Subject: [PATCH 07/33] Fix crash when rotating scans too fast --- .../chrisimx/scanbridge/ScanningScreen.kt | 56 +++++++++---------- .../scanbridge/data/ui/ScanningScreenData.kt | 2 +- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt b/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt index 4fbf695..e7a0bb3 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt @@ -24,7 +24,6 @@ import android.app.Application import android.content.Context import android.content.Context.MODE_PRIVATE import android.content.Intent -import android.graphics.BitmapFactory import android.net.Uri import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult @@ -106,7 +105,6 @@ import io.github.chrisimx.esclkt.millimeters import io.github.chrisimx.esclkt.threeHundredthsOfInch import io.github.chrisimx.scanbridge.data.ui.ScanRelativeRotation import io.github.chrisimx.scanbridge.data.ui.ScanningScreenViewModel -import io.github.chrisimx.scanbridge.data.ui.toggleRotation import io.github.chrisimx.scanbridge.uicomponents.ExportSettingsPopup import io.github.chrisimx.scanbridge.uicomponents.FullScreenError import io.github.chrisimx.scanbridge.uicomponents.LoadingScreen @@ -114,10 +112,7 @@ import io.github.chrisimx.scanbridge.uicomponents.dialog.ConfirmCloseDialog import io.github.chrisimx.scanbridge.uicomponents.dialog.DeletionDialog import io.github.chrisimx.scanbridge.uicomponents.dialog.LoadingDialog import io.github.chrisimx.scanbridge.util.clearAndNavigateTo -import io.github.chrisimx.scanbridge.util.getEditedImageName import io.github.chrisimx.scanbridge.util.getMaxResolution -import io.github.chrisimx.scanbridge.util.rotateBy90 -import io.github.chrisimx.scanbridge.util.saveAsJPEG import io.github.chrisimx.scanbridge.util.snackBarError import io.github.chrisimx.scanbridge.util.toReadableString import io.github.chrisimx.scanbridge.util.zipFiles @@ -138,7 +133,6 @@ import timber.log.Timber private const val TAG = "ScanningScreen" - fun doZipExport( scanningViewModel: ScanningScreenViewModel, context: Context, @@ -788,6 +782,7 @@ fun ScanContent( ) { val pagerState = scanningViewModel.scanningScreenData.pagerState val context = LocalContext.current + val currentScans = scanningViewModel.scanningScreenData.currentScansState Column( modifier = Modifier @@ -807,8 +802,6 @@ fun ScanContent( ) ) - val currentScans = scanningViewModel.scanningScreenData.currentScansState - if (currentScans.size > pagerState.currentPage) { Text( currentScans[pagerState.currentPage].originalScanSettings.inputSource?.toReadableString( @@ -824,31 +817,30 @@ fun ScanContent( state = pagerState ) { page -> if (page >= scanningViewModel.scanningScreenData.currentScansState.size) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - CircularProgressIndicator() - Text(modifier = Modifier.padding(vertical = 15.dp), text = stringResource(R.string.retrieving_page)) + if (scanningViewModel.scanningScreenData.scanJobRunning) { + DownloadingPageFullscreen(innerPadding) + } else { + FullScreenError( + R.drawable.rounded_document_scanner_24, + stringResource(R.string.no_scans_yet) + ) } return@HorizontalPager } else { val zoomState = rememberZoomableState(zoomSpec = ZoomSpec(5f)) Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .padding(vertical = 5.dp) + .zoomable(zoomState), contentAlignment = Alignment.Center ) { - val imagePath = scanningViewModel.scanningScreenData.currentScansState[page].filePath + val imagePath = scanningViewModel.scanningScreenData.currentScansState.getOrNull(page)?.filePath AsyncImage( model = imagePath, contentDescription = stringResource(R.string.desc_scanned_page), modifier = Modifier - .zoomable(zoomState) - .padding(vertical = 5.dp) .testTag("scan_page") ) } @@ -856,13 +848,6 @@ fun ScanContent( } } - if (scanningViewModel.scanningScreenData.currentScansState.isEmpty() && !scanningViewModel.scanningScreenData.scanJobRunning) { - FullScreenError( - R.drawable.rounded_document_scanner_24, - stringResource(R.string.no_scans_yet) - ) - } - if (pagerState.currentPage < scanningViewModel.scanningScreenData.currentScansState.size) { Box( modifier = Modifier @@ -962,3 +947,18 @@ fun ScanContent( } } } + +@Composable +private fun DownloadingPageFullscreen(innerPadding: PaddingValues) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator() + Text(modifier = Modifier.padding(vertical = 15.dp), text = stringResource(R.string.retrieving_page)) + } + return +} diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt index 194026f..0d78b87 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt @@ -68,7 +68,7 @@ data class ScanningScreenData( val stateCurrentScans: SnapshotStateList = mutableStateListOf(), val createdTempFiles: MutableList = mutableListOf(), val pagerState: PagerState = PagerState { - stateCurrentScans.size + if (scanJobRunning.value) 1 else 0 + (stateCurrentScans.size + if (scanJobRunning.value) 1 else 0).coerceAtLeast(1) }, val sourceFileToSave: MutableState = mutableStateOf(null), val isRotating: MutableState = mutableStateOf(false) From 4862aea80e81ae95a692a0562f762b1172abf50b Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Wed, 18 Feb 2026 19:08:25 +0100 Subject: [PATCH 08/33] Add assertations to clearAndNavigateTo to make sure it is noticed if it does not work --- .../chrisimx/scanbridge/util/NavControllerExtensions.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/util/NavControllerExtensions.kt b/app/src/main/java/io/github/chrisimx/scanbridge/util/NavControllerExtensions.kt index bd5a3c1..4befb83 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/util/NavControllerExtensions.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/util/NavControllerExtensions.kt @@ -5,9 +5,12 @@ import androidx.navigation.NavController fun NavController.clearAndNavigateTo(route: Any) { val navController = this navController.navigate(route) { - popUpTo(navController.graph.startDestinationId) { + popUpTo(0) { inclusive = true } launchSingleTop = true } + assert(navController.previousBackStackEntry == null, + { "clearAndNavigateTo did not correctly clear backstack! Please report this."} + ) } From 0987104a8d2fc49a88adcc351dbab3775900d674 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Wed, 18 Feb 2026 19:08:41 +0100 Subject: [PATCH 09/33] Add logging to SessionsStore --- .../io/github/chrisimx/scanbridge/stores/SessionsStore.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/stores/SessionsStore.kt b/app/src/main/java/io/github/chrisimx/scanbridge/stores/SessionsStore.kt index c0420d1..d75f384 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/stores/SessionsStore.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/stores/SessionsStore.kt @@ -34,6 +34,8 @@ object SessionsStore { } fun loadSession(application: Context, sessionID: String): Session? { + Timber.d("Loading session $sessionID") + val path = application.applicationInfo.dataDir + "/files/" + sessionID + ".session" val file = File(path) @@ -49,6 +51,8 @@ object SessionsStore { @OptIn(ExperimentalSerializationApi::class) fun saveSession(session: Session, application: Context, sessionID: String): String { + Timber.d("Saving session $sessionID with $session") + val path = application.applicationInfo.dataDir + "/files/" + sessionID + ".session" val file = File(path) From dc54f6b04ec6ad3c978cc497ca83ee80e3e55e13 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Wed, 18 Feb 2026 19:10:05 +0100 Subject: [PATCH 10/33] Apply format --- .../chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt index 1b9f1f2..5b84f49 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt @@ -211,7 +211,6 @@ class ScanningScreenViewModel( _scanningScreenData.isRotating.value = false } - fun setScannerCapabilities(caps: ScannerCapabilities) { _scanningScreenData.capabilities.value = caps val storedSession = loadSessionFile() From 73319449513568d79fe68b35d46749d88c7a73f9 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Wed, 18 Feb 2026 19:34:13 +0100 Subject: [PATCH 11/33] Apply format --- .../chrisimx/scanbridge/util/NavControllerExtensions.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/util/NavControllerExtensions.kt b/app/src/main/java/io/github/chrisimx/scanbridge/util/NavControllerExtensions.kt index 4befb83..74e2460 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/util/NavControllerExtensions.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/util/NavControllerExtensions.kt @@ -10,7 +10,8 @@ fun NavController.clearAndNavigateTo(route: Any) { } launchSingleTop = true } - assert(navController.previousBackStackEntry == null, - { "clearAndNavigateTo did not correctly clear backstack! Please report this."} + assert( + navController.previousBackStackEntry == null, + { "clearAndNavigateTo did not correctly clear backstack! Please report this." } ) } From 8b6ed7c405ff274686369a2cec9df6852626252e Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Wed, 18 Feb 2026 22:47:05 +0100 Subject: [PATCH 12/33] Recycle bitmaps early and check for null --- .../io/github/chrisimx/scanbridge/CropScreen.kt | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt b/app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt index e11c9c5..7d38e9d 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt @@ -46,14 +46,22 @@ import me.saket.telephoto.zoomable.EnabledZoomGestures import me.saket.telephoto.zoomable.ZoomSpec import me.saket.telephoto.zoomable.rememberZoomableState import me.saket.telephoto.zoomable.zoomable +import timber.log.Timber -suspend fun finishCrop(cropRect: Rect, file: String): File = withContext(Dispatchers.IO) { +suspend fun finishCrop(cropRect: Rect, file: String): File? = withContext(Dispatchers.IO) { val sourceBitmap = BitmapFactory.decodeFile(file) + if (sourceBitmap == null) { + Timber.e("Could not decode source bitmap for cropping") + return@withContext null + } + val croppedBitmap = sourceBitmap.cropWithRect(cropRect) + sourceBitmap.recycle() val file = File(file) val croppedFile = File(file.parent, file.getEditedImageName()) croppedBitmap.saveAsJPEG(croppedFile) + croppedBitmap.recycle() return@withContext croppedFile } @@ -106,7 +114,8 @@ fun CropScreen(sessionID: String, pageIdx: Int, returnRoute: BaseRoute, navContr if (processing) return@launch processing = true - val croppedFile = finishCrop(currentRect, originalImageFile) + val croppedFile = finishCrop(currentRect, originalImageFile) ?: return@launch + updateSessionFile(originalSession, pageIdx, croppedFile, originalImageFile, context, sessionID) navController.clearAndNavigateTo(returnRoute) processing = false From b5895dfc95def64d963cab7ccc88587d4d98698b Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Wed, 18 Feb 2026 22:57:13 +0100 Subject: [PATCH 13/33] Remove unused CropScreenBottomBar --- .../main/java/io/github/chrisimx/scanbridge/CropScreen.kt | 8 -------- 1 file changed, 8 deletions(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt b/app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt index 7d38e9d..0c43abd 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt @@ -175,11 +175,3 @@ fun CropScreen(sessionID: String, pageIdx: Int, returnRoute: BaseRoute, navContr } } } - -@Composable -fun CropScreenBottomBar() { - BottomAppBar( - actions = { - } - ) -} From eb800475fb59e3e021032825d303150ba85a7dbb Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Wed, 18 Feb 2026 22:57:52 +0100 Subject: [PATCH 14/33] Clean up CroppableAsyncImage composable (make imageLoader overridable) --- .../scanbridge/uicomponents/CroppableImage.kt | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt b/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt index 0453bad..622aef7 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt @@ -274,7 +274,8 @@ fun CroppableAsyncImage( additionalTouchAreaAround: Dp, handleTouchRadius: Dp, cropRectChanged: (Rect) -> Unit, - onPan: (Offset) -> Unit + onPan: (Offset) -> Unit, + imageLoader: ImageLoader? = null ) { val context = LocalContext.current val density = LocalDensity.current @@ -282,14 +283,16 @@ fun CroppableAsyncImage( additionalTouchAreaAround.toPx().toInt() } + val selectedImageLoader = imageLoader ?: ImageLoader.Builder(context) + .memoryCachePolicy(CachePolicy.DISABLED) + .diskCachePolicy(CachePolicy.DISABLED) + .build() + SubcomposeAsyncImage( model = imageModel, contentDescription = contentDescription, modifier = modifier, - imageLoader = ImageLoader.Builder(context) - .memoryCachePolicy(CachePolicy.DISABLED) - .diskCachePolicy(CachePolicy.DISABLED) - .build(), + imageLoader = selectedImageLoader, loading = { CircularProgressIndicator() }, @@ -315,7 +318,7 @@ fun CroppableAsyncImage( mainContent = { Image( state.painter, - "", + contentDescription, Modifier .requiredWidth(intrinsicWidthDp) .requiredHeight(intrinsicHeightDp), From afdb1852cfb94a05182b476ec38a7b51409e0474 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Wed, 18 Feb 2026 23:00:13 +0100 Subject: [PATCH 15/33] Make sure to prevent division by zero for relativeRect in CropOverlay --- .../scanbridge/uicomponents/CroppableImage.kt | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt b/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt index 622aef7..c89ef4a 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt @@ -104,12 +104,16 @@ fun CropOverlay( var size by remember { mutableStateOf(IntSize.Zero) } val relativeRect by remember { derivedStateOf { - Rect( - rect.left / size.width, - rect.top / size.height, - rect.right / size.width, - rect.bottom / size.height - ) + if (size.width == 0 || size.height == 0) { + Rect(1f, 1f, 1f, 1f) + } else { + Rect( + rect.left / size.width, + rect.top / size.height, + rect.right / size.width, + rect.bottom / size.height + ) + } } } From dab09df41291798d6302c10af974f4aa49d0fb40 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Wed, 18 Feb 2026 23:01:53 +0100 Subject: [PATCH 16/33] Remove unnecessary return --- .../main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt b/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt index e7a0bb3..15df0fa 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt @@ -960,5 +960,4 @@ private fun DownloadingPageFullscreen(innerPadding: PaddingValues) { CircularProgressIndicator() Text(modifier = Modifier.padding(vertical = 15.dp), text = stringResource(R.string.retrieving_page)) } - return } From fa92ee74492ab2ea89a0e3f610fb2d30678fcadc Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Wed, 18 Feb 2026 23:02:31 +0100 Subject: [PATCH 17/33] Fix typo in variable name --- .../chrisimx/scanbridge/uicomponents/CroppableImage.kt | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt b/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt index c89ef4a..2a26711 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt @@ -5,12 +5,9 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredHeight import androidx.compose.foundation.layout.requiredWidth -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Scaffold @@ -173,9 +170,9 @@ fun CropOverlay( } }, onDrag = { pointerInputChange, dragChange -> - val evenType = lastDragEventType + val eventType = lastDragEventType - when (evenType) { + when (eventType) { CropDragEvent.DraggedInside -> { val possibleXChange = if (rect.left + dragChange.x < 0) { -rect.left @@ -197,7 +194,7 @@ fun CropOverlay( } is CropDragEvent.ResizeHandleDragged -> { - val currentlyDraggedHandle = handles[evenType.idx] + val currentlyDraggedHandle = handles[eventType.idx] val edgeFlags = currentlyDraggedHandle.edgeFlags rect = rect.applyResizeDrag(dragChange, size, edgeFlags, density) } From a9f7ac18a5ca1ad129400244ccfc483083ff8bc1 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Thu, 19 Feb 2026 00:19:03 +0100 Subject: [PATCH 18/33] Use check instead of assert to make sure error is thrown --- .../chrisimx/scanbridge/util/NavControllerExtensions.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/util/NavControllerExtensions.kt b/app/src/main/java/io/github/chrisimx/scanbridge/util/NavControllerExtensions.kt index 74e2460..e5055eb 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/util/NavControllerExtensions.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/util/NavControllerExtensions.kt @@ -1,6 +1,7 @@ package io.github.chrisimx.scanbridge.util import androidx.navigation.NavController +import timber.log.Timber fun NavController.clearAndNavigateTo(route: Any) { val navController = this @@ -10,8 +11,5 @@ fun NavController.clearAndNavigateTo(route: Any) { } launchSingleTop = true } - assert( - navController.previousBackStackEntry == null, - { "clearAndNavigateTo did not correctly clear backstack! Please report this." } - ) + check(navController.previousBackStackEntry == null, { "clearAndNavigateTo did not correctly clear backstack!" }) } From 2032f4efe66195bf0260439916453d7bd1241b91 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Thu, 19 Feb 2026 00:19:45 +0100 Subject: [PATCH 19/33] Start activity to show uncaught exceptions --- app/src/main/AndroidManifest.xml | 4 + .../chrisimx/scanbridge/CrashActivity.kt | 118 ++++++++++++++++++ .../chrisimx/scanbridge/CrashHandler.kt | 45 ++++--- .../chrisimx/scanbridge/MainActivity.kt | 3 +- .../chrisimx/scanbridge/ScanBridgeNavHost.kt | 16 +++ .../uicomponents/FullScreenError.kt | 17 ++- .../main/res/drawable/outline_error_24.xml | 5 + .../res/drawable/rounded_content_copy_24.xml | 5 + app/src/main/res/values/strings.xml | 2 + 9 files changed, 196 insertions(+), 19 deletions(-) create mode 100644 app/src/main/java/io/github/chrisimx/scanbridge/CrashActivity.kt create mode 100644 app/src/main/res/drawable/outline_error_24.xml create mode 100644 app/src/main/res/drawable/rounded_content_copy_24.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index aa5dffb..bffb304 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -36,6 +36,10 @@ + + \ No newline at end of file diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/CrashActivity.kt b/app/src/main/java/io/github/chrisimx/scanbridge/CrashActivity.kt new file mode 100644 index 0000000..52aa88c --- /dev/null +++ b/app/src/main/java/io/github/chrisimx/scanbridge/CrashActivity.kt @@ -0,0 +1,118 @@ +package io.github.chrisimx.scanbridge + +import android.content.ClipData +import android.content.ClipboardManager +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.github.chrisimx.scanbridge.theme.ScanBridgeTheme +import java.io.File +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import timber.log.Timber + +class CrashActivity : ComponentActivity() { + @OptIn(ExperimentalMaterial3ExpressiveApi::class) + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + + val error = intent.getStringExtra("error") ?: "Unknown error" + val crashLog = intent.getStringExtra("crash_file") + + Timber.plant(Timber.DebugTree()) + + Timber.e("$crashLog") + + try { + CoroutineScope(Dispatchers.IO).launch { + crashLog?.let { + val crashLogFile = File(it) + if (crashLogFile.exists()) { + crashLogFile.delete() + } + } + } + } catch (e: Exception) { + Timber.e("Error in CrashActivity while trying to remove crash log file: $e") + } + + setContent { + ScanBridgeTheme { + Scaffold( + floatingActionButton = { + FloatingActionButton({ + try { + val clipboard = getSystemService(ClipboardManager::class.java) + val clip = ClipData.newPlainText("Crash log", error) + clipboard.setPrimaryClip(clip) + } catch (e: Exception) { + Timber.e("Error in CrashActivity while trying to copy crash to clipboard: $e") + } + }) { + Row(horizontalArrangement = Arrangement.SpaceAround, verticalAlignment = Alignment.CenterVertically) { + Icon( + painterResource(R.drawable.rounded_content_copy_24), + contentDescription = stringResource(R.string.copy), + Modifier.padding(start = 10.dp, top = 10.dp, bottom = 10.dp, end = 5.dp) + ) + Text( + stringResource(R.string.copy), + Modifier.padding(start = 5.dp, top = 10.dp, bottom = 10.dp, end = 10.dp),) + } + } + } + ) + { innerPadding -> + val scrollState = rememberScrollState() + + Column ( + modifier = Modifier + .padding(innerPadding) + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + painterResource(R.drawable.outline_error_24), + stringResource(R.string.crash), + Modifier.padding(20.dp).size(64.dp) + ) + + Column(modifier = Modifier.verticalScroll(scrollState)) { + Text( + stringResource(R.string.crash_occured), + modifier = Modifier.padding(bottom = 14.dp), + style = MaterialTheme.typography.bodySmallEmphasized + ) + Text( + error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/CrashHandler.kt b/app/src/main/java/io/github/chrisimx/scanbridge/CrashHandler.kt index ea468e7..032ec79 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/CrashHandler.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/CrashHandler.kt @@ -21,6 +21,7 @@ package io.github.chrisimx.scanbridge import android.content.Context import android.content.Context.MODE_PRIVATE +import android.content.Intent import androidx.core.content.edit import java.io.File import java.time.LocalDateTime @@ -32,26 +33,40 @@ class CrashHandler(val context: Context) : Thread.UncaughtExceptionHandler { private val defaultHandler = Thread.getDefaultUncaughtExceptionHandler() override fun uncaughtException(t: Thread, e: Throwable) { - Timber.e(e, "Uncaught exception") + try { + Timber.e(e, "Uncaught exception") - val format = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss") - val dateTime = LocalDateTime.now().format(format) + val format = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss") + val dateTime = LocalDateTime.now().format(format) - val crashDir = File(context.filesDir, "crashes") - if (!crashDir.exists()) { - if (!crashDir.mkdirs()) { - Timber.e("Couldn't create crash directory") - File(context.filesDir, "crash-$dateTime.log").writeText(e.stackTraceToString()) - return + val crashDir = File(context.filesDir, "crashes") + if (!crashDir.exists()) { + if (!crashDir.mkdirs()) { + Timber.e("Couldn't create crash directory") + File(context.filesDir, "crash-$dateTime.log").writeText(e.stackTraceToString()) + return + } } - } - File(crashDir, "crash-$dateTime.log").writeText(e.stackTraceToString()) + File(crashDir, "crash-$dateTime.log").writeText(e.stackTraceToString()) + + context.getSharedPreferences("route_store", MODE_PRIVATE) + .edit { + remove("last_route") + } - context.getSharedPreferences("route_store", MODE_PRIVATE) - .edit { - remove("last_route") + // Try to show an error activity + val intent = Intent(context, CrashActivity::class.java).apply { + putExtra("error", e.stackTraceToString()) + putExtra("crash_file", crashDir.resolve("crash-$dateTime.log").absolutePath) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) } - defaultHandler?.uncaughtException(t, e) + context.startActivity(intent) + + android.os.Process.killProcess(android.os.Process.myPid()) + + } catch (newException: Exception) { + Timber.e(newException, "Uncaught exception in CrashHandler") + } } } diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/MainActivity.kt b/app/src/main/java/io/github/chrisimx/scanbridge/MainActivity.kt index a7f8785..626abf3 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/MainActivity.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/MainActivity.kt @@ -42,6 +42,7 @@ class MainActivity : ComponentActivity() { var saveDebugFileLauncher: ActivityResultLauncher? = null override fun onCreate(savedInstanceState: Bundle?) { + Thread.setDefaultUncaughtExceptionHandler(CrashHandler(this)) enableEdgeToEdge() super.onCreate(savedInstanceState) @@ -80,8 +81,6 @@ class MainActivity : ComponentActivity() { cleanUpCacheFiles() } - Thread.setDefaultUncaughtExceptionHandler(CrashHandler(this)) - setContent { ScanBridgeApp() } diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt b/app/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt index 4c7a1e6..b0453f9 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt @@ -30,6 +30,7 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.toRoute +import io.github.chrisimx.scanbridge.uicomponents.FullScreenError import io.github.chrisimx.scanbridge.uicomponents.TemporaryFileHandler import io.github.chrisimx.scanbridge.util.doTempFilesExist import io.ktor.http.Url @@ -53,6 +54,10 @@ data class ScannerRoute(val scannerName: String, val scannerURL: String, val ses @SerialName("CropImageRoute") data class CropImageRoute(val sessionID: String, val pageIdx: Int, val returnRoute: String) : BaseRoute +@Serializable +@SerialName("ErrorRoute") +data class ErrorRoute(val error: String) : BaseRoute + fun NavBackStackEntry.toTypedRoute(): BaseRoute? { Timber.d("Route changed to: ${destination.route}") return when (destination.route) { @@ -72,6 +77,11 @@ fun NavBackStackEntry.toTypedRoute(): BaseRoute? { ScannerRoute(scannerName, scannerURL, sessionID) } + "ErrorRoute/{error}" -> { + val error = arguments?.getString("error") ?: return null + ErrorRoute(error) + } + else -> null } } @@ -86,6 +96,12 @@ fun ScanBridgeNavHost(navController: NavHostController, startDestination: Any) { navController = navController, startDestination = startDestination ) { + composable { backStackEntry -> + val errorRoute: ErrorRoute = backStackEntry.toRoute() + val errorMessage = errorRoute.error + + FullScreenError(R.drawable.outline_error_24, errorMessage, true) + } composable { StartupScreen(navController) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/FullScreenError.kt b/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/FullScreenError.kt index 43ec2f2..ca8a38d 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/FullScreenError.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/FullScreenError.kt @@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton @@ -43,12 +44,19 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import io.github.chrisimx.scanbridge.R +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun FullScreenError(errorIcon: Int, errorMessage: String, copyButton: Boolean = false) { +fun FullScreenError(errorIcon: Int, + errorMessage: String, + copyButton: Boolean = false, + fontSize: TextUnit = 18.sp, + title: String? = null +) { val context = LocalContext.current val scrollState = rememberScrollState() @@ -67,6 +75,11 @@ fun FullScreenError(errorIcon: Int, errorMessage: String, copyButton: Boolean = tint = MaterialTheme.colorScheme.primary ) + if (title != null) { + Text(title, + style = MaterialTheme.typography.titleMediumEmphasized) + } + Column( modifier = Modifier.verticalScroll(scrollState) .weight(1f, false) @@ -79,7 +92,7 @@ fun FullScreenError(errorIcon: Int, errorMessage: String, copyButton: Boolean = .padding(20.dp), textAlign = TextAlign.Center, fontWeight = FontWeight.Light, - fontSize = 18.sp + fontSize = fontSize ) } diff --git a/app/src/main/res/drawable/outline_error_24.xml b/app/src/main/res/drawable/outline_error_24.xml new file mode 100644 index 0000000..769755c --- /dev/null +++ b/app/src/main/res/drawable/outline_error_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_content_copy_24.xml b/app/src/main/res/drawable/rounded_content_copy_24.xml new file mode 100644 index 0000000..edc172a --- /dev/null +++ b/app/src/main/res/drawable/rounded_content_copy_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 94fbd74..3c02d97 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -107,4 +107,6 @@ Crop Cropping page… Activate zoom gestures + ScanBridge crashed :( + "We are sorry. ScanBridge has crashed. Please report this issue and attach the following stacktrace (you can copy it with the button at the bottom)" \ No newline at end of file From 71d9b864081f2042a7db775abc230e359745ef1c Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Thu, 19 Feb 2026 11:29:05 +0100 Subject: [PATCH 20/33] Add support for displaying different kinds of errors in ScanningScreen --- .../github/chrisimx/scanbridge/CropScreen.kt | 9 ++++++-- .../chrisimx/scanbridge/ScanningScreen.kt | 10 ++++---- .../chrisimx/scanbridge/data/model/Session.kt | 14 +++++++---- .../scanbridge/data/ui/ScanningScreenData.kt | 14 +++++++---- .../data/ui/ScanningScreenViewModel.kt | 23 +++++++++++++++---- .../scanbridge/stores/SessionsStore.kt | 4 ++-- .../main/res/drawable/rounded_warning_24.xml | 5 ++++ app/src/main/res/values/strings.xml | 1 + 8 files changed, 60 insertions(+), 20 deletions(-) create mode 100644 app/src/main/res/drawable/rounded_warning_24.xml diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt b/app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt index 0c43abd..8a01fee 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt @@ -100,13 +100,18 @@ fun CropScreen(sessionID: String, pageIdx: Int, returnRoute: BaseRoute, navContr var processing: Boolean by remember { mutableStateOf(false) } - val originalSession: Session? = remember { SessionsStore.loadSession(context, sessionID) } + val originalSessionResult: Result = remember { + SessionsStore.loadSession(context, sessionID) + } - if (originalSession == null) { + if (originalSessionResult.getOrNull() == null) { + Timber.e("Could not decode/get session with $sessionID but CropScreen was opened") navController.clearAndNavigateTo(StartUpScreenRoute) return } + val originalSession = originalSessionResult.getOrThrow()!! + val originalImageFile = remember { originalSession.scannedPages[pageIdx].filePath } val save: () -> Unit = { diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt b/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt index 15df0fa..136d7da 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt @@ -510,7 +510,7 @@ fun ScanningScreen( val isError by remember { derivedStateOf { - scanningViewModel.scanningScreenData.errorString != null + scanningViewModel.scanningScreenData.error != null } } @@ -530,16 +530,18 @@ fun ScanningScreen( ) } + AnimatedVisibility( isError, enter = fadeIn(animationSpec = tween(1000)), exit = fadeOut(animationSpec = tween(1000)) ) { + val errorDescription = scanningViewModel.scanningScreenData.error FullScreenError( - R.drawable.twotone_wifi_find_24, + errorDescription?.icon ?: R.drawable.twotone_wifi_find_24, stringResource( - R.string.scannercapabilities_retrieve_error, - scanningViewModel.scanningScreenData.errorString!! + errorDescription?.pretext ?: R.string.scannercapabilities_retrieve_error, + errorDescription?.text ?: "Error text not found" ), copyButton = true ) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/model/Session.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/model/Session.kt index f28764b..174a899 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/model/Session.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/model/Session.kt @@ -4,6 +4,7 @@ import io.github.chrisimx.esclkt.ScanSettings import io.github.chrisimx.scanbridge.data.ui.ScanMetadata import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +import timber.log.Timber @Serializable data class Session( @@ -13,11 +14,16 @@ data class Session( val tmpFiles: List ) { companion object { - fun fromString(sessionFileString: String, json: Json): Session = try { - json.decodeFromString(sessionFileString) + fun fromString(sessionFileString: String, json: Json): Result = try { + Result.success(json.decodeFromString(sessionFileString)) } catch (_: Exception) { - val oldSessionVersion = json.decodeFromString(sessionFileString) - oldSessionVersion.migrateToNew() + try { + Timber.e("Could not decode Session at $sessionFileString. Trying with old format") + val oldSessionVersion = json.decodeFromString(sessionFileString) + Result.success(oldSessionVersion.migrateToNew()) + } catch (e: Exception) { + Result.failure(e) + } } } } diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt index 0d78b87..e8aff2f 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt @@ -37,6 +37,12 @@ enum class ScanRelativeRotation { Original } +data class ErrorDescription( + val pretext: Int?, + val icon: Int?, + val text: String? +) + fun ScanRelativeRotation.toggleRotation() = when (this) { ScanRelativeRotation.Rotated -> ScanRelativeRotation.Original ScanRelativeRotation.Original -> ScanRelativeRotation.Rotated @@ -54,7 +60,7 @@ data class ScanningScreenData( val sessionID: String, val confirmDialogShown: MutableState = mutableStateOf(false), val confirmPageDeleteDialogShown: MutableState = mutableStateOf(false), - val errorString: MutableState = mutableStateOf(null), + val error: MutableState = mutableStateOf(null), val scanSettingsVM: MutableState = mutableStateOf(null), val capabilities: MutableState = mutableStateOf(null), val scanSettingsMenuOpen: MutableState = mutableStateOf(false), @@ -78,7 +84,7 @@ data class ScanningScreenData( sessionID, confirmDialogShown, confirmPageDeleteDialogShown, - errorString, + error, scanSettingsVM, capabilities, scanSettingsMenuOpen, @@ -102,7 +108,7 @@ data class ImmutableScanningScreenData( val sessionID: String, private val confirmDialogShownState: State, private val confirmPageDeleteDialogShownState: State, - private val errorStringState: State, + private val errorState: State, private val scanSettingsVMState: State, private val capabilitiesState: State, private val scanSettingsMenuOpenState: State, @@ -127,7 +133,7 @@ data class ImmutableScanningScreenData( val scanJobCancelling by scanJobCancellingState val progressStringResource by progressStringResState val capabilities by capabilitiesState - val errorString by errorStringState + val error by errorState val showExportOptions by showExportOptionsState val showSaveOptions by showSaveOptionsState val exportOptionsPopupPosition by exportOptionsPopupPositionState diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt index 5b84f49..0164e82 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt @@ -169,8 +169,12 @@ class ScanningScreenViewModel( _scanningScreenData.scanJobCancelling.value = value } - fun setError(value: String?) { - _scanningScreenData.errorString.value = value + fun setError(error: String?, titleResource: Int? = null, errorIcon: Int? = null) { + _scanningScreenData.error.value = ErrorDescription( + titleResource, + errorIcon, + error + ) } fun rotateCurrentPage() { @@ -213,7 +217,18 @@ class ScanningScreenViewModel( fun setScannerCapabilities(caps: ScannerCapabilities) { _scanningScreenData.capabilities.value = caps - val storedSession = loadSessionFile() + val storedSessionResult = loadSessionFile() + + storedSessionResult.onFailure { + setError( + it.toString(), + R.string.loading_previous_session_failed, + R.drawable.rounded_warning_24 + ) + return + } + + val storedSession = storedSessionResult.getOrThrow() if (storedSession != null) { scanningScreenData.currentScansState.addAll(storedSession.scannedPages) @@ -328,7 +343,7 @@ class ScanningScreenViewModel( } @OptIn(ExperimentalSerializationApi::class) - fun loadSessionFile(): Session? = SessionsStore.loadSession(application, scanningScreenData.sessionID) + fun loadSessionFile(): Result = SessionsStore.loadSession(application, scanningScreenData.sessionID) fun swapTwoPages(index1: Int, index2: Int) { if (index1 < 0 || diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/stores/SessionsStore.kt b/app/src/main/java/io/github/chrisimx/scanbridge/stores/SessionsStore.kt index d75f384..09ba969 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/stores/SessionsStore.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/stores/SessionsStore.kt @@ -33,7 +33,7 @@ object SessionsStore { prettyPrint = false } - fun loadSession(application: Context, sessionID: String): Session? { + fun loadSession(application: Context, sessionID: String): Result { Timber.d("Loading session $sessionID") val path = application.applicationInfo.dataDir + "/files/" + sessionID + ".session" @@ -41,7 +41,7 @@ object SessionsStore { if (!file.exists()) { Timber.d("Could not find session file at $path") - return null + return Result.success(null) } val sessionFileString = file.readText() diff --git a/app/src/main/res/drawable/rounded_warning_24.xml b/app/src/main/res/drawable/rounded_warning_24.xml new file mode 100644 index 0000000..575870b --- /dev/null +++ b/app/src/main/res/drawable/rounded_warning_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3c02d97..c86f4ca 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -109,4 +109,5 @@ Activate zoom gestures ScanBridge crashed :( "We are sorry. ScanBridge has crashed. Please report this issue and attach the following stacktrace (you can copy it with the button at the bottom)" + Loading previous session failed…\n\n%1$s \ No newline at end of file From f41ae1f5d77518c42ead75476146d278e8da846e Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Thu, 19 Feb 2026 11:34:06 +0100 Subject: [PATCH 21/33] Make rotateCurrentPage complete the loading UI, even if it failed --- .../data/ui/ScanningScreenViewModel.kt | 62 ++++++++++--------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt index 0164e82..33539af 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt @@ -183,36 +183,38 @@ class ScanningScreenViewModel( } _scanningScreenData.isRotating.value = true setLoadingText(R.string.rotating_page) - - val currentScans = scanningScreenData.currentScansState - val currentPagePath = - currentScans[scanningScreenData.pagerState.currentPage].filePath - val currentPageFile = File(currentPagePath) - - Timber.d("Decoding $currentPagePath") - val originalBitmap = BitmapFactory.decodeFile(currentPagePath) - Timber.d("Rotating $currentPagePath") - val rotatedBitmap = originalBitmap.rotateBy90() - originalBitmap.recycle() - - val editedImageName = currentPageFile.getEditedImageName() - val newFile = File(application.filesDir, editedImageName) - - Timber.d("Saving rotated $currentPagePath") - rotatedBitmap.saveAsJPEG(newFile) - - Timber.d("Finished saving rotated $currentPagePath") - - val index = scanningScreenData.pagerState.currentPage - val scanSettings = currentScans[index].originalScanSettings - val priorRotation = currentScans[index].rotation - Timber.d("Updating UI state after rotation") - removeScanAtIndex(index) - addTempFile(currentPageFile) - addScanAtIndex(newFile.absolutePath, scanSettings, priorRotation.toggleRotation(), index) - - setLoadingText(null) - _scanningScreenData.isRotating.value = false + try { + val currentScans = scanningScreenData.currentScansState + val currentPagePath = + currentScans[scanningScreenData.pagerState.currentPage].filePath + val currentPageFile = File(currentPagePath) + + Timber.d("Decoding $currentPagePath") + val originalBitmap = BitmapFactory.decodeFile(currentPagePath) + Timber.d("Rotating $currentPagePath") + val rotatedBitmap = originalBitmap.rotateBy90() + originalBitmap.recycle() + + val editedImageName = currentPageFile.getEditedImageName() + val newFile = File(application.filesDir, editedImageName) + + Timber.d("Saving rotated $currentPagePath") + rotatedBitmap.saveAsJPEG(newFile) + rotatedBitmap.recycle() + + Timber.d("Finished saving rotated $currentPagePath") + + val index = scanningScreenData.pagerState.currentPage + val scanSettings = currentScans[index].originalScanSettings + val priorRotation = currentScans[index].rotation + Timber.d("Updating UI state after rotation") + removeScanAtIndex(index) + addTempFile(currentPageFile) + addScanAtIndex(newFile.absolutePath, scanSettings, priorRotation.toggleRotation(), index) + } finally { + setLoadingText(null) + _scanningScreenData.isRotating.value = false + } } fun setScannerCapabilities(caps: ScannerCapabilities) { From c7a9983980057ad35a7c47b23463a4a0ac6fccc8 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Thu, 19 Feb 2026 11:36:44 +0100 Subject: [PATCH 22/33] Set fallback return route for CropImageRoute if the real one can't be decoded --- .../io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt b/app/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt index b0453f9..85094eb 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt @@ -111,7 +111,13 @@ fun ScanBridgeNavHost(navController: NavHostController, startDestination: Any) { } composable { backStackEntry -> val scannerRoute: CropImageRoute = backStackEntry.toRoute() - val returnRoute = Json.decodeFromString(scannerRoute.returnRoute) + val returnRoute = try { + Json.decodeFromString(scannerRoute.returnRoute) + } catch (e: Exception) { + Timber.e(e, "Failed to decode returnRoute: ${scannerRoute.returnRoute}") + navController.navigate(StartUpScreenRoute) + return@composable + } CropScreen(scannerRoute.sessionID, scannerRoute.pageIdx, returnRoute, navController) } composable { backStackEntry -> From 44f8df77ecd33fcffaec31d581d3332d42cfa078 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Thu, 19 Feb 2026 11:39:17 +0100 Subject: [PATCH 23/33] Make CropDragEvents data objects instead of just objects (toString, equals automatically implemented) --- .../github/chrisimx/scanbridge/uicomponents/CroppableImage.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt b/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt index 2a26711..67c4194 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt @@ -85,8 +85,8 @@ fun Rect.applyResizeDrag(drag: Offset, size: IntSize, flags: EdgeFlags, density: sealed class CropDragEvent { data class ResizeHandleDragged(val idx: Int) : CropDragEvent() - object DraggedOutside : CropDragEvent() - object DraggedInside : CropDragEvent() + data object DraggedOutside : CropDragEvent() + data object DraggedInside : CropDragEvent() } @Composable From 6cb2dabb64d56e324621e1b744ce87a3bb231737 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Thu, 19 Feb 2026 11:45:01 +0100 Subject: [PATCH 24/33] Check for scannercapbilities null in pdf export implementation --- .../io/github/chrisimx/scanbridge/ScanningScreen.kt | 10 +++++++++- app/src/main/res/values/strings.xml | 1 + 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt b/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt index 136d7da..834526d 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt @@ -201,6 +201,14 @@ fun doPdfExport( onError: (String) -> Unit, saveFileLauncher: ActivityResultLauncher? = null ) { + val scannerCapsNullable = scanningViewModel.scanningScreenData.capabilities + val scannerCaps = if (scannerCapsNullable == null) { + onError(context.getString(R.string.scannercapabilities_null)) + return + } else { + scannerCapsNullable + } + if (scanningViewModel.scanningScreenData.currentScansState.isEmpty()) { onError(context.getString(R.string.no_scans_yet)) return @@ -252,7 +260,7 @@ fun doPdfExport( val inputSource = scan.originalScanSettings.inputSource ?: InputSource.Platen - val fallbackResolution = scanningViewModel.scanningScreenData.capabilities!!.getMaxResolution(inputSource) + val fallbackResolution = scannerCaps.getMaxResolution(inputSource) val scannerXResolution = scan.originalScanSettings.xResolution ?: fallbackResolution.xResolution val scannerYResolution = scan.originalScanSettings.yResolution ?: fallbackResolution.yResolution diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c86f4ca..1d444d8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -110,4 +110,5 @@ ScanBridge crashed :( "We are sorry. ScanBridge has crashed. Please report this issue and attach the following stacktrace (you can copy it with the button at the bottom)" Loading previous session failed…\n\n%1$s + The scanner capabilities should be retrieved at this point, but they weren\'t. \ No newline at end of file From 562c3c99a13b0b96b4c1a2c0f152667cd38c8569 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Thu, 19 Feb 2026 11:46:59 +0100 Subject: [PATCH 25/33] Rename offset var to adjustedOffset to make it more clear that this is not the original offset --- .../chrisimx/scanbridge/uicomponents/CroppableImage.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt b/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt index 67c4194..94acf5d 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt @@ -141,17 +141,17 @@ fun CropOverlay( .pointerInput(Unit) { detectDragGestures( onDragStart = { offset -> - val offset = offset - Offset(touchPaddingAroundInPx.toFloat(), touchPaddingAroundInPx.toFloat()) + val adjustedOffset = offset - Offset(touchPaddingAroundInPx.toFloat(), touchPaddingAroundInPx.toFloat()) lastDragEventType = CropDragEvent.DraggedOutside - if (!rect.inflate(handleTouchRadius.toPx()).contains(offset)) { - onPan(offset) + if (!rect.inflate(handleTouchRadius.toPx()).contains(adjustedOffset)) { + onPan(adjustedOffset) return@detectDragGestures } lastDragEventType = CropDragEvent.DraggedInside - if (rect.deflate(rect.width / 4, rect.height / 4).contains(offset)) { + if (rect.deflate(rect.width / 4, rect.height / 4).contains(adjustedOffset)) { return@detectDragGestures } @@ -159,7 +159,7 @@ fun CropOverlay( .mapIndexed { idx, handle -> Pair( idx, - (handle.position - offset).getDistance() + (handle.position - adjustedOffset).getDistance() ) } .filter { it.second < handleTouchRadius.toPx() } From e0a15581e9d4dc82672803b6867baa34834a7afb Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Thu, 19 Feb 2026 11:49:11 +0100 Subject: [PATCH 26/33] Handle bitmap decoding error in rotateCurrentPage --- .../chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt index 33539af..05bf306 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt @@ -191,6 +191,12 @@ class ScanningScreenViewModel( Timber.d("Decoding $currentPagePath") val originalBitmap = BitmapFactory.decodeFile(currentPagePath) + if (originalBitmap == null) { + Timber.e("Failed to decode bitmap for $currentPagePath") + setLoadingText(null) + _scanningScreenData.isRotating.value = false + return + } Timber.d("Rotating $currentPagePath") val rotatedBitmap = originalBitmap.rotateBy90() originalBitmap.recycle() From ff00fb097c889e4b98970234a848ff02e96cab8c Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Thu, 19 Feb 2026 11:50:42 +0100 Subject: [PATCH 27/33] Handle failure in crop save function --- .../java/io/github/chrisimx/scanbridge/CropScreen.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt b/app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt index 8a01fee..61af201 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt @@ -119,11 +119,15 @@ fun CropScreen(sessionID: String, pageIdx: Int, returnRoute: BaseRoute, navContr if (processing) return@launch processing = true - val croppedFile = finishCrop(currentRect, originalImageFile) ?: return@launch + try { + val croppedFile = finishCrop(currentRect, originalImageFile) ?: return@launch + + updateSessionFile(originalSession, pageIdx, croppedFile, originalImageFile, context, sessionID) + navController.clearAndNavigateTo(returnRoute) + } finally { + processing = false + } - updateSessionFile(originalSession, pageIdx, croppedFile, originalImageFile, context, sessionID) - navController.clearAndNavigateTo(returnRoute) - processing = false } } From 783dea94ebd07f9fee407e616fc0356ce10a684d Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Thu, 19 Feb 2026 11:51:31 +0100 Subject: [PATCH 28/33] Apply format --- .../io/github/chrisimx/scanbridge/CrashActivity.kt | 5 +++-- .../io/github/chrisimx/scanbridge/CrashHandler.kt | 1 - .../io/github/chrisimx/scanbridge/CropScreen.kt | 2 -- .../io/github/chrisimx/scanbridge/ScanningScreen.kt | 1 - .../scanbridge/data/ui/ScanningScreenData.kt | 6 +----- .../scanbridge/data/ui/ScanningScreenViewModel.kt | 2 +- .../scanbridge/uicomponents/FullScreenError.kt | 13 +++++-------- .../scanbridge/util/NavControllerExtensions.kt | 1 - 8 files changed, 10 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/CrashActivity.kt b/app/src/main/java/io/github/chrisimx/scanbridge/CrashActivity.kt index 52aa88c..95b73f7 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/CrashActivity.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/CrashActivity.kt @@ -78,7 +78,8 @@ class CrashActivity : ComponentActivity() { ) Text( stringResource(R.string.copy), - Modifier.padding(start = 5.dp, top = 10.dp, bottom = 10.dp, end = 10.dp),) + Modifier.padding(start = 5.dp, top = 10.dp, bottom = 10.dp, end = 10.dp) + ) } } } @@ -86,7 +87,7 @@ class CrashActivity : ComponentActivity() { { innerPadding -> val scrollState = rememberScrollState() - Column ( + Column( modifier = Modifier .padding(innerPadding) .padding(horizontal = 20.dp), diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/CrashHandler.kt b/app/src/main/java/io/github/chrisimx/scanbridge/CrashHandler.kt index 032ec79..ebba44e 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/CrashHandler.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/CrashHandler.kt @@ -64,7 +64,6 @@ class CrashHandler(val context: Context) : Thread.UncaughtExceptionHandler { context.startActivity(intent) android.os.Process.killProcess(android.os.Process.myPid()) - } catch (newException: Exception) { Timber.e(newException, "Uncaught exception in CrashHandler") } diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt b/app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt index 61af201..fbd9f98 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt @@ -7,7 +7,6 @@ import androidx.compose.animation.core.snap import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.material3.BottomAppBar import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FabPosition @@ -127,7 +126,6 @@ fun CropScreen(sessionID: String, pageIdx: Int, returnRoute: BaseRoute, navContr } finally { processing = false } - } } diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt b/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt index 834526d..9db3ff2 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt @@ -538,7 +538,6 @@ fun ScanningScreen( ) } - AnimatedVisibility( isError, enter = fadeIn(animationSpec = tween(1000)), diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt index e8aff2f..6f8b588 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenData.kt @@ -37,11 +37,7 @@ enum class ScanRelativeRotation { Original } -data class ErrorDescription( - val pretext: Int?, - val icon: Int?, - val text: String? -) +data class ErrorDescription(val pretext: Int?, val icon: Int?, val text: String?) fun ScanRelativeRotation.toggleRotation() = when (this) { ScanRelativeRotation.Rotated -> ScanRelativeRotation.Original diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt index 05bf306..0b25f67 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/data/ui/ScanningScreenViewModel.kt @@ -232,7 +232,7 @@ class ScanningScreenViewModel( it.toString(), R.string.loading_previous_session_failed, R.drawable.rounded_warning_24 - ) + ) return } diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/FullScreenError.kt b/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/FullScreenError.kt index ca8a38d..c4a62e5 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/FullScreenError.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/FullScreenError.kt @@ -51,12 +51,7 @@ import io.github.chrisimx.scanbridge.R @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun FullScreenError(errorIcon: Int, - errorMessage: String, - copyButton: Boolean = false, - fontSize: TextUnit = 18.sp, - title: String? = null -) { +fun FullScreenError(errorIcon: Int, errorMessage: String, copyButton: Boolean = false, fontSize: TextUnit = 18.sp, title: String? = null) { val context = LocalContext.current val scrollState = rememberScrollState() @@ -76,8 +71,10 @@ fun FullScreenError(errorIcon: Int, ) if (title != null) { - Text(title, - style = MaterialTheme.typography.titleMediumEmphasized) + Text( + title, + style = MaterialTheme.typography.titleMediumEmphasized + ) } Column( diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/util/NavControllerExtensions.kt b/app/src/main/java/io/github/chrisimx/scanbridge/util/NavControllerExtensions.kt index e5055eb..3035052 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/util/NavControllerExtensions.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/util/NavControllerExtensions.kt @@ -1,7 +1,6 @@ package io.github.chrisimx.scanbridge.util import androidx.navigation.NavController -import timber.log.Timber fun NavController.clearAndNavigateTo(route: Any) { val navController = this From c55bcfb24ea41277439232e5b320282398c8b0fd Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Thu, 19 Feb 2026 12:09:48 +0100 Subject: [PATCH 29/33] Fallback to default crash handler if necessary --- .../main/java/io/github/chrisimx/scanbridge/CrashHandler.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/CrashHandler.kt b/app/src/main/java/io/github/chrisimx/scanbridge/CrashHandler.kt index ebba44e..548e59e 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/CrashHandler.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/CrashHandler.kt @@ -66,6 +66,9 @@ class CrashHandler(val context: Context) : Thread.UncaughtExceptionHandler { android.os.Process.killProcess(android.os.Process.myPid()) } catch (newException: Exception) { Timber.e(newException, "Uncaught exception in CrashHandler") + if (defaultHandler != null) { + defaultHandler.uncaughtException(t, e) + } } } } From 2b40d4b1b2796dbf83e69aeb54928427d1494ce4 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Thu, 19 Feb 2026 12:10:36 +0100 Subject: [PATCH 30/33] Fix typo in string resource name --- .../main/java/io/github/chrisimx/scanbridge/CrashActivity.kt | 2 +- app/src/main/res/values/strings.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/CrashActivity.kt b/app/src/main/java/io/github/chrisimx/scanbridge/CrashActivity.kt index 95b73f7..85eeab8 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/CrashActivity.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/CrashActivity.kt @@ -101,7 +101,7 @@ class CrashActivity : ComponentActivity() { Column(modifier = Modifier.verticalScroll(scrollState)) { Text( - stringResource(R.string.crash_occured), + stringResource(R.string.crash_occurred), modifier = Modifier.padding(bottom = 14.dp), style = MaterialTheme.typography.bodySmallEmphasized ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1d444d8..01fbc5a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -108,7 +108,7 @@ Cropping page… Activate zoom gestures ScanBridge crashed :( - "We are sorry. ScanBridge has crashed. Please report this issue and attach the following stacktrace (you can copy it with the button at the bottom)" + "We are sorry. ScanBridge has crashed. Please report this issue and attach the following stacktrace (you can copy it with the button at the bottom)" Loading previous session failed…\n\n%1$s The scanner capabilities should be retrieved at this point, but they weren\'t. \ No newline at end of file From 715c6643dc2846c7178ad30958189456448a1843 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Thu, 19 Feb 2026 12:14:35 +0100 Subject: [PATCH 31/33] Do not delete crash file from CrashActivity --- .../github/chrisimx/scanbridge/CrashActivity.kt | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/CrashActivity.kt b/app/src/main/java/io/github/chrisimx/scanbridge/CrashActivity.kt index 85eeab8..3bfe5fc 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/CrashActivity.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/CrashActivity.kt @@ -38,25 +38,10 @@ class CrashActivity : ComponentActivity() { super.onCreate(savedInstanceState) val error = intent.getStringExtra("error") ?: "Unknown error" - val crashLog = intent.getStringExtra("crash_file") + // val crashLog = intent.getStringExtra("crash_file") Timber.plant(Timber.DebugTree()) - Timber.e("$crashLog") - - try { - CoroutineScope(Dispatchers.IO).launch { - crashLog?.let { - val crashLogFile = File(it) - if (crashLogFile.exists()) { - crashLogFile.delete() - } - } - } - } catch (e: Exception) { - Timber.e("Error in CrashActivity while trying to remove crash log file: $e") - } - setContent { ScanBridgeTheme { Scaffold( From 01b42ffe77aa20b8a81d97c1a9e5007c6fd2d143 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Thu, 19 Feb 2026 16:16:11 +0100 Subject: [PATCH 32/33] Save crop rect across configuration changes --- .../scanbridge/uicomponents/CroppableImage.kt | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt b/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt index 94acf5d..e76c2dc 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt @@ -16,6 +16,8 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -45,6 +47,20 @@ import kotlin.math.max import kotlin.math.min import timber.log.Timber +val IntSizeSaver = Saver>( + save = { listOf(it.width, it.height) }, + restore = { IntSize(it[0], it[1]) } +) + +val RectSaver = Saver( + save = { rect -> + floatArrayOf(rect.left, rect.top, rect.right, rect.bottom) + }, + restore = { values -> + Rect(values[0], values[1], values[2], values[3]) + } +) + enum class Edge { TOP, LEFT, BOTTOM, RIGHT } data class EdgeFlags(val top: Boolean = false, val left: Boolean = false, val bottom: Boolean = false, val right: Boolean = false) @@ -97,12 +113,12 @@ fun CropOverlay( onRectChange: (Rect) -> Unit = {}, onPan: (Offset) -> Unit = {} ) { - var rect by remember { mutableStateOf(Rect(0f, 0f, 50f, 50f)) } - var size by remember { mutableStateOf(IntSize.Zero) } + var rect by rememberSaveable(stateSaver = RectSaver) { mutableStateOf(Rect(0f, 0f, 50f, 50f)) } + var size by rememberSaveable(stateSaver = IntSizeSaver) { mutableStateOf(IntSize.Zero) } val relativeRect by remember { derivedStateOf { if (size.width == 0 || size.height == 0) { - Rect(1f, 1f, 1f, 1f) + Rect(0f, 0f, 1f, 1f) } else { Rect( rect.left / size.width, @@ -212,8 +228,16 @@ fun CropOverlay( } .padding(touchErrorClearance) .onSizeChanged { + val currentRelativeRect = relativeRect + val newAbsoluteRect = Rect( + left = it.width * currentRelativeRect.left, + top = it.height * currentRelativeRect.top, + right = it.width * currentRelativeRect.right, + bottom = it.height * currentRelativeRect.height + ) size = it - rect = Rect(Offset.Zero, size.toSize()) + rect = newAbsoluteRect + onRectChange(relativeRect) } .then(modifier) ) { From 2727b428fe6583cfeb198582ff3d3fedab0bb247 Mon Sep 17 00:00:00 2001 From: Christian Nagel Date: Thu, 19 Feb 2026 16:17:17 +0100 Subject: [PATCH 33/33] Apply format --- .../main/java/io/github/chrisimx/scanbridge/CrashActivity.kt | 4 ---- .../github/chrisimx/scanbridge/uicomponents/CroppableImage.kt | 1 - 2 files changed, 5 deletions(-) diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/CrashActivity.kt b/app/src/main/java/io/github/chrisimx/scanbridge/CrashActivity.kt index 3bfe5fc..981beed 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/CrashActivity.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/CrashActivity.kt @@ -25,10 +25,6 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import io.github.chrisimx.scanbridge.theme.ScanBridgeTheme -import java.io.File -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import timber.log.Timber class CrashActivity : ComponentActivity() { diff --git a/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt b/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt index e76c2dc..8f98f3b 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt @@ -37,7 +37,6 @@ import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.toSize import coil3.ImageLoader import coil3.compose.SubcomposeAsyncImage import coil3.request.CachePolicy