-
-
Notifications
You must be signed in to change notification settings - Fork 7
Implement basic crop feature #132
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
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 f01a4ec
Adjust handle drag detection of CroppableImage
Chrisimx 2e32d21
Apply format
Chrisimx 1805f0a
Move rotation to view model
Chrisimx ee128ae
Remove unused import
Chrisimx 6a10e99
Add zoom pan to CroppableImage and fix CroppableImage for to high imges
Chrisimx 64291b2
Fix crash when rotating scans too fast
Chrisimx 4862aea
Add assertations to clearAndNavigateTo to make sure it is noticed if …
Chrisimx 0987104
Add logging to SessionsStore
Chrisimx dc54f6b
Apply format
Chrisimx 7331944
Apply format
Chrisimx 8b6ed7c
Recycle bitmaps early and check for null
Chrisimx b5895df
Remove unused CropScreenBottomBar
Chrisimx eb80047
Clean up CroppableAsyncImage composable (make imageLoader overridable)
Chrisimx afdb185
Make sure to prevent division by zero for relativeRect in CropOverlay
Chrisimx dab09df
Remove unnecessary return
Chrisimx fa92ee7
Fix typo in variable name
Chrisimx a9f7ac1
Use check instead of assert to make sure error is thrown
Chrisimx 2032f4e
Start activity to show uncaught exceptions
Chrisimx 71d9b86
Add support for displaying different kinds of errors in ScanningScreen
Chrisimx f41ae1f
Make rotateCurrentPage complete the loading UI, even if it failed
Chrisimx c7a9983
Set fallback return route for CropImageRoute if the real one can't be…
Chrisimx 44f8df7
Make CropDragEvents data objects instead of just objects (toString, e…
Chrisimx 6cb2dab
Check for scannercapbilities null in pdf export implementation
Chrisimx 562c3c9
Rename offset var to adjustedOffset to make it more clear that this i…
Chrisimx e0a1558
Handle bitmap decoding error in rotateCurrentPage
Chrisimx ff00fb0
Handle failure in crop save function
Chrisimx 783dea9
Apply format
Chrisimx c55bcfb
Fallback to default crash handler if necessary
Chrisimx 2b40d4b
Fix typo in string resource name
Chrisimx 715c664
Do not delete crash file from CrashActivity
Chrisimx 01b42ff
Save crop rect across configuration changes
Chrisimx 2727b42
Apply format
Chrisimx File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
100 changes: 100 additions & 0 deletions
100
app/src/main/java/io/github/chrisimx/scanbridge/CrashActivity.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| ) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
184 changes: 184 additions & 0 deletions
184
app/src/main/java/io/github/chrisimx/scanbridge/CropScreen.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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()!! | ||
|
|
||
Chrisimx marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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 | ||
Chrisimx marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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()) | ||
| } | ||
| } | ||
| ) | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.