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..981beed --- /dev/null +++ b/app/src/main/java/io/github/chrisimx/scanbridge/CrashActivity.kt @@ -0,0 +1,100 @@ +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 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()) + + 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_occurred), + 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..548e59e 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,42 @@ 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") + if (defaultHandler != null) { + defaultHandler.uncaughtException(t, e) + } + } } } 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..fbd9f98 --- /dev/null +++ b/app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt @@ -0,0 +1,184 @@ +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.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FabPosition +import androidx.compose.material3.Icon +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.ExperimentalTelephotoApi +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) { + 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 +} + +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, ExperimentalTelephotoApi::class) +@Composable +fun CropScreen(sessionID: String, pageIdx: Int, returnRoute: BaseRoute, navController: NavController) { + val context = LocalContext.current + + val zoomableState = rememberZoomableState(ZoomSpec(maxZoomFactor = 5f)) + val coroutineScope = rememberCoroutineScope() + var currentRect by remember { mutableStateOf(Rect(0f, 0f, 1f, 1f)) } + + var processing: Boolean by remember { mutableStateOf(false) } + + val originalSessionResult: Result = remember { + SessionsStore.loadSession(context, sessionID) + } + + 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 = { + coroutineScope.launch(Dispatchers.Main) { + if (processing) return@launch + + processing = true + try { + val croppedFile = finishCrop(currentRect, originalImageFile) ?: return@launch + + updateSessionFile(originalSession, pageIdx, croppedFile, originalImageFile, context, sessionID) + navController.clearAndNavigateTo(returnRoute) + } finally { + processing = false + } + } + } + + BackHandler { + navController.clearAndNavigateTo(returnRoute) + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + 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) { + LoadingDialog(text = R.string.cropping) + } + + Box( + modifier = Modifier + .fillMaxSize() + .zoomable( + zoomableState, + gestures = EnabledZoomGestures.ZoomOnly + ), + 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, + cropRectChanged = { currentRect = it }, + onPan = { + coroutineScope.launch { + zoomableState.panBy(it, snap()) + } + } + ) + } + } +} 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 440e65c..85094eb 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/ScanBridgeNavHost.kt @@ -30,11 +30,13 @@ 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 import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json import timber.log.Timber @Serializable @@ -48,11 +50,26 @@ 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 + +@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) { "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 @@ -60,6 +77,11 @@ fun NavBackStackEntry.toTypedRoute(): BaseRoute? { ScannerRoute(scannerName, scannerURL, sessionID) } + "ErrorRoute/{error}" -> { + val error = arguments?.getString("error") ?: return null + ErrorRoute(error) + } + else -> null } } @@ -74,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) @@ -81,6 +109,17 @@ fun ScanBridgeNavHost(navController: NavHostController, startDestination: Any) { TemporaryFileHandler() } } + composable { backStackEntry -> + val scannerRoute: CropImageRoute = backStackEntry.toRoute() + 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 -> val scannerRoute: ScannerRoute = backStackEntry.toRoute() val debug = sharedPreferences.getBoolean("write_debug", false) 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 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..9db3ff2 100644 --- a/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt +++ b/app/src/main/java/io/github/chrisimx/scanbridge/ScanningScreen.kt @@ -24,8 +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 import androidx.activity.compose.rememberLauncherForActivityResult @@ -100,9 +98,12 @@ 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.uicomponents.ExportSettingsPopup import io.github.chrisimx.scanbridge.uicomponents.FullScreenError @@ -110,7 +111,8 @@ 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.rotateBy90 +import io.github.chrisimx.scanbridge.util.clearAndNavigateTo +import io.github.chrisimx.scanbridge.util.getMaxResolution import io.github.chrisimx.scanbridge.util.snackBarError import io.github.chrisimx.scanbridge.util.toReadableString import io.github.chrisimx.scanbridge.util.zipFiles @@ -123,6 +125,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,49 +133,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 - } - scanningViewModel.setLoadingText(R.string.rotating_page) - - val currentScans = scanningViewModel.scanningScreenData.currentScansState - val currentPagePath = - currentScans[scanningViewModel.scanningScreenData.pagerState.currentPage].first - 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 baseFileName = currentPageFile.name.extractBaseFilename() - - val newFile = File(context.filesDir, "$baseFileName edit-${System.currentTimeMillis()}.jpg") - - Timber.tag(TAG).d("Saving rotated $currentPagePath") - newFile.outputStream().use { - rotatedBitmap.compress(Bitmap.CompressFormat.JPEG, 100, it) - } - - Timber.tag(TAG).d("Finished saving rotated $currentPagePath") - - val index = scanningViewModel.scanningScreenData.pagerState.currentPage - val scanSettings = currentScans[index].second - Timber.tag(TAG).d("Updating UI state after rotation") - scanningViewModel.removeScanAtIndex(index) - scanningViewModel.addTempFile(currentPageFile) - scanningViewModel.addScanAtIndex(newFile.absolutePath, scanSettings, index) - - scanningViewModel.setLoadingText(null) -} - fun doZipExport( scanningViewModel: ScanningScreenViewModel, context: Context, @@ -205,7 +165,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++ @@ -241,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 @@ -279,41 +247,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 aspectRatio = imageData.width / imageData.height - height72thInches = width72thInches / aspectRatio + val fallbackResolution = scannerCaps.getMaxResolution(inputSource) + val scannerXResolution = scan.originalScanSettings.xResolution ?: fallbackResolution.xResolution + val scannerYResolution = scan.originalScanSettings.yResolution ?: fallbackResolution.yResolution + + 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) @@ -550,16 +518,13 @@ fun ScanningScreen( val isError by remember { derivedStateOf { - scanningViewModel.scanningScreenData.errorString != null + scanningViewModel.scanningScreenData.error != null } } if (!isLoaded) { BackHandler { - navController.navigate(StartUpScreenRoute) { - popUpTo(0) { inclusive = true } - launchSingleTop = true - } + navController.clearAndNavigateTo(StartUpScreenRoute) } Scaffold { innerPadding -> @@ -578,11 +543,12 @@ fun ScanningScreen( 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 ) @@ -635,7 +601,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 +752,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 +768,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,10 +786,12 @@ fun ScanContent( innerPadding: PaddingValues, scannerName: String, scanningViewModel: ScanningScreenViewModel, - coroutineScope: CoroutineScope + coroutineScope: CoroutineScope, + navController: NavHostController? = null ) { val pagerState = scanningViewModel.scanningScreenData.pagerState val context = LocalContext.current + val currentScans = scanningViewModel.scanningScreenData.currentScansState Column( modifier = Modifier @@ -846,9 +811,9 @@ fun ScanContent( ) ) - if (scanningViewModel.scanningScreenData.currentScansState.size > pagerState.currentPage) { + if (currentScans.size > pagerState.currentPage) { Text( - scanningViewModel.scanningScreenData.currentScansState[pagerState.currentPage].second.inputSource?.toReadableString( + currentScans[pagerState.currentPage].originalScanSettings.inputSource?.toReadableString( context ).toString() ) @@ -861,30 +826,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.getOrNull(page)?.filePath AsyncImage( - model = scanningViewModel.scanningScreenData.currentScansState[page].first, + model = imagePath, contentDescription = stringResource(R.string.desc_scanned_page), modifier = Modifier - .zoomable(zoomState) - .padding(vertical = 5.dp) .testTag("scan_page") ) } @@ -892,13 +857,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 @@ -944,6 +902,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, @@ -962,7 +941,7 @@ fun ScanContent( } IconButton(onClick = { thread { - rotate(context, scanningViewModel) + scanningViewModel.rotateCurrentPage() } }) { Icon( @@ -977,3 +956,17 @@ 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)) + } +} 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..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 @@ -1,12 +1,42 @@ 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 +import timber.log.Timber @Serializable data class Session( + val sessionID: String, + val scannedPages: List, + val scanSettings: StatelessImmutableESCLScanSettingsState?, + val tmpFiles: List +) { + companion object { + fun fromString(sessionFileString: String, json: Json): Result = try { + Result.success(json.decodeFromString(sessionFileString)) + } catch (_: Exception) { + 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) + } + } + } +} + +@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..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 @@ -30,13 +30,33 @@ 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 +} + +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 +} + +@Serializable +data class ScanMetadata( + val filePath: String, + val originalScanSettings: ScanSettings, + val rotation: ScanRelativeRotation = ScanRelativeRotation.Original +) data class ScanningScreenData( val esclClient: ESCLRequestClient, 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), @@ -47,19 +67,20 @@ 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 + (stateCurrentScans.size + if (scanJobRunning.value) 1 else 0).coerceAtLeast(1) }, - val sourceFileToSave: MutableState = mutableStateOf(null) + val sourceFileToSave: MutableState = mutableStateOf(null), + val isRotating: MutableState = mutableStateOf(false) ) { fun toImmutable() = ImmutableScanningScreenData( esclClient, sessionID, confirmDialogShown, confirmPageDeleteDialogShown, - errorString, + error, scanSettingsVM, capabilities, scanSettingsMenuOpen, @@ -71,6 +92,7 @@ data class ScanningScreenData( scanJobCancelling, stateProgressStringRes, sourceFileToSave, + isRotating, createdTempFiles, pagerState, stateCurrentScans @@ -82,7 +104,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, @@ -94,9 +116,10 @@ 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> + val currentScansState: SnapshotStateList ) { val confirmDialogShown by confirmDialogShownState val confirmPageDeleteDialogShown by confirmPageDeleteDialogShownState @@ -106,10 +129,11 @@ 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 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 630fdcd..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 @@ -30,21 +30,21 @@ 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.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 @@ -64,12 +64,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 +107,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() @@ -187,13 +169,74 @@ 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() { + if (_scanningScreenData.stateCurrentScans.isEmpty() || _scanningScreenData.isRotating.value) { + return + } + _scanningScreenData.isRotating.value = true + setLoadingText(R.string.rotating_page) + 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) + 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() + + 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) { _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) @@ -287,52 +330,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(): Result = SessionsStore.loadSession(application, scanningScreenData.sessionID) fun swapTwoPages(index1: Int, index2: Int) { if (index1 < 0 || @@ -614,7 +633,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/stores/SessionsStore.kt b/app/src/main/java/io/github/chrisimx/scanbridge/stores/SessionsStore.kt index f904804..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 @@ -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 @@ -34,13 +33,15 @@ 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" val file = File(path) if (!file.exists()) { Timber.d("Could not find session file at $path") - return null + return Result.success(null) } val sessionFileString = file.readText() @@ -50,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) 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..8f98f3b --- /dev/null +++ b/app/src/main/java/io/github/chrisimx/scanbridge/uicomponents/CroppableImage.kt @@ -0,0 +1,394 @@ +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.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.requiredWidth +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 +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 +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 +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 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 +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) + +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 + ) +} + +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 + */ +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() + data object DraggedOutside : CropDragEvent() + data object DraggedInside : CropDragEvent() +} + +@Composable +fun CropOverlay( + modifier: Modifier, + touchPaddingAroundInPx: Int, + handleTouchRadius: Dp, + onRectChange: (Rect) -> Unit = {}, + onPan: (Offset) -> Unit = {} +) { + 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(0f, 0f, 1f, 1f) + } else { + 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 adjustedOffset = offset - Offset(touchPaddingAroundInPx.toFloat(), touchPaddingAroundInPx.toFloat()) + lastDragEventType = CropDragEvent.DraggedOutside + + if (!rect.inflate(handleTouchRadius.toPx()).contains(adjustedOffset)) { + onPan(adjustedOffset) + return@detectDragGestures + } + + lastDragEventType = CropDragEvent.DraggedInside + + if (rect.deflate(rect.width / 4, rect.height / 4).contains(adjustedOffset)) { + return@detectDragGestures + } + + val nearestHandle = handles + .mapIndexed { idx, handle -> + Pair( + idx, + (handle.position - adjustedOffset).getDistance() + ) + } + .filter { it.second < handleTouchRadius.toPx() } + .minByOrNull { it.second } + + if (nearestHandle != null) { + lastDragEventType = CropDragEvent.ResizeHandleDragged(nearestHandle.first) + } + }, + onDrag = { pointerInputChange, dragChange -> + val eventType = lastDragEventType + + when (eventType) { + 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[eventType.idx] + val edgeFlags = currentlyDraggedHandle.edgeFlags + rect = rect.applyResizeDrag(dragChange, size, edgeFlags, density) + } + + CropDragEvent.DraggedOutside -> { + onPan(dragChange) + return@detectDragGestures + } + } + + onRectChange(relativeRect) + pointerInputChange.consume() + } + ) + } + .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 = newAbsoluteRect + onRectChange(relativeRect) + } + .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 (Constraints) -> Unit, + dependentContent: @Composable () -> Unit +) { + SubcomposeLayout(modifier) { 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.measuredWidth), + height = maxOf(currentMax.height, placeable.measuredHeight) + ) + } + + 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) } + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun CroppableAsyncImage( + modifier: Modifier = Modifier, + imageModel: Any?, + contentDescription: String?, + additionalTouchAreaAround: Dp, + handleTouchRadius: Dp, + cropRectChanged: (Rect) -> Unit, + onPan: (Offset) -> Unit, + imageLoader: ImageLoader? = null +) { + val context = LocalContext.current + val density = LocalDensity.current + val additionalTouchAreaAroundInPx = with(density) { + additionalTouchAreaAround.toPx().toInt() + } + + val selectedImageLoader = imageLoader ?: ImageLoader.Builder(context) + .memoryCachePolicy(CachePolicy.DISABLED) + .diskCachePolicy(CachePolicy.DISABLED) + .build() + + SubcomposeAsyncImage( + model = imageModel, + contentDescription = contentDescription, + modifier = modifier, + imageLoader = selectedImageLoader, + 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, + contentDescription, + Modifier + .requiredWidth(intrinsicWidthDp) + .requiredHeight(intrinsicHeightDp), + contentScale = ContentScale.None + ) + } + ) { + CropOverlay( + modifier = Modifier, + additionalTouchAreaAroundInPx, + handleTouchRadius, + onRectChange = cropRectChanged, + onPan = onPan + ) + } + } + ) +} + +@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, onRectChange = { + currentRect = it + }) + } + } + } +} 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..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 @@ -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,14 @@ 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 +70,13 @@ 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 +89,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/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/java/io/github/chrisimx/scanbridge/util/NavControllerExtensions.kt b/app/src/main/java/io/github/chrisimx/scanbridge/util/NavControllerExtensions.kt index bd5a3c1..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 @@ -5,9 +5,10 @@ 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 } + check(navController.previousBackStackEntry == null, { "clearAndNavigateTo did not correctly clear backstack!" }) } 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_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/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/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/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 cbed9f9..01fbc5a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -102,4 +102,13 @@ F-Droid Play Store Save to File + Loading image… + Crop current page + 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)" + 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