Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
af34b01
Initial crop feature implementation
Chrisimx Feb 18, 2026
f01a4ec
Adjust handle drag detection of CroppableImage
Chrisimx Feb 17, 2026
2e32d21
Apply format
Chrisimx Feb 17, 2026
1805f0a
Move rotation to view model
Chrisimx Feb 18, 2026
ee128ae
Remove unused import
Chrisimx Feb 18, 2026
6a10e99
Add zoom pan to CroppableImage and fix CroppableImage for to high imges
Chrisimx Feb 18, 2026
64291b2
Fix crash when rotating scans too fast
Chrisimx Feb 18, 2026
4862aea
Add assertations to clearAndNavigateTo to make sure it is noticed if …
Chrisimx Feb 18, 2026
0987104
Add logging to SessionsStore
Chrisimx Feb 18, 2026
dc54f6b
Apply format
Chrisimx Feb 18, 2026
7331944
Apply format
Chrisimx Feb 18, 2026
8b6ed7c
Recycle bitmaps early and check for null
Chrisimx Feb 18, 2026
b5895df
Remove unused CropScreenBottomBar
Chrisimx Feb 18, 2026
eb80047
Clean up CroppableAsyncImage composable (make imageLoader overridable)
Chrisimx Feb 18, 2026
afdb185
Make sure to prevent division by zero for relativeRect in CropOverlay
Chrisimx Feb 18, 2026
dab09df
Remove unnecessary return
Chrisimx Feb 18, 2026
fa92ee7
Fix typo in variable name
Chrisimx Feb 18, 2026
a9f7ac1
Use check instead of assert to make sure error is thrown
Chrisimx Feb 18, 2026
2032f4e
Start activity to show uncaught exceptions
Chrisimx Feb 18, 2026
71d9b86
Add support for displaying different kinds of errors in ScanningScreen
Chrisimx Feb 19, 2026
f41ae1f
Make rotateCurrentPage complete the loading UI, even if it failed
Chrisimx Feb 19, 2026
c7a9983
Set fallback return route for CropImageRoute if the real one can't be…
Chrisimx Feb 19, 2026
44f8df7
Make CropDragEvents data objects instead of just objects (toString, e…
Chrisimx Feb 19, 2026
6cb2dab
Check for scannercapbilities null in pdf export implementation
Chrisimx Feb 19, 2026
562c3c9
Rename offset var to adjustedOffset to make it more clear that this i…
Chrisimx Feb 19, 2026
e0a1558
Handle bitmap decoding error in rotateCurrentPage
Chrisimx Feb 19, 2026
ff00fb0
Handle failure in crop save function
Chrisimx Feb 19, 2026
783dea9
Apply format
Chrisimx Feb 19, 2026
c55bcfb
Fallback to default crash handler if necessary
Chrisimx Feb 19, 2026
2b40d4b
Fix typo in string resource name
Chrisimx Feb 19, 2026
715c664
Do not delete crash file from CrashActivity
Chrisimx Feb 19, 2026
01b42ff
Save crop rect across configuration changes
Chrisimx Feb 19, 2026
2727b42
Apply format
Chrisimx Feb 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".CrashActivity"
android:exported="false"
android:theme="@style/Theme.ScanBridge">
</activity>
</application>

</manifest>
100 changes: 100 additions & 0 deletions app/src/main/java/io/github/chrisimx/scanbridge/CrashActivity.kt
Original file line number Diff line number Diff line change
@@ -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
)
}
}
}
}
}
}
}
47 changes: 32 additions & 15 deletions app/src/main/java/io/github/chrisimx/scanbridge/CrashHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
}
}
}
184 changes: 184 additions & 0 deletions app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt
Original file line number Diff line number Diff line change
@@ -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<Session?> = 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())
}
}
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class MainActivity : ComponentActivity() {
var saveDebugFileLauncher: ActivityResultLauncher<Intent>? = null

override fun onCreate(savedInstanceState: Bundle?) {
Thread.setDefaultUncaughtExceptionHandler(CrashHandler(this))
enableEdgeToEdge()
super.onCreate(savedInstanceState)

Expand Down Expand Up @@ -80,8 +81,6 @@ class MainActivity : ComponentActivity() {
cleanUpCacheFiles()
}

Thread.setDefaultUncaughtExceptionHandler(CrashHandler(this))

setContent {
ScanBridgeApp()
}
Expand Down
Loading