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