chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:31 +08:00
commit f06de36198
4585 changed files with 588418 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/build
+30
View File
@@ -0,0 +1,30 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
plugins {
alias(libs.plugins.image.toolbox.library)
}
android.namespace = "com.websitebeaver.documentscanner"
dependencies {
implementation(libs.opencv)
implementation(libs.appCompat)
implementation(libs.toolbox.exif)
implementation(projects.lib.opencvTools)
}
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ ImageToolbox is an image editor for android
~ Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
~
~ You should have received a copy of the Apache License
~ along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature
android:name="android.hardware.camera"
android:required="true" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<application>
<activity
android:name=".DocumentScannerActivity"
android:configChanges="orientation|screenSize"
android:screenOrientation="portrait"
android:theme="@style/Theme.FullScreen" />
<provider
android:name="com.websitebeaver.documentscanner.DocumentScannerFileProvider"
android:authorities="${applicationId}.DocumentScannerFileProvider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,211 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.websitebeaver.documentscanner
import android.app.Activity
import android.content.Intent
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import com.websitebeaver.documentscanner.constants.DefaultSetting
import com.websitebeaver.documentscanner.constants.DocumentScannerExtra
import com.websitebeaver.documentscanner.constants.ResponseType
import com.websitebeaver.documentscanner.extensions.toBase64
import com.websitebeaver.documentscanner.utils.ImageUtil
import java.io.File
/**
* This class is used to start a document scan. It accepts parameters used to customize
* the document scan, and callback parameters.
*
* @param activity the current activity
* @param successHandler event handler that gets called on document scan success
* @param errorHandler event handler that gets called on document scan error
* @param cancelHandler event handler that gets called when a user cancels the document scan
* @param responseType the cropped image gets returned in this format
* @param letUserAdjustCrop whether or not the user can change the auto detected document corners
* @param maxNumDocuments the maximum number of documents a user can scan at once
* @param croppedImageQuality the 0 - 100 quality of the cropped image
* @param cropperHandleColor color of cropper corner handles
* @param cropperHandleOutlineColor color of cropper corner outer outline
* @param cropperFrameColor color of cropper frame lines
* @param cropperStrokeWidthDp width of cropper lines in dp
* @param buttonTintColor color of scanner button icons
* @param buttonContainerColor color of scanner button containers
* @param doneButtonTintColor color of finish button icon
* @param doneButtonContainerColor color of finish button inner circle
* @constructor creates document scanner
*/
class DocumentScanner(
private val activity: ComponentActivity,
private val successHandler: ((documentScanResults: ArrayList<String>) -> Unit)? = null,
private val errorHandler: ((errorMessage: String) -> Unit)? = null,
private val cancelHandler: (() -> Unit)? = null,
private var responseType: String? = null,
private var letUserAdjustCrop: Boolean? = null,
private var maxNumDocuments: Int? = null,
private var croppedImageQuality: Int? = null,
private val cropperHandleColor: Int? = null,
private val cropperHandleOutlineColor: Int? = null,
private val cropperFrameColor: Int? = null,
private val cropperStrokeWidthDp: Float? = null,
private val buttonTintColor: Int? = null,
private val buttonContainerColor: Int? = null,
private val doneButtonTintColor: Int? = null,
private val doneButtonContainerColor: Int? = null
) {
init {
responseType = responseType ?: DefaultSetting.RESPONSE_TYPE
croppedImageQuality = croppedImageQuality ?: DefaultSetting.CROPPED_IMAGE_QUALITY
}
/**
* create intent to launch document scanner and set custom options
*/
fun createDocumentScanIntent(): Intent {
val documentScanIntent = Intent(activity, DocumentScannerActivity::class.java)
documentScanIntent.putExtra(
DocumentScannerExtra.EXTRA_CROPPED_IMAGE_QUALITY,
croppedImageQuality
)
documentScanIntent.putExtra(
DocumentScannerExtra.EXTRA_LET_USER_ADJUST_CROP,
letUserAdjustCrop
)
documentScanIntent.putExtra(
DocumentScannerExtra.EXTRA_MAX_NUM_DOCUMENTS,
maxNumDocuments
)
cropperHandleColor?.let {
documentScanIntent.putExtra(DocumentScannerExtra.EXTRA_CROPPER_HANDLE_COLOR, it)
}
cropperHandleOutlineColor?.let {
documentScanIntent.putExtra(
DocumentScannerExtra.EXTRA_CROPPER_HANDLE_OUTLINE_COLOR,
it
)
}
cropperFrameColor?.let {
documentScanIntent.putExtra(DocumentScannerExtra.EXTRA_CROPPER_FRAME_COLOR, it)
}
cropperStrokeWidthDp?.let {
documentScanIntent.putExtra(DocumentScannerExtra.EXTRA_CROPPER_STROKE_WIDTH_DP, it)
}
buttonTintColor?.let {
documentScanIntent.putExtra(DocumentScannerExtra.EXTRA_BUTTON_TINT_COLOR, it)
}
buttonContainerColor?.let {
documentScanIntent.putExtra(DocumentScannerExtra.EXTRA_BUTTON_CONTAINER_COLOR, it)
}
doneButtonTintColor?.let {
documentScanIntent.putExtra(
DocumentScannerExtra.EXTRA_DONE_BUTTON_TINT_COLOR,
it
)
}
doneButtonContainerColor?.let {
documentScanIntent.putExtra(
DocumentScannerExtra.EXTRA_DONE_BUTTON_CONTAINER_COLOR,
it
)
}
return documentScanIntent
}
/**
* handle response from document scanner
*
* @param result the document scanner activity result
*/
fun handleDocumentScanIntentResult(result: ActivityResult) = try {
// make sure responseType is valid
if (!arrayOf(
ResponseType.BASE64,
ResponseType.IMAGE_FILE_PATH
).contains(responseType)
) {
throw Throwable(
"responseType must be either ${ResponseType.BASE64} " +
"or ${ResponseType.IMAGE_FILE_PATH}"
)
}
when (result.resultCode) {
Activity.RESULT_OK -> {
// check for errors
val error = result.data?.extras?.getString("error")
if (error != null) {
throw Throwable("error - $error")
}
// get an array with scanned document file paths
val croppedImageResults: ArrayList<String> =
result.data?.getStringArrayListExtra(
"croppedImageResults"
) ?: throw Throwable("No cropped images returned")
// if responseType is imageFilePath return an array of file paths
var successResponse: ArrayList<String> = croppedImageResults
// if responseType is base64 return an array of base64 images
if (responseType == ResponseType.BASE64) {
val base64CroppedImages =
croppedImageResults.map { croppedImagePath ->
// read cropped image from file path, and convert to base 64
val base64Image = ImageUtil().readBitmapFromFileUriString(
croppedImagePath,
activity.contentResolver
).toBase64(croppedImageQuality!!)
// delete cropped image from android device to avoid
// accumulating photos
File(croppedImagePath).delete()
base64Image
}
successResponse = base64CroppedImages as ArrayList<String>
}
// trigger the success event handler with an array of cropped images
successHandler?.let { it(successResponse) }
}
Activity.RESULT_CANCELED -> {
// user closed camera
cancelHandler?.let { it() }
}
else -> Unit
}
} catch (exception: Throwable) {
// trigger the error event handler
errorHandler?.let { it(exception.localizedMessage ?: "An error happened") }
}
/**
* add document scanner result handler and launch the document scanner
*/
fun startScan() {
activity.registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result: ActivityResult -> handleDocumentScanIntentResult(result) }
.launch(createDocumentScanIntent())
}
}
@@ -0,0 +1,591 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
@file:Suppress("DEPRECATION")
package com.websitebeaver.documentscanner
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.view.WindowManager
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.FileProvider
import androidx.core.graphics.Insets
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import com.t8rin.opencv_tools.document_detector.DocumentDetector
import com.websitebeaver.documentscanner.constants.DefaultSetting
import com.websitebeaver.documentscanner.constants.DocumentScannerExtra
import com.websitebeaver.documentscanner.extensions.move
import com.websitebeaver.documentscanner.extensions.onClick
import com.websitebeaver.documentscanner.extensions.saveToFile
import com.websitebeaver.documentscanner.extensions.screenHeight
import com.websitebeaver.documentscanner.extensions.screenWidth
import com.websitebeaver.documentscanner.models.Document
import com.websitebeaver.documentscanner.models.Quad
import com.websitebeaver.documentscanner.ui.CircleButton
import com.websitebeaver.documentscanner.ui.DoneButton
import com.websitebeaver.documentscanner.ui.ImageCropView
import com.websitebeaver.documentscanner.utils.FileUtil
import com.websitebeaver.documentscanner.utils.ImageUtil
import org.opencv.android.OpenCVLoader
import org.opencv.core.Point
import java.io.File
/**
* This class contains the main document scanner code. It opens the camera, lets the user
* take a photo of a document (homework paper, business card, etc.), detects document corners,
* allows user to make adjustments to the detected corners, depending on options, and saves
* the cropped document. It allows the user to do this for 1 or more documents.
*
* @constructor creates document scanner activity
*/
class DocumentScannerActivity : AppCompatActivity() {
/**
* @property maxNumDocuments maximum number of documents a user can scan at a time
*/
private var maxNumDocuments = DefaultSetting.MAX_NUM_DOCUMENTS
/**
* @property letUserAdjustCrop whether or not to let user move automatically detected corners
*/
private var letUserAdjustCrop = DefaultSetting.LET_USER_ADJUST_CROP
/**
* @property croppedImageQuality the 0 - 100 quality of the cropped image
*/
private var croppedImageQuality = DefaultSetting.CROPPED_IMAGE_QUALITY
/**
* @property cropperOffsetWhenCornersNotFound if we can't find document corners, we set
* corners to image size with a slight margin
*/
private val cropperOffsetWhenCornersNotFound = 100.0
/**
* @property document This is the current document. Initially it's null. Once we capture
* the photo, and find the corners we update document.
*/
private var document: Document? = null
/**
* @property documents a list of documents (original photo file path, original photo
* dimensions and 4 corner points)
*/
private val documents = mutableListOf<Document>()
/**
* @property imageView container with original photo and cropper
*/
private lateinit var imageView: ImageCropView
private var cropperHandleColor = Color.WHITE
private var cropperFrameColor = Color.WHITE
private var buttonTintColor = Color.WHITE
private var buttonContainerColor = Color.argb(128, 255, 255, 255)
private var cropperHandleOutlineColor = buttonContainerColor
private var cropperStrokeWidthDp = 1.2f
private var doneButtonTintColor = buttonTintColor
private var doneButtonContainerColor = buttonContainerColor
private var shouldApplyButtonColors = false
private var contentWidth = 0
private var contentHeight = 0
/**
* called when activity is created
*
* @param savedInstanceState persisted data that maintains state
*/
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
try {
OpenCVLoader.initLocal()
} catch (exception: Throwable) {
finishIntentWithError(
"error starting OpenCV: ${exception.message}"
)
}
WindowCompat.setDecorFitsSystemWindows(window, false)
window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
window.statusBarColor = Color.TRANSPARENT
window.navigationBarColor = Color.BLACK
WindowCompat.getInsetsController(window, window.decorView).apply {
isAppearanceLightStatusBars = false
isAppearanceLightNavigationBars = false
}
// Show cropper, accept crop button, add new document button, and
// retake photo button. Since we open the camera in a few lines, the user
// doesn't see this until they finish taking a photo
setContentView(R.layout.activity_image_crop)
setupWindowInsets()
imageView = findViewById(R.id.image_view)
try {
// validate maxNumDocuments option, and update default if user sets it
var userSpecifiedMaxImages: Int? = null
intent.extras?.get(DocumentScannerExtra.EXTRA_MAX_NUM_DOCUMENTS)?.let {
if (it.toString().toIntOrNull() == null) {
throw Throwable(
"${DocumentScannerExtra.EXTRA_MAX_NUM_DOCUMENTS} must be a positive number"
)
}
userSpecifiedMaxImages = it as Int
maxNumDocuments = userSpecifiedMaxImages
}
// validate letUserAdjustCrop option, and update default if user sets it
intent.extras?.get(DocumentScannerExtra.EXTRA_LET_USER_ADJUST_CROP)?.let {
if (!arrayOf("true", "false").contains(it.toString())) {
throw Throwable(
"${DocumentScannerExtra.EXTRA_LET_USER_ADJUST_CROP} must true or false"
)
}
letUserAdjustCrop = it as Boolean
}
// if we don't want user to move corners, we can let the user only take 1 photo
if (!letUserAdjustCrop) {
maxNumDocuments = 1
if (userSpecifiedMaxImages != null && userSpecifiedMaxImages != 1) {
throw Throwable(
"${DocumentScannerExtra.EXTRA_MAX_NUM_DOCUMENTS} must be 1 when " +
"${DocumentScannerExtra.EXTRA_LET_USER_ADJUST_CROP} is false"
)
}
}
// validate croppedImageQuality option, and update value if user sets it
intent.extras?.get(DocumentScannerExtra.EXTRA_CROPPED_IMAGE_QUALITY)?.let {
if (it !is Int || it < 0 || it > 100) {
throw Throwable(
"${DocumentScannerExtra.EXTRA_CROPPED_IMAGE_QUALITY} must be a number " +
"between 0 and 100"
)
}
croppedImageQuality = it
}
cropperHandleColor = intent.getIntExtra(
DocumentScannerExtra.EXTRA_CROPPER_HANDLE_COLOR,
cropperHandleColor
)
cropperFrameColor = intent.getIntExtra(
DocumentScannerExtra.EXTRA_CROPPER_FRAME_COLOR,
cropperFrameColor
)
intent.extras?.get(DocumentScannerExtra.EXTRA_CROPPER_STROKE_WIDTH_DP)?.let {
val strokeWidthDp = (it as? Number)?.toFloat()
?: throw Throwable(
"${DocumentScannerExtra.EXTRA_CROPPER_STROKE_WIDTH_DP} must be a number"
)
if (
strokeWidthDp <= 0f ||
strokeWidthDp.isNaN() ||
strokeWidthDp.isInfinite()
) {
throw Throwable(
"${DocumentScannerExtra.EXTRA_CROPPER_STROKE_WIDTH_DP} must be greater than 0"
)
}
cropperStrokeWidthDp = strokeWidthDp
}
buttonTintColor = intent.getIntExtra(
DocumentScannerExtra.EXTRA_BUTTON_TINT_COLOR,
buttonTintColor
)
buttonContainerColor = intent.getIntExtra(
DocumentScannerExtra.EXTRA_BUTTON_CONTAINER_COLOR,
buttonContainerColor
)
cropperHandleOutlineColor = intent.getIntExtra(
DocumentScannerExtra.EXTRA_CROPPER_HANDLE_OUTLINE_COLOR,
buttonContainerColor
)
doneButtonTintColor = intent.getIntExtra(
DocumentScannerExtra.EXTRA_DONE_BUTTON_TINT_COLOR,
buttonTintColor
)
doneButtonContainerColor = intent.getIntExtra(
DocumentScannerExtra.EXTRA_DONE_BUTTON_CONTAINER_COLOR,
buttonContainerColor
)
shouldApplyButtonColors = listOf(
DocumentScannerExtra.EXTRA_BUTTON_TINT_COLOR,
DocumentScannerExtra.EXTRA_BUTTON_CONTAINER_COLOR,
DocumentScannerExtra.EXTRA_DONE_BUTTON_TINT_COLOR,
DocumentScannerExtra.EXTRA_DONE_BUTTON_CONTAINER_COLOR
).any(intent::hasExtra)
} catch (exception: Throwable) {
finishIntentWithError(
"invalid extra: ${exception.message}"
)
return
}
// set click event handlers for new document button, accept and crop document button,
// and retake document photo button
val newPhotoButton: CircleButton = findViewById(R.id.new_photo_button)
val completeDocumentScanButton: DoneButton = findViewById(
R.id.complete_document_scan_button
)
val retakePhotoButton: CircleButton = findViewById(R.id.retake_photo_button)
imageView.setCropperStyle(
handleColor = cropperHandleColor,
frameColor = cropperFrameColor,
handleOutlineColor = cropperHandleOutlineColor,
strokeWidthDp = cropperStrokeWidthDp
)
if (shouldApplyButtonColors) {
retakePhotoButton.setColors(
containerColor = buttonContainerColor,
iconColor = buttonTintColor
)
completeDocumentScanButton.setColors(
ringColor = buttonContainerColor,
circleColor = doneButtonContainerColor,
iconColor = doneButtonTintColor
)
newPhotoButton.setColors(
containerColor = buttonContainerColor,
iconColor = buttonTintColor
)
}
newPhotoButton.onClick { onClickNew() }
completeDocumentScanButton.onClick { onClickDone() }
retakePhotoButton.onClick { onClickRetake() }
// open camera, so user can snap document photo
try {
openCamera()
} catch (exception: Throwable) {
finishIntentWithError(
"error opening camera: ${exception.message}"
)
}
}
private fun setupWindowInsets() {
val root: View = findViewById(R.id.document_scanner_root)
val initialPaddingLeft = root.paddingLeft
val initialPaddingTop = root.paddingTop
val initialPaddingRight = root.paddingRight
val initialPaddingBottom = root.paddingBottom
fun updateContentSize() {
contentWidth = (screenWidth - root.paddingLeft - root.paddingRight).coerceAtLeast(0)
contentHeight = (screenHeight - root.paddingTop - root.paddingBottom).coerceAtLeast(0)
}
ViewCompat.setOnApplyWindowInsetsListener(root) { view, windowInsets ->
val systemBars = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
val displayCutout = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
val safeInsets = Insets.max(systemBars, displayCutout)
view.setPadding(
initialPaddingLeft + safeInsets.left,
initialPaddingTop + safeInsets.top,
initialPaddingRight + safeInsets.right,
initialPaddingBottom + safeInsets.bottom
)
updateContentSize()
windowInsets
}
ViewCompat.requestApplyInsets(root)
updateContentSize()
}
/**
* Pass in a photo of a document, and get back 4 corner points (top left, top right, bottom
* right, bottom left). This tries to detect document corners, but falls back to photo corners
* with slight margin in case we can't detect document corners.
*
* @param photo the original photo with a rectangular document
* @return a List of 4 OpenCV points (document corners)
*/
private fun getDocumentCorners(photo: Bitmap): List<Point> {
val cornerPoints: List<Point>? = DocumentDetector.findDocumentCorners(photo)
// if cornerPoints is null then default the corners to the photo bounds with a margin
return cornerPoints ?: listOf(
Point(0.0, 0.0).move(
cropperOffsetWhenCornersNotFound,
cropperOffsetWhenCornersNotFound
),
Point(photo.width.toDouble(), 0.0).move(
-cropperOffsetWhenCornersNotFound,
cropperOffsetWhenCornersNotFound
),
Point(0.0, photo.height.toDouble()).move(
cropperOffsetWhenCornersNotFound,
-cropperOffsetWhenCornersNotFound
),
Point(photo.width.toDouble(), photo.height.toDouble()).move(
-cropperOffsetWhenCornersNotFound,
-cropperOffsetWhenCornersNotFound
)
)
}
/**
* Set document to null since we're capturing a new document, and open the camera. If the
* user captures a photo successfully document gets updated.
*/
/**
* @property photoFilePath the photo file path
*/
private lateinit var photoFilePath: String
/**
* @property startForResult used to launch camera
*/
private val startForResult = registerForActivityResult(
ActivityResultContracts.TakePicture()
) { isSuccess ->
if (isSuccess) {
// send back photo file path on capture success
val originalPhotoPath = photoFilePath
// user takes photo
// if maxNumDocuments is 3 and this is the 3rd photo, hide the new photo button since
// we reach the allowed limit
if (documents.size == maxNumDocuments - 1) {
val newPhotoButton: CircleButton = findViewById(R.id.new_photo_button)
newPhotoButton.isClickable = false
newPhotoButton.visibility = View.INVISIBLE
}
// get bitmap from photo file path
val photo: Bitmap = ImageUtil().getImageFromFilePath(originalPhotoPath)
// get document corners by detecting them, or falling back to photo corners with
// slight margin if we can't find the corners
val corners = try {
val (topLeft, topRight, bottomLeft, bottomRight) = getDocumentCorners(photo)
Quad(topLeft, topRight, bottomRight, bottomLeft)
} catch (exception: Throwable) {
finishIntentWithError(
"unable to get document corners: ${exception.message}"
)
return@registerForActivityResult
}
document = Document(originalPhotoPath, photo.width, photo.height, corners)
if (letUserAdjustCrop) {
// user is allowed to move corners to make corrections
try {
// set preview image height based off of photo dimensions
imageView.setImagePreviewBounds(
photo,
contentWidth.takeIf { it > 0 } ?: screenWidth,
contentHeight.takeIf { it > 0 } ?: screenHeight
)
// display original photo, so user can adjust detected corners
imageView.setImage(photo)
// document corner points are in original image coordinates, so we need to
// scale and move the points to account for blank space (caused by photo and
// photo container having different aspect ratios)
val cornersInImagePreviewCoordinates = corners
.mapOriginalToPreviewImageCoordinates(
imageView.imagePreviewBounds,
imageView.imagePreviewBounds.height() / photo.height
)
// display cropper, and allow user to move corners
imageView.setCropper(cornersInImagePreviewCoordinates)
} catch (exception: Throwable) {
finishIntentWithError(
"unable get image preview ready: ${exception.message}"
)
return@registerForActivityResult
}
} else {
// user isn't allowed to move corners, so accept automatically detected corners
document?.let { document ->
documents.add(document)
}
// create cropped document image, and return file path to complete document scan
cropDocumentAndFinishIntent()
}
} else {
// delete the photo since the user didn't finish taking the photo
File(photoFilePath).delete()
// user exits camera
// complete document scan if this is the first document since we can't go to crop view
// until user takes at least 1 photo
if (documents.isEmpty()) {
onClickCancel()
}
}
}
/**
* open the camera by launching an image capture intent
*
* @param pageNumber the current document page number
*/
private fun openCamera(pageNumber: Int = documents.size) {
// create new file for photo
val photoFile: File = FileUtil().createImageFile(this, pageNumber)
// store the photo file path, and send it back once the photo is saved
photoFilePath = photoFile.absolutePath
// photo gets saved to this file path
// open camera
startForResult.launch(
FileProvider.getUriForFile(
this,
"${packageName}.DocumentScannerFileProvider",
photoFile
)
)
}
/**
* Once user accepts by pressing check button, or by pressing add new document button, add
* original photo path and 4 document corners to documents list. If user isn't allowed to
* adjust corners, call this automatically.
*/
private fun addSelectedCornersAndOriginalPhotoPathToDocuments() {
// only add document it's not null (the current document photo capture, and corner
// detection are successful)
document?.let { document ->
// convert corners from image preview coordinates to original photo coordinates
// (original image is probably bigger than the preview image)
val cornersInOriginalImageCoordinates = imageView.corners
.mapPreviewToOriginalImageCoordinates(
imageView.imagePreviewBounds,
imageView.imagePreviewBounds.height() / document.originalPhotoHeight
)
document.corners = cornersInOriginalImageCoordinates
documents.add(document)
}
}
/**
* This gets called when a user presses the new document button. Store current photo path
* with document corners. Then open the camera, so user can take a photo of the next
* page or document
*/
private fun onClickNew() {
addSelectedCornersAndOriginalPhotoPathToDocuments()
openCamera()
}
/**
* This gets called when a user presses the done button. Store current photo path with
* document corners. Then crop document using corners, and return cropped image paths
*/
private fun onClickDone() {
addSelectedCornersAndOriginalPhotoPathToDocuments()
cropDocumentAndFinishIntent()
}
/**
* This gets called when a user presses the retake photo button. The user presses this in
* case the original document photo isn't good, and they need to take it again.
*/
private fun onClickRetake() {
// we're going to retake the photo, so delete the one we just took
document?.let { document -> File(document.originalPhotoFilePath).delete() }
openCamera()
}
/**
* This gets called when a user doesn't want to complete the document scan after starting.
* For example a user can quit out of the camera before snapping a photo of the document.
*/
private fun onClickCancel() {
setResult(RESULT_CANCELED)
finish()
}
/**
* This crops original document photo, saves cropped document photo, deletes original
* document photo, and returns cropped document photo file path. It repeats that for
* all document photos.
*/
private fun cropDocumentAndFinishIntent() {
val croppedImageResults = arrayListOf<String>()
for ((pageNumber, document) in documents.withIndex()) {
// crop document photo by using corners
val croppedImage: Bitmap = try {
ImageUtil().crop(
document.originalPhotoFilePath,
document.corners
)
} catch (exception: Throwable) {
finishIntentWithError("unable to crop image: ${exception.message}")
return
}
// delete original document photo
File(document.originalPhotoFilePath).delete()
// save cropped document photo
try {
val croppedImageFile = FileUtil().createImageFile(this, pageNumber)
croppedImage.saveToFile(croppedImageFile, croppedImageQuality)
croppedImageResults.add(Uri.fromFile(croppedImageFile).toString())
} catch (exception: Throwable) {
finishIntentWithError(
"unable to save cropped image: ${exception.message}"
)
}
}
// return array of cropped document photo file paths
setResult(
RESULT_OK,
Intent().putExtra("croppedImageResults", croppedImageResults)
)
finish()
}
/**
* This ends the document scanner activity, and returns an error message that can be
* used to debug error
*
* @param errorMessage an error message
*/
private fun finishIntentWithError(errorMessage: String) {
setResult(
RESULT_OK,
Intent().putExtra("error", errorMessage)
)
finish()
}
}
@@ -0,0 +1,22 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.websitebeaver.documentscanner
import androidx.core.content.FileProvider
class DocumentScannerFileProvider : FileProvider()
@@ -0,0 +1,30 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.websitebeaver.documentscanner.constants
/**
* This class contains default document scanner options
*/
class DefaultSetting {
companion object {
const val CROPPED_IMAGE_QUALITY = 100
const val LET_USER_ADJUST_CROP = true
const val MAX_NUM_DOCUMENTS = 24
const val RESPONSE_TYPE = ResponseType.IMAGE_FILE_PATH
}
}
@@ -0,0 +1,37 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.websitebeaver.documentscanner.constants
/**
* This class contains constants meant to be used as intent extras
*/
class DocumentScannerExtra {
companion object {
const val EXTRA_CROPPED_IMAGE_QUALITY = "croppedImageQuality"
const val EXTRA_LET_USER_ADJUST_CROP = "letUserAdjustCrop"
const val EXTRA_MAX_NUM_DOCUMENTS = "maxNumDocuments"
const val EXTRA_CROPPER_HANDLE_COLOR = "cropperHandleColor"
const val EXTRA_CROPPER_HANDLE_OUTLINE_COLOR = "cropperHandleOutlineColor"
const val EXTRA_CROPPER_FRAME_COLOR = "cropperFrameColor"
const val EXTRA_CROPPER_STROKE_WIDTH_DP = "cropperStrokeWidthDp"
const val EXTRA_BUTTON_TINT_COLOR = "buttonTintColor"
const val EXTRA_BUTTON_CONTAINER_COLOR = "buttonContainerColor"
const val EXTRA_DONE_BUTTON_TINT_COLOR = "doneButtonTintColor"
const val EXTRA_DONE_BUTTON_CONTAINER_COLOR = "doneButtonContainerColor"
}
}
@@ -0,0 +1,28 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.websitebeaver.documentscanner.constants
/**
* constants that represent all possible document scanner response formats
*/
class ResponseType {
companion object {
const val BASE64 = "base64"
const val IMAGE_FILE_PATH = "imageFilePath"
}
}
@@ -0,0 +1,25 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.websitebeaver.documentscanner.enums
/**
* enums for all 4 quad corners
*/
enum class QuadCorner {
TOP_LEFT, TOP_RIGHT, BOTTOM_RIGHT, BOTTOM_LEFT
}
@@ -0,0 +1,48 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.websitebeaver.documentscanner.extensions
import android.graphics.Rect
import androidx.appcompat.app.AppCompatActivity
@Suppress("DEPRECATION")
/**
* @property screenBounds the screen bounds (used to get screen width and height)
*/
val AppCompatActivity.screenBounds: Rect
get() {
// currentWindowMetrics was added in Android R
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
return windowManager.currentWindowMetrics.bounds
}
// fall back to get screen width and height if using a version before Android R
return Rect(
0, 0, windowManager.defaultDisplay.width, windowManager.defaultDisplay.height
)
}
/**
* @property screenWidth the screen width
*/
val AppCompatActivity.screenWidth: Int get() = screenBounds.width()
/**
* @property screenHeight the screen height
*/
val AppCompatActivity.screenHeight: Int get() = screenBounds.height()
@@ -0,0 +1,65 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.websitebeaver.documentscanner.extensions
import android.graphics.Bitmap
import android.util.Base64
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileOutputStream
import kotlin.math.sqrt
/**
* This converts the bitmap to base64
*
* @return image as a base64 string
*/
fun Bitmap.toBase64(quality: Int): String {
val byteArrayOutputStream = ByteArrayOutputStream()
compress(Bitmap.CompressFormat.JPEG, quality, byteArrayOutputStream)
return Base64.encodeToString(
byteArrayOutputStream.toByteArray(),
Base64.DEFAULT
)
}
/**
* This converts the bitmap to base64
*
* @param file the bitmap gets saved to this file
*/
fun Bitmap.saveToFile(file: File, quality: Int) {
val fileOutputStream = FileOutputStream(file)
compress(Bitmap.CompressFormat.JPEG, quality, fileOutputStream)
fileOutputStream.close()
}
/**
* This resizes the image, so that the byte count is a little less than targetBytes
*
* @param targetBytes the returned bitmap has a size a little less than targetBytes
*/
fun Bitmap.changeByteCountByResizing(targetBytes: Int): Bitmap {
val scale = sqrt(targetBytes.toDouble() / byteCount.toDouble())
return Bitmap.createScaledBitmap(
this,
(width * scale).toInt(),
(height * scale).toInt(),
true
)
}
@@ -0,0 +1,146 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.websitebeaver.documentscanner.extensions
import android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.PointF
import android.graphics.RectF
import android.graphics.drawable.Drawable
import com.websitebeaver.documentscanner.enums.QuadCorner
import com.websitebeaver.documentscanner.models.Line
import com.websitebeaver.documentscanner.models.Quad
/**
* This draws a quad (used to draw cropper). It draws 4 circles and
* 4 connecting lines
*
* @param quad 4 corners
* @param pointRadius corner circle radius
* @param cropperLineStyle quad line style (color, thickness for example)
* @param cropperCornerStyle quad corner style (color, thickness for example)
* @param cropperCornerOutlineStyle quad corner outer outline style
* @param cropperSelectedCornerFillStyles style for selected corner
* @param selectedCornerProgress corner selection animation progress
*/
fun Canvas.drawQuad(
quad: Quad,
pointRadius: Float,
cropperLineStyle: Paint,
cropperCornerStyle: Paint,
cropperCornerOutlineStyle: Paint,
cropperSelectedCornerFillStyles: Paint,
selectedCornerProgress: Map<QuadCorner, Float>,
imagePreviewBounds: RectF,
ratio: Float,
selectedCornerRadiusMagnification: Float,
selectedCornerBackgroundMagnification: Float,
) {
fun drawEdges() {
for (edge: Line in quad.edges) {
drawLine(edge.from.x, edge.from.y, edge.to.x, edge.to.y, cropperLineStyle)
}
}
fun Float.lerpTo(target: Float, progress: Float): Float = this + (target - this) * progress
// draw connecting lines under the corner handles
drawEdges()
// draw selected corner magnifier fills first, then redraw edges so the frame is visible in zoom
for ((quadCorner: QuadCorner, cornerPoint: PointF) in quad.corners) {
val progress = selectedCornerProgress[quadCorner]?.coerceIn(0f, 1f) ?: 0f
if (progress <= 0f) continue
val circleRadius = pointRadius.lerpTo(
selectedCornerRadiusMagnification * pointRadius,
progress
)
val backgroundMagnification = 1f.lerpTo(
selectedCornerBackgroundMagnification,
progress
)
val matrix = Matrix()
matrix.postScale(ratio, ratio, ratio / cornerPoint.x, ratio / cornerPoint.y)
matrix.postTranslate(imagePreviewBounds.left, imagePreviewBounds.top)
matrix.postScale(
backgroundMagnification,
backgroundMagnification,
cornerPoint.x,
cornerPoint.y
)
cropperSelectedCornerFillStyles.shader.setLocalMatrix(matrix)
// fill selected corner circle with magnified image, so it's easier to crop
drawCircle(cornerPoint.x, cornerPoint.y, circleRadius, cropperSelectedCornerFillStyles)
}
if (selectedCornerProgress.isNotEmpty()) {
drawEdges()
}
// draw 4 corner points
for ((quadCorner: QuadCorner, cornerPoint: PointF) in quad.corners) {
val progress = selectedCornerProgress[quadCorner]?.coerceIn(0f, 1f) ?: 0f
val circleRadius = pointRadius.lerpTo(
selectedCornerRadiusMagnification * pointRadius,
progress
)
// draw outer ring around corner circles
drawCircle(
cornerPoint.x,
cornerPoint.y,
circleRadius + cropperCornerOutlineStyle.strokeWidth,
cropperCornerOutlineStyle
)
// draw corner circles
drawCircle(
cornerPoint.x,
cornerPoint.y,
circleRadius,
cropperCornerStyle
)
}
}
/**
* This draws the check icon on the finish document scan button. It's needed
* because the inner circle covers the check icon.
*
* @param buttonCenterX the button center x coordinate
* @param buttonCenterY the button center y coordinate
* @param drawable the check icon
*/
fun Canvas.drawCheck(
buttonCenterX: Float,
buttonCenterY: Float,
drawable: Drawable,
tintColor: Int? = null
) {
val mutate = drawable.constantState?.newDrawable()?.mutate()
tintColor?.let { mutate?.setTint(it) }
mutate?.setBounds(
(buttonCenterX - drawable.intrinsicWidth.toFloat() / 2).toInt(),
(buttonCenterY - drawable.intrinsicHeight.toFloat() / 2).toInt(),
(buttonCenterX + drawable.intrinsicWidth.toFloat() / 2).toInt(),
(buttonCenterY + drawable.intrinsicHeight.toFloat() / 2).toInt()
)
mutate?.draw(this)
}
@@ -0,0 +1,35 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.websitebeaver.documentscanner.extensions
import android.widget.ImageButton
/**
* This function adds an on click listener to the button. It makes the button not clickable,
* calls the on click function, and then makes the button clickable. This prevents the on click
* function from being called while it runs.
*
* @param onClick the click event handler
*/
fun ImageButton.onClick(onClick: () -> Unit) {
setOnClickListener {
isClickable = false
onClick()
isClickable = true
}
}
@@ -0,0 +1,92 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.websitebeaver.documentscanner.extensions
import android.graphics.PointF
import org.opencv.core.Point
import kotlin.math.pow
import kotlin.math.sqrt
/**
* converts an OpenCV point to Android point
*
* @return Android point
*/
fun Point.toPointF(): PointF {
return PointF(x.toFloat(), y.toFloat())
}
/**
* calculates the distance between 2 OpenCV points
*
* @param point the 2nd OpenCV point
* @return the distance between this point and the 2nd point
*/
fun Point.distance(point: Point): Double {
return sqrt((point.x - x).pow(2) + (point.y - y).pow(2))
}
/**
* offset the OpenCV point by (dx, dy)
*
* @param dx horizontal offset
* @param dy vertical offset
* @return the OpenCV point after moving it (dx, dy)
*/
fun Point.move(dx: Double, dy: Double): Point {
return Point(x + dx, y + dy)
}
/**
* converts an Android point to OpenCV point
*
* @return OpenCV point
*/
fun PointF.toOpenCVPoint(): Point {
return Point(x.toDouble(), y.toDouble())
}
/**
* multiply an Android point by magnitude
*
* @return Android point after multiplying by magnitude
*/
fun PointF.multiply(magnitude: Float): PointF {
return PointF(magnitude * x, magnitude * y)
}
/**
* offset the Android point by (dx, dy)
*
* @param dx horizontal offset
* @param dy vertical offset
* @return the Android point after moving it (dx, dy)
*/
fun PointF.move(dx: Float, dy: Float): PointF {
return PointF(x + dx, y + dy)
}
/**
* calculates the distance between 2 Android points
*
* @param point the 2nd Android point
* @return the distance between this point and the 2nd point
*/
fun PointF.distance(point: PointF): Float {
return sqrt((point.x - x).pow(2) + (point.y - y).pow(2))
}
@@ -0,0 +1,35 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.websitebeaver.documentscanner.models
/**
* This class contains the original document photo, and a cropper. The user can drag the corners
* to make adjustments to the detected corners.
*
* @param originalPhotoFilePath the photo file path before cropping
* @param originalPhotoWidth the original photo width
* @param originalPhotoHeight the original photo height
* @param corners the document's 4 corner points
* @constructor creates a document
*/
class Document(
val originalPhotoFilePath: String,
private val originalPhotoWidth: Int,
val originalPhotoHeight: Int,
var corners: Quad
)
@@ -0,0 +1,32 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.websitebeaver.documentscanner.models
import android.graphics.PointF
/**
* represents a line connecting 2 Android points
*
* @param fromPoint the 1st point
* @param toPoint the 2nd point
* @constructor creates a line connecting 2 points
*/
class Line(fromPoint: PointF, toPoint: PointF) {
val from: PointF = fromPoint
val to: PointF = toPoint
}
@@ -0,0 +1,159 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.websitebeaver.documentscanner.models
import android.graphics.PointF
import android.graphics.RectF
import com.websitebeaver.documentscanner.enums.QuadCorner
import com.websitebeaver.documentscanner.extensions.distance
import com.websitebeaver.documentscanner.extensions.move
import com.websitebeaver.documentscanner.extensions.multiply
import com.websitebeaver.documentscanner.extensions.toPointF
import org.opencv.core.Point
/**
* This class is used to represent the cropper. It contains 4 corners.
*
* @param topLeftCorner the top left corner
* @param topRightCorner the top right corner
* @param bottomRightCorner the bottom right corner
* @param bottomLeftCorner the bottom left corner
* @constructor creates a quad from Android points
*/
class Quad(
val topLeftCorner: PointF,
val topRightCorner: PointF,
val bottomRightCorner: PointF,
val bottomLeftCorner: PointF
) {
/**
* @constructor creates a quad from OpenCV points
*/
constructor(
topLeftCorner: Point,
topRightCorner: Point,
bottomRightCorner: Point,
bottomLeftCorner: Point
) : this(
topLeftCorner.toPointF(),
topRightCorner.toPointF(),
bottomRightCorner.toPointF(),
bottomLeftCorner.toPointF()
)
/**
* @property corners lets us get the point coordinates for any corner
*/
var corners: MutableMap<QuadCorner, PointF> = mutableMapOf(
QuadCorner.TOP_LEFT to topLeftCorner,
QuadCorner.TOP_RIGHT to topRightCorner,
QuadCorner.BOTTOM_RIGHT to bottomRightCorner,
QuadCorner.BOTTOM_LEFT to bottomLeftCorner
)
/**
* @property edges 4 lines that connect the 4 corners
*/
val edges: Array<Line>
get() = arrayOf(
Line(topLeftCorner, topRightCorner),
Line(topRightCorner, bottomRightCorner),
Line(bottomRightCorner, bottomLeftCorner),
Line(bottomLeftCorner, topLeftCorner)
)
/**
* This finds the corner that's closest to a point. When a user touches to drag
* the cropper, that point is used to determine which corner to move.
*
* @param point we want to find the corner closest to this point
* @return the closest corner
*/
fun getCornerClosestToPoint(point: PointF): QuadCorner {
return corners.minByOrNull { corner -> corner.value.distance(point) }?.key!!
}
/**
* This moves a corner by (dx, dy)
*
* @param corner the corner that needs to be moved
* @param dx the corner moves dx horizontally
* @param dy the corner moves dy vertically
*/
fun moveCorner(corner: QuadCorner, dx: Float, dy: Float) {
corners[corner]?.offset(dx, dy)
}
/**
* This maps original image coordinates to preview image coordinates. The original image
* is probably larger than the preview image.
*
* @param imagePreviewBounds offset the point by the top left of imagePreviewBounds
* @param ratio multiply the point by ratio
* @return the 4 corners after mapping coordinates
*/
fun mapOriginalToPreviewImageCoordinates(imagePreviewBounds: RectF, ratio: Float): Quad {
return Quad(
topLeftCorner.multiply(ratio).move(
imagePreviewBounds.left,
imagePreviewBounds.top
),
topRightCorner.multiply(ratio).move(
imagePreviewBounds.left,
imagePreviewBounds.top
),
bottomRightCorner.multiply(ratio).move(
imagePreviewBounds.left,
imagePreviewBounds.top
),
bottomLeftCorner.multiply(ratio).move(
imagePreviewBounds.left,
imagePreviewBounds.top
)
)
}
/**
* This maps preview image coordinates to original image coordinates. The original image
* is probably larger than the preview image.
*
* @param imagePreviewBounds reverse offset the point by the top left of imagePreviewBounds
* @param ratio divide the point by ratio
* @return the 4 corners after mapping coordinates
*/
fun mapPreviewToOriginalImageCoordinates(imagePreviewBounds: RectF, ratio: Float): Quad {
return Quad(
topLeftCorner.move(
-imagePreviewBounds.left,
-imagePreviewBounds.top
).multiply(1 / ratio),
topRightCorner.move(
-imagePreviewBounds.left,
-imagePreviewBounds.top
).multiply(1 / ratio),
bottomRightCorner.move(
-imagePreviewBounds.left,
-imagePreviewBounds.top
).multiply(1 / ratio),
bottomLeftCorner.move(
-imagePreviewBounds.left,
-imagePreviewBounds.top
).multiply(1 / ratio)
)
}
}
@@ -0,0 +1,98 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.websitebeaver.documentscanner.ui
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatImageButton
import com.websitebeaver.documentscanner.R
/**
* This class creates a rounded action button by modifying an image button. This is used for the
* add new document button and retake photo button.
*
* @param context image button context
* @param attrs image button attributes
* @constructor creates circle button
*/
class CircleButton(
context: Context,
attrs: AttributeSet
) : AppCompatImageButton(context, attrs) {
/**
* @property ring the button's outer ring
*/
private val ring = Paint(Paint.ANTI_ALIAS_FLAG)
private val circle = Paint(Paint.ANTI_ALIAS_FLAG)
private var drawCircleBackground = false
init {
// set outer ring style
ring.color = Color.WHITE
ring.style = Paint.Style.STROKE
ring.strokeWidth = resources.getDimension(R.dimen.small_button_ring_thickness)
circle.style = Paint.Style.FILL
}
/**
* Set button colors from the app theme.
*/
fun setColors(containerColor: Int, iconColor: Int) {
ring.color = containerColor
circle.color = containerColor
drawCircleBackground = true
imageTintList = ColorStateList.valueOf(iconColor)
invalidate()
}
/**
* This gets called repeatedly. We use it to draw the button
*
* @param canvas the image button canvas
*/
@SuppressLint("DrawAllocation")
override fun onDraw(canvas: Canvas) {
val rect = RectF(
ring.strokeWidth / 2,
ring.strokeWidth / 2,
width.toFloat() - ring.strokeWidth / 2,
height.toFloat() - ring.strokeWidth / 2
)
val cornerRadius = resources.getDimension(R.dimen.small_button_corner_radius)
if (drawCircleBackground) {
canvas.drawRoundRect(rect, cornerRadius, cornerRadius, circle)
}
super.onDraw(canvas)
if (!drawCircleBackground) {
// draw outer ring
canvas.drawRoundRect(rect, cornerRadius, cornerRadius, ring)
}
}
}
@@ -0,0 +1,102 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.websitebeaver.documentscanner.ui
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatImageButton
import androidx.core.content.ContextCompat
import com.websitebeaver.documentscanner.R
import com.websitebeaver.documentscanner.extensions.drawCheck
/**
* This class creates a circular done button by modifying an image button. The user presses
* this button once they finish cropping an image
*
* @param context image button context
* @param attrs image button attributes
* @constructor creates done button
*/
class DoneButton(
context: Context,
attrs: AttributeSet
) : AppCompatImageButton(context, attrs) {
/**
* @property ring the button's outer ring
*/
private val ring = Paint(Paint.ANTI_ALIAS_FLAG)
/**
* @property circle the button's inner circle
*/
private val circle = Paint(Paint.ANTI_ALIAS_FLAG)
private var iconTintColor = Color.WHITE
init {
// set outer ring style
ring.color = Color.WHITE
ring.style = Paint.Style.STROKE
ring.strokeWidth = resources.getDimension(R.dimen.large_button_ring_thickness)
// set inner circle style
circle.color = ContextCompat.getColor(context, R.color.done_button_inner_circle_color)
circle.style = Paint.Style.FILL
}
/**
* Set button colors from the app theme.
*/
fun setColors(ringColor: Int, circleColor: Int, iconColor: Int) {
ring.color = ringColor
circle.color = circleColor
iconTintColor = iconColor
imageTintList = ColorStateList.valueOf(iconColor)
invalidate()
}
/**
* This gets called repeatedly. We use it to draw the done button.
*
* @param canvas the image button canvas
*/
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// calculate button center point, outer ring radius, and inner circle radius
val centerX = width.toFloat() / 2
val centerY = height.toFloat() / 2
val outerRadius = (width.toFloat() - ring.strokeWidth) / 2
val innerRadius = outerRadius - resources.getDimension(
R.dimen.large_button_outer_ring_offset
)
// draw outer ring
canvas.drawCircle(centerX, centerY, outerRadius, ring)
// draw inner circle
canvas.drawCircle(centerX, centerY, innerRadius, circle)
// draw check icon since it gets covered by inner circle
canvas.drawCheck(centerX, centerY, drawable, iconTintColor)
}
}
@@ -0,0 +1,524 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.websitebeaver.documentscanner.ui
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ValueAnimator
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapShader
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.PointF
import android.graphics.RectF
import android.graphics.Shader
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.animation.DecelerateInterpolator
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.graphics.drawable.toBitmap
import com.websitebeaver.documentscanner.R
import com.websitebeaver.documentscanner.enums.QuadCorner
import com.websitebeaver.documentscanner.extensions.changeByteCountByResizing
import com.websitebeaver.documentscanner.extensions.distance
import com.websitebeaver.documentscanner.extensions.drawQuad
import com.websitebeaver.documentscanner.models.Quad
/**
* This class contains the original document photo, and a cropper. The user can drag the corners
* to make adjustments to the detected corners.
*
* @param context view context
* @param attrs view attributes
* @constructor creates image crop view
*/
class ImageCropView(context: Context, attrs: AttributeSet) : AppCompatImageView(context, attrs) {
/**
* @property quad the 4 document corners
*/
private var quad: Quad? = null
/**
* @property prevTouchPoint keep track of where the user touches, so we know how much
* to move corners on drag
*/
private var prevTouchPoint: PointF? = null
/**
* @property dragTarget corner or edge that should move on drag
*/
private var dragTarget: CropperDragTarget? = null
/**
* @property selectedCornerProgress animated selected state for each cropper corner
*/
private val selectedCornerProgress = mutableMapOf<QuadCorner, Float>()
/**
* @property selectedCornerAnimators active corner selection animations
*/
private val selectedCornerAnimators = mutableMapOf<QuadCorner, ValueAnimator>()
/**
* @property cropperLineStyle paint style for connecting lines
*/
private val cropperLineStyle = Paint(Paint.ANTI_ALIAS_FLAG)
/**
* @property cropperCornerStyle paint style for 4 corners
*/
private val cropperCornerStyle = Paint(Paint.ANTI_ALIAS_FLAG)
/**
* @property cropperCornerOutlineStyle paint style for the outer ring around corner handles
*/
private val cropperCornerOutlineStyle = Paint(Paint.ANTI_ALIAS_FLAG)
/**
* @property cropperSelectedCornerFillStyles when you tap and drag a cropper corner the circle
* acts like a magnifying glass
*/
private val cropperSelectedCornerFillStyles = Paint()
/**
* @property imagePreviewHeight this is needed because height doesn't update immediately
* after we set the image
*/
private var imagePreviewHeight = height
/**
* @property imagePreviewWidth this is needed because width doesn't update immediately
* after we set the image
*/
private var imagePreviewWidth = width
/**
* @property ratio image container height to image height ratio used to map container
* to image coordinates and vice versa
*/
private val ratio: Float get() = imagePreviewBounds.height() / drawable.intrinsicHeight
/**
* @property corners document corners in image preview coordinates
*/
val corners: Quad get() = quad!!
/**
* @property imagePreviewMaxSizeInBytes if the photo is too big, we need to scale it down
* before we display it
*/
private val imagePreviewMaxSizeInBytes = 100 * 1024 * 1024
init {
val defaultCropperStrokeWidth = DefaultCropperStrokeWidthDp.dpToPx()
// set cropper style
cropperLineStyle.color = Color.WHITE
cropperLineStyle.style = Paint.Style.STROKE
cropperLineStyle.strokeWidth = defaultCropperStrokeWidth
cropperCornerStyle.color = Color.WHITE
cropperCornerStyle.style = Paint.Style.STROKE
cropperCornerStyle.strokeWidth = defaultCropperStrokeWidth
cropperCornerOutlineStyle.color = Color.argb(128, 255, 255, 255)
cropperCornerOutlineStyle.style = Paint.Style.STROKE
cropperCornerOutlineStyle.strokeWidth = defaultCropperStrokeWidth
}
/**
* Initially the image preview height is 0. This calculates the height by using the photo
* dimensions. It maintains the photo aspect ratio (we likely need to scale the photo down
* to fit the preview container).
*
* @param photo the original photo with a rectangular document
* @param screenWidth the device width
*/
fun setImagePreviewBounds(photo: Bitmap, screenWidth: Int, screenHeight: Int) {
// image width to height aspect ratio
val imageRatio = photo.width.toFloat() / photo.height.toFloat()
val buttonsViewMinHeight = context.resources.getDimension(
R.dimen.buttons_container_min_height
).toInt()
imagePreviewHeight = if (photo.height > photo.width) {
// if user takes the photo in portrait
(screenWidth.toFloat() / imageRatio).toInt()
} else {
// if user takes the photo in landscape
(screenWidth.toFloat() * imageRatio).toInt()
}
// set a cap on imagePreviewHeight, so that the bottom buttons container isn't hidden
imagePreviewHeight = Integer.min(
imagePreviewHeight,
screenHeight - buttonsViewMinHeight
)
imagePreviewWidth = screenWidth
// image container initially has a 0 width and 0 height, calculate both and set them
layoutParams.height = imagePreviewHeight
layoutParams.width = imagePreviewWidth
// refresh layout after we change height
requestLayout()
}
/**
* Insert bitmap in image view, and trigger onSetImage event handler
*/
fun setImage(photo: Bitmap) {
var previewImagePhoto = photo
// if the image is too large, we need to scale it down before displaying it
if (photo.byteCount > imagePreviewMaxSizeInBytes) {
previewImagePhoto = photo.changeByteCountByResizing(imagePreviewMaxSizeInBytes)
}
this.setImageBitmap(previewImagePhoto)
this.onSetImage()
}
/**
* Once the user takes a photo, we try to detect corners. This function stores them as quad.
*
* @param cropperCorners 4 corner points in original photo coordinates
*/
fun setCropper(cropperCorners: Quad) {
quad = cropperCorners
}
/**
* Set cropper colors from the app theme.
*/
fun setCropperColors(handleColor: Int, frameColor: Int) {
cropperCornerStyle.color = handleColor
cropperLineStyle.color = frameColor
invalidate()
}
/**
* Set cropper colors and line width from the app theme.
*/
fun setCropperStyle(
handleColor: Int,
frameColor: Int,
handleOutlineColor: Int,
strokeWidthDp: Float
) {
val strokeWidth = strokeWidthDp.dpToPx()
cropperCornerStyle.color = handleColor
cropperCornerOutlineStyle.color = handleOutlineColor
cropperLineStyle.color = frameColor
cropperCornerStyle.strokeWidth = strokeWidth
cropperCornerOutlineStyle.strokeWidth = strokeWidth
cropperLineStyle.strokeWidth = strokeWidth
invalidate()
}
/**
* @property imagePreviewBounds image coordinates - if the image ratio is different than
* the image container ratio then there's blank space either at the top and bottom of the
* image or the left and right of the image
*/
val imagePreviewBounds: RectF
get() {
// image container width to height ratio
val imageViewRatio: Float = imagePreviewWidth.toFloat() / imagePreviewHeight.toFloat()
// image width to height ratio
val imageRatio = drawable.intrinsicWidth.toFloat() / drawable.intrinsicHeight.toFloat()
var left = 0f
var top = 0f
var right = imagePreviewWidth.toFloat()
var bottom = imagePreviewHeight.toFloat()
if (imageRatio > imageViewRatio) {
// if the image is really wide, there's blank space at the top and bottom
val offset = (imagePreviewHeight - (imagePreviewWidth / imageRatio)) / 2
top += offset
bottom -= offset
} else {
// if the image is really tall, there's blank space at the left and right
// it's also possible that the image ratio matches the image container ratio
// in which case there's no blank space
val offset = (imagePreviewWidth - (imagePreviewHeight * imageRatio)) / 2
left += offset
right -= offset
}
return RectF(left, top, right, bottom)
}
/**
* This gets called once we insert an image in this image view
*/
private fun onSetImage() {
cropperSelectedCornerFillStyles.style = Paint.Style.FILL
cropperSelectedCornerFillStyles.shader = BitmapShader(
drawable.toBitmap(),
Shader.TileMode.CLAMP,
Shader.TileMode.CLAMP
)
}
private fun findDragTarget(touchPoint: PointF): CropperDragTarget {
val touchRadius = resources.getDimension(R.dimen.cropper_corner_radius) * 2f
val touchedCorner = quad!!.corners
.mapNotNull { (corner, point) ->
val distance = point.distance(touchPoint)
if (distance <= touchRadius) {
corner to distance
} else null
}
.minByOrNull { it.second }
?.first
if (touchedCorner != null) return CropperDragTarget.Corner(touchedCorner)
val touchedEdge = cropperEdges
.mapNotNull { (firstCorner, secondCorner) ->
val firstPoint = quad!!.corners[firstCorner] ?: return@mapNotNull null
val secondPoint = quad!!.corners[secondCorner] ?: return@mapNotNull null
val distanceSquared = touchPoint.distanceToSegmentSquared(firstPoint, secondPoint)
if (distanceSquared <= touchRadius * touchRadius) {
CropperDragTarget.Edge(firstCorner, secondCorner) to distanceSquared
} else null
}
.minByOrNull { it.second }
?.first
return touchedEdge ?: CropperDragTarget.Corner(quad!!.getCornerClosestToPoint(touchPoint))
}
private fun PointF.coerceDragAmountFor(corners: Set<QuadCorner>): PointF {
val bounds = imagePreviewBounds
var minX = Float.NEGATIVE_INFINITY
var maxX = Float.POSITIVE_INFINITY
var minY = Float.NEGATIVE_INFINITY
var maxY = Float.POSITIVE_INFINITY
corners.forEach { corner ->
val point = quad!!.corners[corner] ?: return@forEach
minX = minX.coerceAtLeast(bounds.left - point.x)
maxX = maxX.coerceAtMost(bounds.right - point.x)
minY = minY.coerceAtLeast(bounds.top - point.y)
maxY = maxY.coerceAtMost(bounds.bottom - point.y)
}
return PointF(
x.coerceIn(minX, maxX),
y.coerceIn(minY, maxY)
)
}
/**
* This gets called constantly, and we use it to update the cropper corners
*
* @param canvas the image preview canvas
*/
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (quad !== null) {
// draw 4 corners and connecting lines
canvas.drawQuad(
quad!!,
resources.getDimension(R.dimen.cropper_corner_radius),
cropperLineStyle,
cropperCornerStyle,
cropperCornerOutlineStyle,
cropperSelectedCornerFillStyles,
selectedCornerProgress,
imagePreviewBounds,
ratio,
resources.getDimension(R.dimen.cropper_selected_corner_radius_magnification),
resources.getDimension(R.dimen.cropper_selected_corner_background_magnification)
)
}
}
/**
* This gets called when the user touches, drags, and stops touching screen. We use this
* to figure out which corner we need to move, and how far we need to move it.
*
* @param event the touch event
*/
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
// keep track of the touched point
val touchPoint = PointF(event.x, event.y)
when (event.action) {
MotionEvent.ACTION_DOWN -> {
// when the user touches the screen record the point, and find the closest
// corner or edge to the touch point
prevTouchPoint = touchPoint
dragTarget = findDragTarget(touchPoint)
animateSelectedCorners(dragTarget?.corners ?: emptySet())
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
// when the user stops touching the screen reset these values
prevTouchPoint = null
dragTarget = null
animateSelectedCorners(emptySet())
}
MotionEvent.ACTION_MOVE -> {
// when the user drags their finger, update the selected corner or edge position
val target = dragTarget ?: return true
val touchMoveDistance = PointF(
touchPoint.x - prevTouchPoint!!.x,
touchPoint.y - prevTouchPoint!!.y
).coerceDragAmountFor(target.corners)
if (target.corners.isNotEmpty()) {
target.corners.forEach { corner ->
quad!!.moveCorner(
corner,
touchMoveDistance.x,
touchMoveDistance.y
)
}
}
// record the point touched, so we can use it to calculate how far to move corner
// next time the user drags (assuming they don't stop touching the screen)
prevTouchPoint = touchPoint
}
}
// force refresh view
invalidate()
return true
}
override fun onDetachedFromWindow() {
val animators = selectedCornerAnimators.values.toList()
selectedCornerAnimators.clear()
selectedCornerProgress.clear()
animators.forEach { it.cancel() }
super.onDetachedFromWindow()
}
private fun animateSelectedCorners(selectedCorners: Set<QuadCorner>) {
val cornersToUpdate = selectedCornerProgress.keys + selectedCorners
cornersToUpdate.forEach { corner ->
animateCornerSelection(
corner = corner,
targetProgress = if (corner in selectedCorners) 1f else 0f
)
}
}
private fun animateCornerSelection(corner: QuadCorner, targetProgress: Float) {
val currentProgress = selectedCornerProgress[corner] ?: 0f
if (currentProgress == targetProgress) return
selectedCornerAnimators.remove(corner)?.cancel()
val animator = ValueAnimator.ofFloat(currentProgress, targetProgress).apply {
duration = CornerSelectionAnimationDurationMs
interpolator = DecelerateInterpolator()
addUpdateListener { animation ->
val progress = animation.animatedValue as Float
if (progress <= 0f) {
selectedCornerProgress.remove(corner)
} else {
selectedCornerProgress[corner] = progress
}
invalidate()
}
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
if (selectedCornerAnimators[corner] != animation) return
selectedCornerAnimators.remove(corner)
if (targetProgress <= 0f) {
selectedCornerProgress.remove(corner)
} else {
selectedCornerProgress[corner] = targetProgress
}
invalidate()
}
})
}
selectedCornerAnimators[corner] = animator
animator.start()
}
private fun Float.dpToPx(): Float = this * resources.displayMetrics.density
private sealed class CropperDragTarget {
data class Corner(val corner: QuadCorner) : CropperDragTarget()
data class Edge(
val firstCorner: QuadCorner,
val secondCorner: QuadCorner
) : CropperDragTarget()
val corners: Set<QuadCorner>
get() = when (this) {
is Corner -> setOf(corner)
is Edge -> setOf(firstCorner, secondCorner)
}
}
private fun PointF.distanceToSegmentSquared(start: PointF, end: PointF): Float {
val segmentX = end.x - start.x
val segmentY = end.y - start.y
val lengthSquared = segmentX * segmentX + segmentY * segmentY
if (lengthSquared == 0f) {
val xDistance = x - start.x
val yDistance = y - start.y
return xDistance * xDistance + yDistance * yDistance
}
val projection = (((x - start.x) * segmentX + (y - start.y) * segmentY) / lengthSquared)
.coerceIn(0f, 1f)
val closestX = start.x + projection * segmentX
val closestY = start.y + projection * segmentY
val xDistance = x - closestX
val yDistance = y - closestY
return xDistance * xDistance + yDistance * yDistance
}
private companion object {
private const val DefaultCropperStrokeWidthDp = 1.2f
private const val CornerSelectionAnimationDurationMs = 140L
val cropperEdges = listOf(
QuadCorner.TOP_LEFT to QuadCorner.TOP_RIGHT,
QuadCorner.TOP_RIGHT to QuadCorner.BOTTOM_RIGHT,
QuadCorner.BOTTOM_RIGHT to QuadCorner.BOTTOM_LEFT,
QuadCorner.BOTTOM_LEFT to QuadCorner.TOP_LEFT
)
}
}
@@ -0,0 +1,51 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.websitebeaver.documentscanner.utils
import androidx.activity.ComponentActivity
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* This class contains a helper function creating temporary files
*
* @constructor creates file util
*/
class FileUtil {
/**
* create a temporary file
*
* @param activity the current activity
* @param pageNumber the current document page number
*/
fun createImageFile(activity: ComponentActivity, pageNumber: Int): File {
// use current time to make file name more unique
val dateTime: String = SimpleDateFormat(
"yyyyMMdd_HHmmss",
Locale.US
).format(Date())
return File.createTempFile(
"DOCUMENT_SCAN_${pageNumber}_${dateTime}",
".jpg",
activity.cacheDir
)
}
}
@@ -0,0 +1,183 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.websitebeaver.documentscanner.utils
import android.content.ContentResolver
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import androidx.core.graphics.createBitmap
import androidx.core.net.toUri
import com.t8rin.exif.ExifInterface
import com.websitebeaver.documentscanner.extensions.distance
import com.websitebeaver.documentscanner.extensions.toOpenCVPoint
import com.websitebeaver.documentscanner.models.Quad
import org.opencv.android.Utils
import org.opencv.core.Mat
import org.opencv.core.MatOfPoint2f
import org.opencv.core.Point
import org.opencv.core.Size
import org.opencv.geometry.Geometry
import org.opencv.imgcodecs.Imgcodecs
import org.opencv.imgproc.Imgproc
import java.io.File
import kotlin.math.min
/**
* This class contains helper functions for processing images
*
* @constructor creates image util
*/
class ImageUtil {
/**
* get image matrix from file path
*
* @param filePath image is saved here
* @return image matrix
*/
private fun getImageMatrixFromFilePath(filePath: String): Mat {
// read image as matrix using OpenCV
val image: Mat = Imgcodecs.imread(filePath)
// if OpenCV fails to read the image then it's empty
if (!image.empty()) {
// convert image to RGB color space since OpenCV reads it using BGR color space
Imgproc.cvtColor(image, image, Imgproc.COLOR_BGR2RGB)
return image
}
if (!File(filePath).exists()) {
throw Throwable("File doesn't exist - $filePath")
}
if (!File(filePath).canRead()) {
throw Throwable("You don't have permission to read $filePath")
}
// try reading image without OpenCV
var imageBitmap = BitmapFactory.decodeFile(filePath)
val rotation = when (ExifInterface(filePath).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL
)) {
ExifInterface.ORIENTATION_ROTATE_90 -> 90
ExifInterface.ORIENTATION_ROTATE_180 -> 180
ExifInterface.ORIENTATION_ROTATE_270 -> 270
else -> 0
}
imageBitmap = Bitmap.createBitmap(
imageBitmap,
0,
0,
imageBitmap.width,
imageBitmap.height,
Matrix().apply { postRotate(rotation.toFloat()) },
true
)
Utils.bitmapToMat(imageBitmap, image)
return image
}
/**
* get bitmap image from file path
*
* @param filePath image is saved here
* @return image bitmap
*/
fun getImageFromFilePath(filePath: String): Bitmap {
// read image as matrix using OpenCV
val image: Mat = this.getImageMatrixFromFilePath(filePath)
// convert image matrix to bitmap
val bitmap = createBitmap(image.cols(), image.rows())
Utils.matToBitmap(image, bitmap)
return bitmap
}
/**
* take a photo with a document, crop everything out but document, and force it to display
* as a rectangle
*
* @param photoFilePath original image is saved here
* @param corners the 4 document corners
* @return bitmap with cropped and warped document
*/
fun crop(photoFilePath: String, corners: Quad): Bitmap {
// read image with OpenCV
val image = this.getImageMatrixFromFilePath(photoFilePath)
// convert top left, top right, bottom right, and bottom left document corners from
// Android points to OpenCV points
val tLC = corners.topLeftCorner.toOpenCVPoint()
val tRC = corners.topRightCorner.toOpenCVPoint()
val bRC = corners.bottomRightCorner.toOpenCVPoint()
val bLC = corners.bottomLeftCorner.toOpenCVPoint()
// Calculate the document edge distances. The user might take a skewed photo of the
// document, so the top left corner to top right corner distance might not be the same
// as the bottom left to bottom right corner. We could take an average of the 2, but
// this takes the smaller of the 2. It does the same for height.
val width = min(tLC.distance(tRC), bLC.distance(bRC))
val height = min(tLC.distance(bLC), tRC.distance(bRC))
// create empty image matrix with cropped and warped document width and height
val croppedImage = MatOfPoint2f(
Point(0.0, 0.0),
Point(width, 0.0),
Point(width, height),
Point(0.0, height),
)
// This crops the document out of the rest of the photo. Since the user might take a
// skewed photo instead of a straight on photo, the document might be rotated and
// skewed. This corrects that problem. output is an image matrix that contains the
// corrected image after this fix.
val output = Mat()
Imgproc.warpPerspective(
image,
output,
Geometry.getPerspectiveTransform(
MatOfPoint2f(tLC, tRC, bRC, bLC),
croppedImage
),
Size(width, height)
)
// convert output image matrix to bitmap
val croppedBitmap = createBitmap(output.cols(), output.rows())
Utils.matToBitmap(output, croppedBitmap)
return croppedBitmap
}
/**
* get bitmap image from file uri
*
* @param fileUriString image is saved here and starts with file:///
* @return bitmap image
*/
fun readBitmapFromFileUriString(
fileUriString: String,
contentResolver: ContentResolver
): Bitmap {
return BitmapFactory.decodeStream(
contentResolver.openInputStream(fileUriString.toUri())
)
}
}
@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ ImageToolbox is an image editor for android
~ Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
~
~ You should have received a copy of the Apache License
~ along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
-->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<set>
<objectAnimator
android:duration="@integer/button_grow_animation_duration"
android:propertyName="scaleX"
android:valueTo="@dimen/grown_button_zoom"
android:valueType="floatType" />
<objectAnimator
android:duration="@integer/button_grow_animation_duration"
android:propertyName="scaleY"
android:valueTo="@dimen/grown_button_zoom"
android:valueType="floatType" />
<objectAnimator
android:duration="@integer/button_grow_animation_duration"
android:propertyName="elevation"
android:valueTo="@dimen/grown_button_elevation"
android:valueType="floatType" />
</set>
</item>
<item android:state_pressed="false">
<set>
<objectAnimator
android:duration="@integer/button_shrink_animation_duration"
android:propertyName="scaleX"
android:valueTo="@dimen/button_zoom"
android:valueType="floatType" />
<objectAnimator
android:duration="@integer/button_shrink_animation_duration"
android:propertyName="scaleY"
android:valueTo="@dimen/button_zoom"
android:valueType="floatType" />
<objectAnimator
android:duration="@integer/button_shrink_animation_duration"
android:propertyName="elevation"
android:valueTo="@dimen/button_elevation"
android:valueType="floatType" />
</set>
</item>
</selector>
@@ -0,0 +1,27 @@
<!--
~ ImageToolbox is an image editor for android
~ Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
~
~ You should have received a copy of the Apache License
~ along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#FFFFFF"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
</vector>
@@ -0,0 +1,28 @@
<!--
~ ImageToolbox is an image editor for android
~ Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
~
~ You should have received a copy of the Apache License
~ along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:tint="#FFFFFF"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z" />
</vector>
@@ -0,0 +1,27 @@
<!--
~ ImageToolbox is an image editor for android
~ Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
~
~ You should have received a copy of the Apache License
~ along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z" />
</vector>
@@ -0,0 +1,94 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ ImageToolbox is an image editor for android
~ Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
~
~ You should have received a copy of the Apache License
~ along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
-->
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/document_scanner_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black"
android:paddingVertical="@dimen/image_crop_view_vertical_padding"
tools:context=".DocumentScannerActivity">
<com.websitebeaver.documentscanner.ui.ImageCropView
android:id="@+id/image_view"
android:layout_width="match_parent"
android:layout_height="@dimen/image_crop_view_initial_height"
android:contentDescription="@string/image_with_cropper" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/image_view"
android:layout_alignParentBottom="true"
android:baselineAligned="false"
android:gravity="center"
android:orientation="horizontal"
android:paddingHorizontal="@dimen/buttons_container_horizontal_padding"
android:paddingBottom="@dimen/buttons_container_bottom_padding">
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_weight="1">
<com.websitebeaver.documentscanner.ui.CircleButton
android:id="@+id/retake_photo_button"
android:layout_width="@dimen/small_button_diameter"
android:layout_height="@dimen/small_button_diameter"
android:layout_gravity="center"
android:background="@android:color/transparent"
android:src="@drawable/ic_baseline_arrow_back_24"
android:stateListAnimator="@animator/button_grow_animation" />
</FrameLayout>
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_weight="1">
<com.websitebeaver.documentscanner.ui.DoneButton
android:id="@+id/complete_document_scan_button"
android:layout_width="@dimen/large_button_diameter"
android:layout_height="@dimen/large_button_diameter"
android:layout_gravity="center"
android:background="@android:color/transparent"
android:src="@drawable/ic_baseline_check_24"
android:stateListAnimator="@animator/button_grow_animation" />
</FrameLayout>
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_weight="1">
<com.websitebeaver.documentscanner.ui.CircleButton
android:id="@+id/new_photo_button"
android:layout_width="@dimen/small_button_diameter"
android:layout_height="@dimen/small_button_diameter"
android:layout_gravity="center"
android:background="@android:color/transparent"
android:src="@drawable/ic_baseline_add_24"
android:stateListAnimator="@animator/button_grow_animation" />
</FrameLayout>
</LinearLayout>
</RelativeLayout>
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ ImageToolbox is an image editor for android
~ Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
~
~ You should have received a copy of the Apache License
~ along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
-->
<resources>
<color name="black">#000000</color>
<color name="done_button_inner_circle_color">#D0E4FF</color>
</resources>
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ ImageToolbox is an image editor for android
~ Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
~
~ You should have received a copy of the Apache License
~ along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
-->
<resources>
<dimen name="button_elevation">0dp</dimen>
<dimen name="button_zoom">1</dimen>
<dimen name="buttons_container_bottom_padding">0.1dp</dimen>
<dimen name="buttons_container_horizontal_padding">5dp</dimen>
<dimen name="buttons_container_min_height">200dp</dimen>
<dimen name="cropper_corner_radius">25px</dimen>
<dimen name="cropper_selected_corner_radius_magnification">4px</dimen>
<dimen name="cropper_selected_corner_background_magnification">5px</dimen>
<dimen name="grown_button_elevation">1dp</dimen>
<dimen name="grown_button_zoom">1.07</dimen>
<dimen name="image_crop_view_initial_height">0dp</dimen>
<dimen name="image_crop_view_vertical_padding">15.4dp</dimen>
<dimen name="large_button_diameter">84dp</dimen>
<dimen name="large_button_ring_thickness">4dp</dimen>
<dimen name="large_button_outer_ring_offset">8dp</dimen>
<dimen name="small_button_corner_radius">16dp</dimen>
<dimen name="small_button_diameter">52dp</dimen>
<dimen name="small_button_ring_thickness">2dp</dimen>
</resources>
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ ImageToolbox is an image editor for android
~ Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
~
~ You should have received a copy of the Apache License
~ along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
-->
<resources>
<integer name="button_grow_animation_duration">100</integer>
<integer name="button_shrink_animation_duration">150</integer>
</resources>
@@ -0,0 +1,20 @@
<!--
~ ImageToolbox is an image editor for android
~ Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
~
~ You should have received a copy of the Apache License
~ along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
-->
<resources>
<string name="image_with_cropper">Original Image With Cropper</string>
</resources>
@@ -0,0 +1,26 @@
<!--
~ ImageToolbox is an image editor for android
~ Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
~
~ You should have received a copy of the Apache License
~ along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
-->
<resources>
<style name="Theme.FullScreen" parent="@style/Theme.AppCompat.Light.NoActionBar">
<item name="android:windowFullscreen">true</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:layout_width">fill_parent</item>
<item name="android:layout_height">fill_parent</item>
</style>
</resources>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ ImageToolbox is an image editor for android
~ Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
~
~ You should have received a copy of the Apache License
~ along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
-->
<paths>
<external-files-path
name="images"
path="Pictures" />
</paths>