chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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>
|
||||
+211
@@ -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())
|
||||
}
|
||||
}
|
||||
+591
@@ -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()
|
||||
}
|
||||
}
|
||||
+22
@@ -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()
|
||||
+30
@@ -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
|
||||
}
|
||||
}
|
||||
+37
@@ -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"
|
||||
}
|
||||
}
|
||||
+28
@@ -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"
|
||||
}
|
||||
}
|
||||
+25
@@ -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
|
||||
}
|
||||
+48
@@ -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()
|
||||
+65
@@ -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
|
||||
)
|
||||
}
|
||||
+146
@@ -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)
|
||||
}
|
||||
+35
@@ -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
|
||||
}
|
||||
}
|
||||
+92
@@ -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))
|
||||
}
|
||||
+35
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
+98
@@ -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)
|
||||
}
|
||||
}
|
||||
+524
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+183
@@ -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>
|
||||
Reference in New Issue
Block a user