diff --git a/CHANGELOG.md b/CHANGELOG.md index c18c2d98..9751b452 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- Fixed broken bucket fill when zoomed in ([#20]) + ## [1.4.0] - 2026-01-30 ### Added - Added support for custom fonts @@ -54,6 +57,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial release. +[#20]: https://github.com/FossifyOrg/Paint/issues/20 [#76]: https://github.com/FossifyOrg/Paint/issues/76 [#106]: https://github.com/FossifyOrg/Paint/issues/106 [#119]: https://github.com/FossifyOrg/Paint/issues/119 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c464dc63..b962fd3c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -29,7 +29,10 @@ base { android { compileSdk = project.libs.versions.app.build.compileSDKVersion.get().toInt() + testBuildType = providers.gradleProperty("testBuildType").orElse("debug").get() + defaultConfig { + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" applicationId = project.property("APP_ID").toString() minSdk = project.libs.versions.app.build.minimumSDK.get().toInt() targetSdk = project.libs.versions.app.build.targetSDK.get().toInt() @@ -90,6 +93,10 @@ android { getByName("main").java.directories.add("src/main/kotlin") } + testOptions { + unitTests.isIncludeAndroidResources = true + } + compileOptions { val currentJavaVersionFromLibs = JavaVersion.valueOf(libs.versions.app.build.javaVersion.get()) @@ -137,6 +144,11 @@ detekt { } dependencies { + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.junit) + testImplementation(libs.junit) + testImplementation(libs.robolectric) + implementation(libs.clipper2) implementation(libs.fossify.commons) implementation(libs.androidx.print) detektPlugins(libs.compose.detekt) diff --git a/app/src/androidTest/kotlin/org/fossify/paint/CanvasFillDeviceTest.kt b/app/src/androidTest/kotlin/org/fossify/paint/CanvasFillDeviceTest.kt new file mode 100644 index 00000000..e2209c16 --- /dev/null +++ b/app/src/androidTest/kotlin/org/fossify/paint/CanvasFillDeviceTest.kt @@ -0,0 +1,179 @@ +package org.fossify.paint + +import android.graphics.Color +import android.graphics.PointF +import android.graphics.RectF +import android.os.SystemClock +import android.view.MotionEvent +import android.view.View +import android.view.WindowManager +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.fossify.paint.activities.MainActivity +import org.fossify.paint.models.MyPath +import org.fossify.paint.models.PaintOptions +import org.fossify.paint.views.MyCanvas +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class CanvasFillDeviceTest { + @get:Rule + val scenario = ActivityScenarioRule(MainActivity::class.java) + + private val instrumentation = InstrumentationRegistry.getInstrumentation() + + @Before + fun resetDrawing() { + instrumentation.waitForIdleSync() + scenario.scenario.onActivity { it.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } + withCanvas { + it.clearCanvas() + it.updateBackgroundColor(Color.WHITE) + it.setViewportBounds(RectF(0f, 0f, it.width.toFloat(), it.height.toFloat())) + it.toggleBucketFill(true) + it.setColor(Color.RED) + } + } + + @Test + fun blankInfiniteFillIsVisibleOnHardwareCanvas() { + withCanvas { canvas -> tap(canvas, canvas.width / 2f, canvas.height / 2f) } + awaitOperations(1) + instrumentation.waitForIdleSync() + SystemClock.sleep(250) + val screenshot = instrumentation.uiAutomation.takeScreenshot() + withCanvas { canvas -> + val location = IntArray(2) + canvas.getLocationOnScreen(location) + assertEquals( + Color.RED, + screenshot.getPixel(location[0] + canvas.width / 2, location[1] + canvas.height / 2) + ) + } + screenshot.recycle() + } + + @Test + fun outsideFillAndItsHoleRemainVisibleAfterPanningAndZooming() { + var width = 0f + var height = 0f + withCanvas { canvas -> + assertTrue(canvas.isHardwareAccelerated) + width = canvas.width.toFloat() + height = canvas.height.toFloat() + canvas.addOperation( + box(width * 0.3f, height * 0.3f, width * 0.7f, height * 0.7f), + PaintOptions(strokeWidth = 10f) + ) + tap(canvas, width * 0.1f, height * 0.5f) + } + awaitOperations(2) + for (viewport in listOf( + RectF(0f, 0f, width, height), + RectF(-width, -height, width * 2, height * 2), + RectF(100000f, 100000f, 100000f + width, 100000f + height) + )) { + withCanvas { it.setViewportBounds(viewport) } + instrumentation.waitForIdleSync() + SystemClock.sleep(250) + val screenshot = instrumentation.uiAutomation.takeScreenshot() + withCanvas { canvas -> + val location = IntArray(2) + canvas.getLocationOnScreen(location) + val expected = canvas.getBitmap() + for (fraction in listOf(0.1f, 0.5f, 0.9f)) { + val x = (width * fraction).toInt() + val y = (height * 0.5f).toInt() + assertEquals(expected.getPixel(x, y), screenshot.getPixel(location[0] + x, location[1] + y)) + } + expected.recycle() + } + screenshot.recycle() + } + } + + @Test + fun toolbarBucketFillsEnclosedRegionThroughRealTouchDispatch() { + val location = IntArray(2) + lateinit var center: PointF + lateinit var outside: PointF + withCanvas { canvas -> + canvas.toggleBucketFill(false) + canvas.getLocationOnScreen(location) + center = PointF(canvas.width * 0.5f, canvas.height * 0.45f) + outside = PointF(canvas.width * 0.1f, center.y) + val left = canvas.width * 0.3f + val right = canvas.width * 0.7f + val top = canvas.height * 0.28f + val bottom = canvas.height * 0.62f + canvas.addOperation(box(left, top, right, bottom), PaintOptions(strokeWidth = 10f)) + } + val start = SystemClock.uptimeMillis() + fun send(action: Int, point: PointF) { + MotionEvent.obtain( + start, SystemClock.uptimeMillis(), action, location[0] + point.x, location[1] + point.y, 0 + ).also { + instrumentation.sendPointerSync(it) + it.recycle() + } + } + scenario.scenario.onActivity { activity -> + activity.findViewById(R.id.my_canvas).setColor(Color.RED) + activity.findViewById(R.id.bucket_fill).performClick() + } + send(MotionEvent.ACTION_DOWN, center) + send(MotionEvent.ACTION_UP, center) + awaitOperations(2) + instrumentation.waitForIdleSync() + SystemClock.sleep(250) + val screenshot = instrumentation.uiAutomation.takeScreenshot() + assertEquals(Color.RED, screenshot.getPixel(location[0] + center.x.toInt(), location[1] + center.y.toInt())) + assertEquals(Color.WHITE, screenshot.getPixel(location[0] + outside.x.toInt(), location[1] + outside.y.toInt())) + screenshot.recycle() + } + + private fun withCanvas(block: (MyCanvas) -> Unit) { + scenario.scenario.onActivity { block(it.findViewById(R.id.my_canvas)) } + } + + private fun awaitOperations(count: Int) { + var actual = 0 + var pending = true + val deadline = SystemClock.uptimeMillis() + 10000 + do { + instrumentation.waitForIdleSync() + withCanvas { + actual = it.getPathsMap().size + pending = it.hasPendingEdits() + } + if (actual == count && !pending) return + SystemClock.sleep(10) + } while (SystemClock.uptimeMillis() < deadline) + assertEquals(count, actual) + assertTrue(!pending) + } + + private fun box(left: Float, top: Float, right: Float, bottom: Float) = MyPath().apply { + moveTo(left, top) + lineTo(right, top) + lineTo(right, bottom) + lineTo(left, bottom) + close() + } + + private fun tap(canvas: MyCanvas, x: Float, y: Float) { + val time = SystemClock.uptimeMillis() + for (action in listOf(MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP)) { + MotionEvent.obtain(time, time + action, action, x, y, 0).also { + canvas.onTouchEvent(it) + it.recycle() + } + } + } +} diff --git a/app/src/main/kotlin/org/fossify/paint/actions/Close.kt b/app/src/main/kotlin/org/fossify/paint/actions/Close.kt new file mode 100644 index 00000000..043a07a0 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/paint/actions/Close.kt @@ -0,0 +1,14 @@ +package org.fossify.paint.actions + +import android.graphics.Path +import java.io.Writer + +class Close : Action { + override fun perform(path: Path) { + path.close() + } + + override fun perform(writer: Writer) { + writer.write("Z") + } +} diff --git a/app/src/main/kotlin/org/fossify/paint/activities/MainActivity.kt b/app/src/main/kotlin/org/fossify/paint/activities/MainActivity.kt index 9702d4d3..021c07d3 100644 --- a/app/src/main/kotlin/org/fossify/paint/activities/MainActivity.kt +++ b/app/src/main/kotlin/org/fossify/paint/activities/MainActivity.kt @@ -1,6 +1,5 @@ package org.fossify.paint.activities -import android.app.Activity import android.content.ActivityNotFoundException import android.content.Intent import android.content.pm.ActivityInfo @@ -63,7 +62,9 @@ import org.fossify.paint.interfaces.CanvasListener import org.fossify.paint.models.Svg import java.io.ByteArrayOutputStream import java.io.File +import java.io.IOException import java.io.OutputStream +import kotlin.math.max class MainActivity : SimpleActivity(), CanvasListener { companion object { @@ -242,7 +243,8 @@ class MainActivity : SimpleActivity(), CanvasListener { } override fun onBackPressedCompat(): Boolean { - val hasUnsavedChanges = savedPathsHash != binding.myCanvas.getDrawingHashCode() + val hasUnsavedChanges = binding.myCanvas.hasPendingEdits() || + savedPathsHash != binding.myCanvas.getDrawingHashCode() if (hasUnsavedChanges && System.currentTimeMillis() - lastSavePromptTS > SAVE_DISCARD_PROMPT_INTERVAL) { lastSavePromptTS = System.currentTimeMillis() ConfirmationAdvancedDialog( @@ -291,16 +293,21 @@ class MainActivity : SimpleActivity(), CanvasListener { override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) { super.onActivityResult(requestCode, resultCode, resultData) - if (requestCode == PICK_IMAGE_INTENT && resultCode == Activity.RESULT_OK && resultData != null && resultData.data != null) { - tryOpenUri(resultData.data!!, resultData) - } else if (requestCode == SAVE_IMAGE_INTENT && resultCode == Activity.RESULT_OK && resultData != null && resultData.data != null) { - val outputStream = contentResolver.openOutputStream(resultData.data!!) - if (defaultExtension == SVG) { - Svg.saveToOutputStream(this, outputStream, binding.myCanvas) - } else { - saveToOutputStream(outputStream, defaultExtension.getCompressionFormat(), false) + if (resultCode != RESULT_OK) return + val uri = resultData?.data ?: return + + when (requestCode) { + PICK_IMAGE_INTENT -> tryOpenUri(uri, resultData) + SAVE_IMAGE_INTENT -> { + val outputStream = contentResolver.openOutputStream(uri) + if (defaultExtension == SVG) { + Svg.saveToOutputStream(this, outputStream, binding.myCanvas) { savedPathsHash = it } + } else { + saveToOutputStream(outputStream, defaultExtension.getCompressionFormat(), false) { + savedPathsHash = it + } + } } - savedPathsHash = binding.myCanvas.getDrawingHashCode() } } @@ -383,7 +390,6 @@ class MainActivity : SimpleActivity(), CanvasListener { private fun openPath(path: String) = when { path.endsWith(".svg") -> { - binding.myCanvas.mBackgroundBitmap = null Svg.loadSvg(this, File(path), binding.myCanvas) defaultExtension = SVG true @@ -408,7 +414,6 @@ class MainActivity : SimpleActivity(), CanvasListener { ?: intent.type ?: contentResolver.getType(uri) return when (type) { "svg", "image/svg+xml" -> { - binding.myCanvas.mBackgroundBitmap = null Svg.loadSvg(this, uri, binding.myCanvas) defaultExtension = SVG true @@ -545,7 +550,8 @@ class MainActivity : SimpleActivity(), CanvasListener { private fun saveToOutputStream( outputStream: OutputStream?, format: Bitmap.CompressFormat, - finishAfterSaving: Boolean + finishAfterSaving: Boolean, + onSaved: (Long) -> Unit = {} ) { if (outputStream == null) { toast(R.string.unknown_error_occurred) @@ -558,13 +564,21 @@ class MainActivity : SimpleActivity(), CanvasListener { 70 } - outputStream.use { - binding.myCanvas.getBitmap().compress(format, quality, it) - } - - if (finishAfterSaving) { - setResult(Activity.RESULT_OK) - finish() + binding.myCanvas.whenDrawingReady { + try { + outputStream.use { + if (!binding.myCanvas.getBitmap(false).compress(format, quality, it)) { + throw IOException("Failed to encode drawing") + } + } + onSaved(binding.myCanvas.getDrawingHashCode()) + if (finishAfterSaving) { + setResult(RESULT_OK) + finish() + } + } catch (e: IOException) { + showErrorToast(e) + } } } @@ -612,7 +626,6 @@ class MainActivity : SimpleActivity(), CanvasListener { defaultExtension = defaultExtension, hidePath = false ) { fullPath, filename, extension -> - savedPathsHash = binding.myCanvas.getDrawingHashCode() saveFile(fullPath) defaultPath = fullPath.getParentPath() defaultFilename = filename @@ -623,37 +636,38 @@ class MainActivity : SimpleActivity(), CanvasListener { } private fun saveFile(path: String) { + val onSaved: (Long) -> Unit = { + savedPathsHash = it + rescanPaths(arrayListOf(path)) {} + } when (path.getFilenameExtension()) { - SVG -> Svg.saveSvg(this, path, binding.myCanvas) - else -> saveImageFile(path) + SVG -> Svg.saveSvg(this, path, binding.myCanvas, onSaved) + else -> saveImageFile(path, onSaved) } - rescanPaths(arrayListOf(path)) {} } - private fun saveImageFile(path: String) { + private fun saveImageFile(path: String, onSaved: (Long) -> Unit) { val fileDirItem = FileDirItem(path, path.getFilenameFromPath()) getFileOutputStream(fileDirItem, true) { if (it != null) { - writeToOutputStream(path, it) - toast(R.string.file_saved) + saveToOutputStream(it, path.getCompressionFormat(), false) { hash -> + onSaved(hash) + toast(R.string.file_saved) + } } else { toast(R.string.unknown_error_occurred) } } } - private fun writeToOutputStream(path: String, out: OutputStream) { - out.use { - binding.myCanvas.getBitmap().compress(path.getCompressionFormat(), 70, out) - } - } - private fun shareImage() { - getImagePath(binding.myCanvas.getBitmap()) { - if (it != null) { - sharePathIntent(it, BuildConfig.APPLICATION_ID) - } else { - toast(R.string.unknown_error_occurred) + binding.myCanvas.whenDrawingReady { + getImagePath(binding.myCanvas.getBitmap(false)) { + if (it != null) { + sharePathIntent(it, BuildConfig.APPLICATION_ID) + } else { + toast(R.string.unknown_error_occurred) + } } } } @@ -718,6 +732,12 @@ class MainActivity : SimpleActivity(), CanvasListener { } fun setBackgroundColor(pickedColor: Int) { + updateBackgroundControls(pickedColor) + binding.myCanvas.updateBackgroundColor(pickedColor) + defaultExtension = PNG + } + + fun updateBackgroundControls(pickedColor: Int) { if (isEyeDropperOn) { eyeDropperClicked() } @@ -733,8 +753,6 @@ class MainActivity : SimpleActivity(), CanvasListener { strokeWidthBar.setColors(0, contrastColor, 0) } - myCanvas.updateBackgroundColor(pickedColor) - defaultExtension = PNG getBrushPreviewView().setStroke(getBrushStrokeSize(), contrastColor) } } @@ -789,7 +807,7 @@ class MainActivity : SimpleActivity(), CanvasListener { private fun updateBrushSize() { binding.apply { myCanvas.setBrushSize(brushSize) - val scale = Math.max(0.03f, brushSize / 100f) + val scale = max(0.03f, brushSize / 100f) strokeWidthPreview.scaleX = scale strokeWidthPreview.scaleY = scale } @@ -798,10 +816,12 @@ class MainActivity : SimpleActivity(), CanvasListener { private fun printImage() { val printHelper = PrintHelper(this) printHelper.scaleMode = PrintHelper.SCALE_MODE_FIT - try { - printHelper.printBitmap(getString(R.string.app_name), binding.myCanvas.getBitmap()) - } catch (e: Exception) { - showErrorToast(e) + binding.myCanvas.whenDrawingReady { + try { + printHelper.printBitmap(getString(R.string.app_name), binding.myCanvas.getBitmap(false)) + } catch (e: Exception) { + showErrorToast(e) + } } } diff --git a/app/src/main/kotlin/org/fossify/paint/extensions/Bitmap.kt b/app/src/main/kotlin/org/fossify/paint/extensions/Bitmap.kt deleted file mode 100644 index a725d603..00000000 --- a/app/src/main/kotlin/org/fossify/paint/extensions/Bitmap.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.fossify.paint.extensions - -import android.graphics.Bitmap -import org.fossify.paint.helpers.VectorFloodFiller -import org.fossify.paint.models.MyPath - -fun Bitmap.vectorFloodFill(color: Int, x: Int, y: Int, tolerance: Int): MyPath { - val floodFiller = VectorFloodFiller(this).apply { - fillColor = color - this.tolerance = tolerance - } - - floodFiller.floodFill(x, y) - return floodFiller.path -} diff --git a/app/src/main/kotlin/org/fossify/paint/extensions/View.kt b/app/src/main/kotlin/org/fossify/paint/extensions/View.kt deleted file mode 100644 index a63ffe50..00000000 --- a/app/src/main/kotlin/org/fossify/paint/extensions/View.kt +++ /dev/null @@ -1,16 +0,0 @@ -package org.fossify.paint.extensions - -import android.graphics.Rect -import android.view.View - -val View.boundingBox: Rect - get() { - val rect = Rect() - getDrawingRect(rect) - val location = IntArray(2) - getLocationOnScreen(location) - rect.offset(location[0], location[1]) - return rect - } - -fun View.contains(x: Int, y: Int) = boundingBox.contains(x, y) diff --git a/app/src/main/kotlin/org/fossify/paint/helpers/BucketFill.kt b/app/src/main/kotlin/org/fossify/paint/helpers/BucketFill.kt new file mode 100644 index 00000000..326738c8 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/paint/helpers/BucketFill.kt @@ -0,0 +1,167 @@ +package org.fossify.paint.helpers + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.graphics.PointF +import androidx.core.graphics.createBitmap +import androidx.core.graphics.get +import com.github.micycle1.clipper2.core.Paths64 +import com.github.micycle1.clipper2.core.Point64 +import org.fossify.paint.models.MyPath +import org.fossify.paint.models.PaintOptions +import kotlin.math.abs + +private const val PIXEL_CENTER = 0.5f +private const val BITMAP_COLOR_TOLERANCE = 24 + +class BucketFill( + private val backgroundColor: Int, + private val operations: List>, + private val backgroundBitmap: Bitmap? = null, + private val bitmapOrigin: PointF = PointF() +) { + fun fill(x: Float, y: Float, color: Int): MyPath? { + val targetColor = sampleColor(x, y) + if (targetColor == color) return null + + var region = bitmapRegion(targetColor) + val pending = Paths64() + var pendingAdd = false + fun flush() { + if (pending.isNotEmpty()) { + region = region.paint(pending, false, pendingAdd) + pending.clear() + } + } + val paint = Paint() + for ((path, options) in operations) { + options.applyTo(paint, backgroundColor) + val shape = operationShape(path, options, paint) + val outline = FillRegion.fromPath(shape).paths + val add = sameColor(targetColor, paint.color) + if (shape.isInverseFillType) { + flush() + region = region.paint(outline, true, add) + } else { + if (pendingAdd != add) flush() + pendingAdd = add + pending.addAll(outline) + } + } + flush() + return region.connectedAt(Point64(x * COORDINATE_SCALE, y * COORDINATE_SCALE)) + } + + private fun sampleColor(x: Float, y: Float): Int { + val bitmap = createBitmap(1, 1) + val canvas = Canvas(bitmap) + canvas.drawColor(backgroundColor) + canvas.translate(PIXEL_CENTER - x, PIXEL_CENTER - y) + backgroundBitmap?.let { canvas.drawBitmap(it, bitmapOrigin.x, bitmapOrigin.y, null) } + val paint = Paint().apply { isAntiAlias = false } + for ((path, options) in operations) { + options.applyTo(paint, backgroundColor) + val shape = operationShape(path, options, paint) + paint.style = Paint.Style.FILL + canvas.drawPath(shape, paint) + } + val color = bitmap[0, 0] + bitmap.recycle() + return color + } + + private fun operationShape(path: Path, options: PaintOptions, paint: Paint) = Path().apply { + if (options.isFill) set(path) else paint.getFillPath(path, this) + } + + private fun bitmapRegion(targetColor: Int): FillRegion { + val image = backgroundBitmap ?: return FillRegion(Paths64(), sameColor(targetColor, backgroundColor)) + val inverse = sameColor(targetColor, backgroundColor, BITMAP_COLOR_TOLERANCE) + val bitmap = createBitmap(image.width, image.height) + Canvas(bitmap).apply { + drawColor(backgroundColor) + drawBitmap(image, 0f, 0f, null) + } + val pixels = IntArray(image.width * image.height) + bitmap.getPixels(pixels, 0, image.width, 0, 0, image.width, image.height) + bitmap.recycle() + + val included = BooleanArray(pixels.size) { + sameColor(pixels[it], targetColor, BITMAP_COLOR_TOLERANCE) != inverse + } + val edges = bitmapEdges(included, image.width, image.height) + val outline = bitmapOutline(edges, image.width + 1, bitmapOrigin, inverse) + return FillRegion(polygonPaths(outline), inverse) + } + + private fun sameColor(first: Int, second: Int, tolerance: Int = 1) = + abs(Color.red(first) - Color.red(second)) <= tolerance && + abs(Color.green(first) - Color.green(second)) <= tolerance && + abs(Color.blue(first) - Color.blue(second)) <= tolerance +} + +private const val EDGE_RIGHT = 0 +private const val EDGE_DOWN = 1 +private const val EDGE_LEFT = 2 +private const val EDGE_UP = 3 + +private fun bitmapEdges(included: BooleanArray, width: Int, height: Int): ByteArray { + val stride = width + 1 + val edges = ByteArray(stride * (height + 1)) + fun add(vertex: Int, direction: Int) { + edges[vertex] = (edges[vertex].toInt() or (1 shl direction)).toByte() + } + for (y in 0 until height) { + for (x in 0 until width) { + val pixel = y * width + x + if (!included[pixel]) continue + val vertex = y * stride + x + if (y == 0 || !included[pixel - width]) add(vertex, EDGE_RIGHT) + if (x == width - 1 || !included[pixel + 1]) add(vertex + 1, EDGE_DOWN) + if (y == height - 1 || !included[pixel + width]) add(vertex + stride + 1, EDGE_LEFT) + if (x == 0 || !included[pixel - 1]) add(vertex + stride, EDGE_UP) + } + } + return edges +} + +private fun bitmapOutline(edges: ByteArray, stride: Int, origin: PointF, inverse: Boolean): Path { + val outline = Path() + val steps = intArrayOf(1, stride, -1, -stride) + val turn = if (inverse) -1 else 1 + for (start in edges.indices) { + while (edges[start].toInt() != 0) { + traceBitmapContour(outline, edges, steps, origin, start, turn) + } + } + return outline +} + +private fun traceBitmapContour( + outline: Path, edges: ByteArray, steps: IntArray, origin: PointF, start: Int, turn: Int +) { + val stride = steps[EDGE_DOWN] + var vertex = start + var direction = Integer.numberOfTrailingZeros(edges[start].toInt()) + outline.moveTo(origin.x + vertex % stride, origin.y + vertex / stride) + do { + edges[vertex] = (edges[vertex].toInt() and (1 shl direction).inv()).toByte() + val next = vertex + steps[direction] + if (next == start) { + outline.close() + break + } + val outgoing = edges[next].toInt() + check(outgoing != 0) { "Open bitmap fill contour at vertex $next" } + var nextDirection = (direction + turn + steps.size) % steps.size + while (outgoing and (1 shl nextDirection) == 0) { + nextDirection = (nextDirection - turn + steps.size) % steps.size + } + if (nextDirection != direction) outline.lineTo(origin.x + next % stride, origin.y + next / stride) + vertex = next + direction = nextDirection + } while (true) +} diff --git a/app/src/main/kotlin/org/fossify/paint/helpers/DrawingRegions.kt b/app/src/main/kotlin/org/fossify/paint/helpers/DrawingRegions.kt new file mode 100644 index 00000000..582246ee --- /dev/null +++ b/app/src/main/kotlin/org/fossify/paint/helpers/DrawingRegions.kt @@ -0,0 +1,178 @@ +package org.fossify.paint.helpers + +import android.graphics.Bitmap +import android.graphics.BitmapShader +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.ColorMatrixColorFilter +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Path +import android.graphics.PointF +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.graphics.RectF +import android.graphics.Shader +import androidx.core.graphics.createBitmap +import com.github.micycle1.clipper2.core.Paths64 +import org.fossify.paint.models.MyPath +import org.fossify.paint.models.PaintOptions + +internal class DrawingRegions( + backgroundColor: Int, + operations: List>, + bitmapBounds: RectF? = null, + previous: DrawingRegions? = null +) { + private val regions: Map + val paths: Map + + init { + val regions = previous?.regions?.toMutableMap() + ?: linkedMapOf(backgroundColor to FillRegion(Paths64(), true)) + val pending = Paths64() + var pendingColor = backgroundColor + fun apply(outline: FillRegion, color: Int?) { + regions.putIfAbsent(color, FillRegion(Paths64(), false)) + for (entry in regions.entries) { + entry.setValue(entry.value.paint(outline.paths, outline.inverse, entry.key == color)) + } + } + fun flush() { + if (pending.isNotEmpty()) apply(FillRegion(pending, false), pendingColor) + pending.clear() + } + if (previous == null && bitmapBounds != null) { + val bounds = Path().apply { addRect(bitmapBounds, Path.Direction.CW) } + apply(FillRegion.fromPath(bounds), null) + } + for ((path, options) in operations) { + val outline = FillRegion.fromOperation(path, options, backgroundColor) + val color = if (options.isEraser) backgroundColor else options.color + if (outline.inverse) { + flush() + apply(outline, color) + } else { + if (pendingColor != color) flush() + pendingColor = color + pending.addAll(outline.paths) + } + } + flush() + this.regions = regions + paths = regions.filterValues { it.inverse || it.paths.isNotEmpty() }.mapValues { it.value.toPath() } + } + + fun draw(canvas: Canvas, bitmap: Bitmap?, origin: PointF, backgroundColor: Int) { + val layer = canvas.saveLayer(null, OPAQUE_COVERAGE) + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { xfermode = ADD_COVERAGE } + for ((color, path) in paths) { + paint.color = color ?: backgroundColor + paint.shader = if (color == null && bitmap != null) { + BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP).apply { + setLocalMatrix(Matrix().apply { setTranslate(origin.x, origin.y) }) + } + } else null + canvas.drawPath(path, paint) + } + canvas.restoreToCount(layer) + } + + companion object { + private val ADD_COVERAGE = PorterDuffXfermode(PorterDuff.Mode.ADD) + // Normalize shared edge coverage before compositing the opaque canvas. + private val OPAQUE_COVERAGE = Paint().apply { + colorFilter = ColorMatrixColorFilter(floatArrayOf( + 1f, 0f, 0f, 0f, 0f, + 0f, 1f, 0f, 0f, 0f, + 0f, 0f, 1f, 0f, 0f, + 0f, 0f, 0f, 0f, 255f + )) + } + } +} + +internal class DrawingRenderer { + data class Snapshot( + val operations: List>, + val backgroundColor: Int, + val bitmap: Bitmap?, + val origin: PointF + ) { + val fillEnd = operations.indexOfLast { it.second.isFill } + 1 + + fun hasSameBackground(other: Snapshot) = backgroundColor == other.backgroundColor && + bitmap === other.bitmap && origin == other.origin + } + + data class Drawing( + val snapshot: Snapshot, + val regions: DrawingRegions? = null, + val opaqueBitmap: Bitmap? = null + ) + + var drawing = Drawing(Snapshot(emptyList(), Color.WHITE, null, PointF())) + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + + fun update(snapshot: Snapshot): Boolean { + val previous = drawing.snapshot + when { + previous == snapshot -> Unit + snapshot.fillEnd == 0 -> drawing = Drawing(snapshot) + snapshot.hasSameBackground(previous) && snapshot.fillEnd == previous.fillEnd && + snapshot.operations.take(snapshot.fillEnd) == previous.operations.take(previous.fillEnd) -> { + drawing = drawing.copy(snapshot = snapshot) + } + else -> return false + } + return true + } + + fun draw(canvas: Canvas) { + val snapshot = drawing.snapshot + canvas.drawColor(snapshot.backgroundColor) + val regions = drawing.regions + if (regions != null) { + regions.draw(canvas, drawing.opaqueBitmap, snapshot.origin, snapshot.backgroundColor) + } else { + snapshot.bitmap?.let { canvas.drawBitmap(it, snapshot.origin.x, snapshot.origin.y, null) } + } + val start = if (regions == null) 0 else snapshot.fillEnd + for (index in start until snapshot.operations.size) { + val (path, options) = snapshot.operations[index] + options.applyTo(paint, snapshot.backgroundColor) + canvas.drawPath(path, paint) + } + } + + companion object { + fun prepare(snapshot: Snapshot, previous: Drawing): Drawing { + if (snapshot.fillEnd == 0) return Drawing(snapshot) + val bitmap = snapshot.bitmap + val origin = snapshot.origin + val opaqueBitmap = if (previous.snapshot.bitmap === bitmap && + previous.snapshot.backgroundColor == snapshot.backgroundColor && previous.opaqueBitmap != null) { + previous.opaqueBitmap + } else { + bitmap?.let { image -> + createBitmap(image.width, image.height).also { + val canvas = Canvas(it) + canvas.drawColor(snapshot.backgroundColor) + canvas.drawBitmap(image, 0f, 0f, null) + } + } + } + val bounds = bitmap?.let { RectF(origin.x, origin.y, origin.x + it.width, origin.y + it.height) } + val prefix = snapshot.operations.take(snapshot.fillEnd) + val previousPrefix = previous.snapshot.operations.take(previous.snapshot.fillEnd) + val canExtend = previous.regions != null && snapshot.hasSameBackground(previous.snapshot) && + prefix.size >= previousPrefix.size && prefix.take(previousPrefix.size) == previousPrefix + val regions = if (canExtend) { + DrawingRegions(snapshot.backgroundColor, prefix.drop(previousPrefix.size), bounds, previous.regions) + } else { + DrawingRegions(snapshot.backgroundColor, prefix, bounds) + } + return Drawing(snapshot, regions, opaqueBitmap) + } + } +} diff --git a/app/src/main/kotlin/org/fossify/paint/helpers/FillRegion.kt b/app/src/main/kotlin/org/fossify/paint/helpers/FillRegion.kt new file mode 100644 index 00000000..a1824fa9 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/paint/helpers/FillRegion.kt @@ -0,0 +1,120 @@ +package org.fossify.paint.helpers + +import android.graphics.Paint +import android.graphics.Path +import android.graphics.PathMeasure +import com.github.micycle1.clipper2.Clipper +import com.github.micycle1.clipper2.core.ClipType +import com.github.micycle1.clipper2.core.FillRule +import com.github.micycle1.clipper2.core.Path64 +import com.github.micycle1.clipper2.core.Paths64 +import com.github.micycle1.clipper2.core.Point64 +import com.github.micycle1.clipper2.engine.Clipper64 +import com.github.micycle1.clipper2.engine.PointInPolygonResult +import com.github.micycle1.clipper2.engine.PolyPath64 +import com.github.micycle1.clipper2.engine.PolyTree64 +import org.fossify.paint.models.MyPath +import org.fossify.paint.models.PaintOptions + +private const val CURVE_APPROXIMATION_ERROR = 0.025f +private const val APPROXIMATION_POINT_SIZE = 3 +internal const val COORDINATE_SCALE = 1000.0 + +internal class FillRegion(val paths: Paths64, val inverse: Boolean) { + fun paint(outline: Paths64, outlineInverse: Boolean, add: Boolean): FillRegion { + val result = when { + !outlineInverse -> clip(if (add != inverse) ClipType.Union else ClipType.Difference, paths, outline) + inverse == add -> clip(ClipType.Intersection, paths, outline) + else -> clip(ClipType.Difference, outline, paths) + } + return FillRegion(result, if (outlineInverse) add else inverse) + } + + fun toPath() = MyPath().apply { + isInverseFill = inverse + for (contour in paths) addContour(contour) + } + + fun connectedAt(point: Point64): MyPath? { + val tree = PolyTree64() + val engine = Clipper64() + if (inverse) { + val frame = Clipper.getBounds(paths).apply { left--; top--; right++; bottom++ } + engine.addSubject(frame.asPath()) + engine.addClip(paths) + } else { + engine.addSubject(paths) + } + check(engine.execute(if (inverse) ClipType.Difference else ClipType.Union, FillRule.NonZero, tree)) { + "Failed to resolve fill component: ${paths.size} contours, inverse=$inverse" + } + val outside = if (inverse) tree[0] else null + var containing: PolyPath64 = outside ?: tree + var inside = inverse + while (true) { + val child = (0 until containing.count).asSequence().map { containing[it] }.firstOrNull { + Clipper.pointInPolygon(point, it.polygon) != PointInPolygonResult.IsOutside + } ?: break + containing = child + inside = !inside + } + if (!inside) return null + + return MyPath().apply { + isInverseFill = containing === outside + // The temporary outer frame finds connected regions; it never bounds the saved fill. + if (!isInverseFill) addContour(containing.polygon) + for (index in 0 until containing.count) addContour(containing[index].polygon) + } + } + + companion object { + fun fromPath(path: Path): FillRegion { + val rule = when (path.fillType) { + Path.FillType.EVEN_ODD, Path.FillType.INVERSE_EVEN_ODD -> FillRule.EvenOdd + else -> FillRule.NonZero + } + return FillRegion(clip(ClipType.Union, polygonPaths(path), Paths64(), rule), path.isInverseFillType) + } + + fun fromOperation(path: Path, options: PaintOptions, backgroundColor: Int): FillRegion { + val paint = Paint().apply { options.applyTo(this, backgroundColor) } + val shape = Path().apply { if (options.isFill) set(path) else paint.getFillPath(path, this) } + return fromPath(shape) + } + } +} + +private fun clip(type: ClipType, subject: Paths64, clip: Paths64, rule: FillRule = FillRule.NonZero): Paths64 { + val engine = Clipper64() + engine.addSubject(subject) + engine.addClip(clip) + return Paths64().also { + check(engine.execute(type, rule, it)) { + "Fill clipping failed: $type, $rule, ${subject.size} subject and ${clip.size} clip contours" + } + } +} + +internal fun polygonPaths(path: Path): Paths64 { + val result = Paths64() + val measure = PathMeasure(path, true) + do { + val contour = Path() + if (!measure.getSegment(0f, measure.length, contour, true)) continue + val approximation = contour.approximate(CURVE_APPROXIMATION_ERROR) + val points = Path64() + for (i in approximation.indices step APPROXIMATION_POINT_SIZE) { + val point = Point64(approximation[i + 1] * COORDINATE_SCALE, approximation[i + 2] * COORDINATE_SCALE) + if (points.lastOrNull() != point) points.add(point) + } + result.add(points) + } while (measure.nextContour()) + return result +} + +private fun MyPath.addContour(points: Path64) { + moveTo((points[0].x / COORDINATE_SCALE).toFloat(), (points[0].y / COORDINATE_SCALE).toFloat()) + for (point in points.drop(1)) lineTo((point.x / COORDINATE_SCALE).toFloat(), (point.y / COORDINATE_SCALE).toFloat()) + close() +} diff --git a/app/src/main/kotlin/org/fossify/paint/helpers/VectorFloodFiller.kt b/app/src/main/kotlin/org/fossify/paint/helpers/VectorFloodFiller.kt deleted file mode 100644 index 0dc2af56..00000000 --- a/app/src/main/kotlin/org/fossify/paint/helpers/VectorFloodFiller.kt +++ /dev/null @@ -1,151 +0,0 @@ -package org.fossify.paint.helpers - -import android.graphics.Bitmap -import android.graphics.Color -import org.fossify.paint.models.MyPath -import java.util.LinkedList -import java.util.Queue - -// Original algorithm by J. Dunlap http:// www.codeproject.com/KB/GDI-plus/queuelinearflood-fill.aspx -// Java port by Owen Kaluza -// Android port by Darrin Smith (Standard Android) -class VectorFloodFiller(image: Bitmap) { - val path = MyPath() - - private val width: Int - private val height: Int - private val pixels: IntArray - - private lateinit var pixelsChecked: BooleanArray - private lateinit var ranges: Queue - - var fillColor = 0 - var tolerance = 0 - private var startColorRed = 0 - private var startColorGreen = 0 - private var startColorBlue = 0 - - init { - width = image.width - height = image.height - pixels = IntArray(width * height) - image.getPixels(pixels, 0, width, 0, 0, width, height) - } - - private fun prepare() { - // Called before starting flood-fill - pixelsChecked = BooleanArray(pixels.size) - ranges = LinkedList() - } - - // Fills the specified point on the bitmap with the currently selected fill color. - // int x, int y: The starting coordinates for the fill - fun floodFill(x: Int, y: Int) { - // Setup - prepare() - - // Get starting color. - val startPixel = pixels.getOrNull(width * y + x) ?: return - if (startPixel == fillColor) { - // No-op. - return - } - startColorRed = Color.red(startPixel) - startColorGreen = Color.green(startPixel) - startColorBlue = Color.blue(startPixel) - - // Do first call to flood-fill. - linearFill(x, y) - - // Call flood-fill routine while flood-fill ranges still exist on the queue - var range: FloodFillRange - while (ranges.size > 0) { - // Get Next Range Off the Queue - range = ranges.remove() - - // Check Above and Below Each Pixel in the flood-fill Range - var downPxIdx = width * (range.Y + 1) + range.startX - var upPxIdx = width * (range.Y - 1) + range.startX - val upY = range.Y - 1 // so we can pass the y coordinate by ref - val downY = range.Y + 1 - for (i in range.startX..range.endX) { - // Start Fill Upwards - // if we're not above the top of the bitmap and the pixel above this one is within the color tolerance - if (range.Y > 0 && !pixelsChecked[upPxIdx] && isPixelColorWithinTolerance(upPxIdx)) { - linearFill(i, upY) - } - - // Start Fill Downwards - // if we're not below the bottom of the bitmap and the pixel below this one is within the color tolerance - if ( - range.Y < height - 1 && !pixelsChecked[downPxIdx] - && isPixelColorWithinTolerance(downPxIdx) - ) { - linearFill(i, downY) - } - downPxIdx++ - upPxIdx++ - } - } - } - - // Finds the furthermost left and right boundaries of the fill area - // on a given y coordinate, starting from a given x coordinate, filling as it goes. - // Adds the resulting horizontal range to the queue of flood-fill ranges, - // to be processed in the main loop. - // - // int x, int y: The starting coordinates - private fun linearFill(x: Int, y: Int) { - // Find Left Edge of Color Area - var lFillLoc = x // the location to check/fill on the left - var pxIdx = width * y + x - path.moveTo(x.toFloat(), y.toFloat()) - while (true) { - pixelsChecked[pxIdx] = true - lFillLoc-- - pxIdx-- - // exit loop if we're at edge of bitmap or color area - if (lFillLoc < 0 || pixelsChecked[pxIdx] || !isPixelColorWithinTolerance(pxIdx)) { - break - } - } - vectorFill(pxIdx + 1) - lFillLoc++ - - // Find Right Edge of Color Area - var rFillLoc = x // the location to check/fill on the left - pxIdx = width * y + x - while (true) { - pixelsChecked[pxIdx] = true - rFillLoc++ - pxIdx++ - if (rFillLoc >= width || pixelsChecked[pxIdx] || !isPixelColorWithinTolerance(pxIdx)) { - break - } - } - vectorFill(pxIdx - 1) - rFillLoc-- - - // add range to queue - val r = FloodFillRange(lFillLoc, rFillLoc, y) - ranges.offer(r) - } - - // vector fill pixels with color - private fun vectorFill(pxIndex: Int) { - val x = (pxIndex % width).toFloat() - val y = (pxIndex - x) / width - path.lineTo(x, y) - } - - // Sees if a pixel is within the color tolerance range. - private fun isPixelColorWithinTolerance(px: Int): Boolean { - val red = pixels[px] ushr 16 and 0xff - val green = pixels[px] ushr 8 and 0xff - val blue = pixels[px] and 0xff - return red >= startColorRed - tolerance && red <= startColorRed + tolerance && green >= startColorGreen - tolerance && green <= startColorGreen + tolerance && blue >= startColorBlue - tolerance && blue <= startColorBlue + tolerance - } - - // Represents a linear range to be filled and branched from. - private inner class FloodFillRange(var startX: Int, var endX: Int, var Y: Int) -} diff --git a/app/src/main/kotlin/org/fossify/paint/models/MyPath.kt b/app/src/main/kotlin/org/fossify/paint/models/MyPath.kt index d82b026f..8b01055f 100644 --- a/app/src/main/kotlin/org/fossify/paint/models/MyPath.kt +++ b/app/src/main/kotlin/org/fossify/paint/models/MyPath.kt @@ -1,10 +1,8 @@ package org.fossify.paint.models -import android.app.Activity import android.graphics.Path -import org.fossify.commons.extensions.toast -import org.fossify.paint.R import org.fossify.paint.actions.Action +import org.fossify.paint.actions.Close import org.fossify.paint.actions.Line import org.fossify.paint.actions.Move import org.fossify.paint.actions.Quad @@ -16,6 +14,15 @@ import java.util.LinkedList // https://stackoverflow.com/a/8127953 class MyPath : Path(), Serializable { val actions = LinkedList() + var isInverseFill = false + set(value) { + field = value + fillType = if (value) FillType.INVERSE_EVEN_ODD else FillType.EVEN_ODD + } + + init { + fillType = FillType.EVEN_ODD + } private fun readObject(inputStream: ObjectInputStream) { inputStream.defaultReadObject() @@ -26,29 +33,23 @@ class MyPath : Path(), Serializable { } } - fun readObject(pathData: String, activity: Activity) { - val tokens = pathData.split("\\s+".toRegex()).dropLastWhile(String::isEmpty).toTypedArray() + fun readPathData(pathData: String) { + val tokens = pathData.split("\\s+".toRegex()).filter(String::isNotEmpty) var i = 0 - try { - while (i < tokens.size) { - when (tokens[i][0]) { - 'M' -> addAction(Move(tokens[i])) - 'L' -> addAction(Line(tokens[i])) - 'Q' -> { - // Quad actions are of the following form: - // "Qx1,y1 x2,y2" - // Since we split the tokens by whitespace, we need to join them again - if (i + 1 >= tokens.size) - throw InvalidParameterException("Error parsing the data for a Quad.") - - addAction(Quad(tokens[i] + " " + tokens[i + 1])) - ++i + while (i < tokens.size) { + when (tokens[i][0]) { + 'M' -> addAction(Move(tokens[i])) + 'L' -> addAction(Line(tokens[i])) + 'Z', 'z' -> close() + 'Q' -> { + if (i + 1 >= tokens.size) { + throw InvalidParameterException("Error parsing the data for a Quad.") } + addAction(Quad(tokens[i] + " " + tokens[i + 1])) + ++i } - ++i } - } catch (e: Exception) { - activity.toast(R.string.unknown_error_occurred) + ++i } } @@ -59,12 +60,18 @@ class MyPath : Path(), Serializable { private fun addAction(action: Action) { when (action) { + is Close -> close() is Move -> moveTo(action.x, action.y) is Line -> lineTo(action.x, action.y) is Quad -> quadTo(action.x1, action.y1, action.x2, action.y2) } } + override fun close() { + actions.add(Close()) + super.close() + } + override fun moveTo(x: Float, y: Float) { actions.add(Move(x, y)) super.moveTo(x, y) diff --git a/app/src/main/kotlin/org/fossify/paint/models/PaintOptions.kt b/app/src/main/kotlin/org/fossify/paint/models/PaintOptions.kt index d766b7a5..b5917d7b 100644 --- a/app/src/main/kotlin/org/fossify/paint/models/PaintOptions.kt +++ b/app/src/main/kotlin/org/fossify/paint/models/PaintOptions.kt @@ -1,11 +1,19 @@ package org.fossify.paint.models import android.graphics.Color +import android.graphics.Paint data class PaintOptions( var color: Int = Color.BLACK, var strokeWidth: Float = 5f, - var isEraser: Boolean = false + var isEraser: Boolean = false, + var isFill: Boolean = false ) { - fun getColorToExport() = if (isEraser) "none" else "#${Integer.toHexString(color).substring(2)}" + fun applyTo(paint: Paint, backgroundColor: Int) { + paint.color = if (isEraser) backgroundColor else color + paint.style = if (isFill) Paint.Style.FILL else Paint.Style.STROKE + paint.strokeWidth = strokeWidth + paint.strokeJoin = Paint.Join.ROUND + paint.strokeCap = Paint.Cap.ROUND + } } diff --git a/app/src/main/kotlin/org/fossify/paint/models/Svg.kt b/app/src/main/kotlin/org/fossify/paint/models/Svg.kt index f067612c..74f8f444 100644 --- a/app/src/main/kotlin/org/fossify/paint/models/Svg.kt +++ b/app/src/main/kotlin/org/fossify/paint/models/Svg.kt @@ -1,171 +1,231 @@ package org.fossify.paint.models import android.graphics.Color -import android.graphics.drawable.ColorDrawable +import android.graphics.RectF import android.net.Uri import android.sax.RootElement -import android.util.Xml +import android.util.Log +import androidx.core.view.doOnLayout import org.fossify.commons.extensions.getFileOutputStream import org.fossify.commons.extensions.getFilenameFromPath +import org.fossify.commons.extensions.showErrorToast import org.fossify.commons.extensions.toast import org.fossify.commons.models.FileDirItem import org.fossify.paint.R import org.fossify.paint.activities.MainActivity import org.fossify.paint.activities.SimpleActivity +import org.fossify.paint.helpers.DrawingRenderer import org.fossify.paint.views.MyCanvas +import org.xml.sax.Attributes +import org.xml.sax.InputSource import java.io.BufferedWriter import java.io.File import java.io.FileInputStream import java.io.InputStream +import java.io.IOException import java.io.OutputStream import java.io.OutputStreamWriter -import java.io.Serializable import java.io.Writer +import javax.xml.parsers.SAXParserFactory + +private const val RGB_MASK = 0xffffff object Svg { - fun saveSvg(activity: SimpleActivity, path: String, canvas: MyCanvas) { + fun saveSvg(activity: SimpleActivity, path: String, canvas: MyCanvas, onSaved: (Long) -> Unit = {}) { activity.getFileOutputStream(FileDirItem(path, path.getFilenameFromPath()), true) { - saveToOutputStream(activity, it, canvas) + saveToOutputStream(activity, it, canvas, onSaved) } } fun saveToOutputStream( activity: SimpleActivity, outputStream: OutputStream?, - canvas: MyCanvas + canvas: MyCanvas, + onSaved: (Long) -> Unit = {} ) { if (outputStream != null) { - val backgroundColor = (canvas.background as ColorDrawable).color - val writer = BufferedWriter(OutputStreamWriter(outputStream)) - writeSvg(writer, backgroundColor, canvas.getPathsMap(), canvas.width, canvas.height) - writer.close() - activity.toast(R.string.file_saved) + canvas.whenDrawingReady { + try { + BufferedWriter(OutputStreamWriter(outputStream)).use { writer -> + writeSvg( + writer, canvas.getPreparedDrawing(), canvas.width, canvas.height, canvas.getViewportBounds() + ) + } + onSaved(canvas.getDrawingHashCode()) + activity.toast(R.string.file_saved) + } catch (e: IOException) { + activity.showErrorToast(e) + } + } } else { activity.toast(R.string.unknown_error_occurred) } } - private fun writeSvg( + internal fun writeSvg( writer: Writer, - backgroundColor: Int, - paths: Map, + drawing: DrawingRenderer.Drawing, width: Int, - height: Int + height: Int, + viewport: RectF = RectF(0f, 0f, width.toFloat(), height.toFloat()) ) { writer.apply { - write("") - write( - "" - ) + write("") + write("") + writeDrawing(this, drawing, viewport) + write("") + } + } - for ((key, value) in paths) { - writePath(this, key, value) + private fun writeDrawing( + writer: Writer, + drawing: DrawingRenderer.Drawing, + viewport: RectF + ) { + val operations = drawing.snapshot.operations + val regions = drawing.regions + if (regions == null) { + for ((path, options) in operations) writePath(writer, path, options, viewport) + return + } + writer.apply { + write("") + for ((path, options) in operations) writePath(this, path, options, viewport) + write("") + write("") + write("") + write("") + for ((color, path) in regions.paths) { + val options = PaintOptions(color = color ?: drawing.snapshot.backgroundColor, isFill = true) + writePath(this, path, options, viewport) } - write("") + write("") + for ((path, options) in operations.drop(drawing.snapshot.fillEnd)) writePath(this, path, options, viewport) + write("") } } - private fun writePath(writer: Writer, path: MyPath, options: PaintOptions) { + private fun writePath( + writer: Writer, + path: MyPath, + options: PaintOptions, + viewport: RectF + ) { writer.apply { write("") + val color = if (options.isEraser) "none" else colorString(options.color) + if (options.isFill) { + write("\" fill=\"$color\" fill-rule=\"evenodd\" stroke=\"none\"") + if (path.isInverseFill) write(" data-inverse-fill=\"true\"") + } else { + write("\" fill=\"none\" stroke=\"$color\" stroke-width=\"${options.strokeWidth}\"") + write(" stroke-linecap=\"round\" stroke-linejoin=\"round\"") + } + write("/>") } } + @Suppress("TooGenericExceptionCaught") fun loadSvg(activity: MainActivity, fileOrUri: Any, canvas: MyCanvas) { - val svg = parseSvg(activity, fileOrUri) - - canvas.clearCanvas() - activity.setBackgroundColor(svg.background!!.color) - - svg.paths.forEach { - val path = MyPath() - path.readObject(it.data, activity) - val options = PaintOptions(it.color, it.strokeWidth, it.isEraser) - - canvas.addOperation(path, options) - } - } - - private fun parseSvg(activity: MainActivity, fileOrUri: Any): SSvg { - var inputStream: InputStream? = null - val svg = SSvg() try { - inputStream = when (fileOrUri) { + val stream = when (fileOrUri) { is File -> FileInputStream(fileOrUri) is Uri -> activity.contentResolver.openInputStream(fileOrUri) else -> null + } ?: return + val svg = stream.use { parseSvg(it) } + canvas.doOnLayout { + canvas.whenDrawingReady { + runCatching { + canvas.replaceDrawing(svg.viewport, svg.backgroundColor, svg.paths) + activity.updateBackgroundControls(svg.backgroundColor) + }.onFailure { showLoadError(activity, it) } + } } + } catch (e: Exception) { + showLoadError(activity, e) + } + } - // Actual parsing (http://stackoverflow.com/a/4828765) - val ns = "http://www.w3.org/2000/svg" - val root = RootElement(ns, "svg") - val rectElement = root.getChild(ns, "rect") - val pathElement = root.getChild(ns, "path") - - root.setStartElementListener { attributes -> - val width = attributes.getValue("width").toInt() - val height = attributes.getValue("height").toInt() - svg.setSize(width, height) - } - - rectElement.setStartElementListener { attributes -> - val width = attributes.getValue("width").toInt() - val height = attributes.getValue("height").toInt() - val color = Color.parseColor(attributes.getValue("fill")) - if (svg.background != null) - throw UnsupportedOperationException("Unsupported SVG, should only have one .") - - svg.background = SRect(width, height, color) - } + private fun showLoadError(activity: MainActivity, error: Throwable) { + Log.e("Paint", "Failed to load SVG", error) + activity.toast(R.string.unknown_error_occurred) + } - pathElement.setStartElementListener { attributes -> - val d = attributes.getValue("d") - val width = attributes.getValue("stroke-width").toFloat() - val stroke = attributes.getValue("stroke") - val isEraser = stroke == "none" - val color = if (isEraser) 0 else Color.parseColor(stroke) - svg.paths.add(SPath(d, color, width, isEraser)) + @Suppress("DestructuringDeclarationWithTooManyEntries") + internal fun parseSvg(inputStream: InputStream): Drawing { + val svg = Drawing() + val namespace = "http://www.w3.org/2000/svg" + val root = RootElement(namespace, "svg") + root.setStartElementListener { attributes -> + val viewBox = attributes.getValue("viewBox") + svg.viewport = if (viewBox == null) { + RectF(0f, 0f, attributes.getValue("width").toFloat(), attributes.getValue("height").toFloat()) + } else { + val (left, top, width, height) = viewBox.trim().split("[\\s,]+".toRegex()).map(String::toFloat) + RectF(left, top, left + width, top + height) } - - Xml.parse(inputStream, Xml.Encoding.UTF_8, root.contentHandler) - } finally { - inputStream?.close() } + root.getChild(namespace, "rect").setStartElementListener { attributes -> + svg.backgroundColor = Color.parseColor(attributes.getValue("fill")) + } + root.getChild(namespace, "path").setStartElementListener { readPath(it, svg) } + var isHistory = false + val history = root.getChild(namespace, "defs").getChild(namespace, "g") + history.setStartElementListener { isHistory = it.getValue("id") == "paint-history" } + history.getChild(namespace, "path").setStartElementListener { if (isHistory) readPath(it, svg) } + val factory = SAXParserFactory.newInstance().apply { isNamespaceAware = true } + factory.newSAXParser().xmlReader.apply { + contentHandler = root.contentHandler + }.parse(InputSource(inputStream)) + validateViewport(svg.viewport) return svg } - private class SSvg : Serializable { - var background: SRect? = null - val paths: ArrayList = ArrayList() - private var width = 0 - private var height = 0 - - fun setSize(w: Int, h: Int) { - width = w - height = h - } + private fun validateViewport(viewport: RectF) { + require(viewport.run { + listOf(left, top, right, bottom, width(), height()).all { it.isFinite() } && width() > 0f && height() > 0f + }) } - private class SRect(val width: Int, val height: Int, val color: Int) : Serializable + private fun colorString(color: Int) = "#%06x".format(color and RGB_MASK) - private class SPath( - var data: String, - var color: Int, - var strokeWidth: Float, - var isEraser: Boolean - ) : Serializable + internal class Drawing { + var backgroundColor = Color.WHITE + var viewport = RectF() + val paths = LinkedHashMap() + } +} + +private fun readPath(attributes: Attributes, drawing: Svg.Drawing) { + val fill = attributes.getValue("fill") ?: "none" + val stroke = attributes.getValue("stroke") ?: "none" + val isFill = fill != "none" + val isEraser = !isFill && stroke == "none" + val color = if (isEraser) 0 else Color.parseColor(if (isFill) fill else stroke) + val isInverse = attributes.getValue("data-inverse-fill") == "true" + val data = attributes.getValue("d") + val path = MyPath().apply { + readPathData(if (isInverse) data.substringAfter('Z').trim() else data) + isInverseFill = isInverse + } + drawing.paths[path] = PaintOptions( + color, attributes.getValue("stroke-width")?.toFloat() ?: 0f, isEraser, isFill + ) } diff --git a/app/src/main/kotlin/org/fossify/paint/views/MyCanvas.kt b/app/src/main/kotlin/org/fossify/paint/views/MyCanvas.kt index 90ace034..a45dc591 100644 --- a/app/src/main/kotlin/org/fossify/paint/views/MyCanvas.kt +++ b/app/src/main/kotlin/org/fossify/paint/views/MyCanvas.kt @@ -5,36 +5,46 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color +import android.graphics.Matrix import android.graphics.Paint +import android.graphics.Path import android.graphics.Point import android.graphics.PointF +import android.graphics.RectF +import android.os.Build +import android.os.Handler +import android.os.Looper import android.os.Parcelable import android.util.AttributeSet +import android.util.Log import android.view.MotionEvent import android.view.MotionEvent.INVALID_POINTER_ID import android.view.ScaleGestureDetector import android.view.View import android.view.ViewConfiguration +import androidx.core.graphics.createBitmap +import androidx.core.graphics.withMatrix +import androidx.core.view.doOnLayout import com.bumptech.glide.Glide import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.request.RequestOptions import org.fossify.commons.extensions.toast import org.fossify.commons.helpers.ensureBackgroundThread import org.fossify.paint.R -import org.fossify.paint.extensions.contains import org.fossify.paint.extensions.removeFirst import org.fossify.paint.extensions.removeLast import org.fossify.paint.extensions.removeLastOrNull -import org.fossify.paint.extensions.vectorFloodFill +import org.fossify.paint.helpers.BucketFill +import org.fossify.paint.helpers.DrawingRenderer import org.fossify.paint.interfaces.CanvasListener import org.fossify.paint.models.MyPath import org.fossify.paint.models.PaintOptions import java.util.concurrent.ExecutionException +import java.util.concurrent.Executor import kotlin.math.abs class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) { private val MAX_HISTORY_COUNT = 1000 - private val FLOOD_FILL_TOLERANCE = 1 private val mScaledTouchSlop = ViewConfiguration.get(context).scaledTouchSlop @@ -47,6 +57,7 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) { private var mLastBackgroundBitmap: Bitmap? = null private var mPaint = Paint() + private val mRenderer = DrawingRenderer() private var mPath = MyPath() private var mPaintOptions = PaintOptions() @@ -70,8 +81,11 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) { private var mIgnoreMultitouchChanges = false private var mWasScalingInGesture = false private var mWasMovingCanvasInGesture = false - private var mBackgroundColor = 0 - private var mCenter: PointF? = null + private var mBackgroundColor = Color.WHITE + private var mDrawingInProgress = false + private val mDrawingHandler = Handler(Looper.getMainLooper()) + private val mPendingEdits = ArrayDeque() + internal var drawingExecutor = Executor { task -> ensureBackgroundThread { task.run() } } private var mScaleDetector: ScaleGestureDetector? = null private var mScaleFactor = 1f @@ -80,6 +94,10 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) { private var mTouchSloppedBeforeMultitouch: Boolean = false init { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + // Android 8's hardware renderer clips inverse fills to the path bounds. + setLayerType(LAYER_TYPE_SOFTWARE, null) + } mPaint.apply { color = mPaintOptions.color style = Paint.Style.STROKE @@ -104,18 +122,41 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) { public override fun onRestoreInstanceState(state: Parcelable) { val savedOperations = DrawingStateHolder.operations if (savedOperations != null) { - mOperations = savedOperations + whenDrawingReady { mOperations = savedOperations } } super.onRestoreInstanceState(state) updateUndoVisibility() } + override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) { + super.onSizeChanged(width, height, oldWidth, oldHeight) + whenDrawingReady {} + } + + override fun onDetachedFromWindow() { + mLastMotionEvent?.recycle() + mLastMotionEvent = null + super.onDetachedFromWindow() + } + override fun onTouchEvent(event: MotionEvent): Boolean { if (mAllowMovingZooming) { mScaleDetector!!.onTouchEvent(event) } val action = event.actionMasked + if (action == MotionEvent.ACTION_CANCEL) { + mActivePointerId = INVALID_POINTER_ID + mIgnoreTouches = false + mIgnoreMultitouchChanges = false + mWasMultitouch = false + mWasScalingInGesture = false + mWasMovingCanvasInGesture = false + mTouchSloppedBeforeMultitouch = false + mPath.reset() + invalidate() + return true + } if (mIgnoreTouches && action == MotionEvent.ACTION_UP) { mIgnoreTouches = false mWasScalingInGesture = false @@ -138,15 +179,12 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) { return true } - val scaledWidth = width / mScaleFactor - val touchPercentageX = x / width - val compensationX = (scaledWidth / 2) * (1 - mScaleFactor) - val newValueX = scaledWidth * touchPercentageX - compensationX - (mPosX / mScaleFactor) - - val scaledHeight = height / mScaleFactor - val touchPercentageY = y / height - val compensationY = (scaledHeight / 2) * (1 - mScaleFactor) - val newValueY = scaledHeight * touchPercentageY - compensationY - (mPosY / mScaleFactor) + val point = floatArrayOf(x, y) + val inverse = Matrix() + canvasMatrix().invert(inverse) + inverse.mapPoints(point) + val newValueX = point[0] + val newValueY = point[1] when (action) { MotionEvent.ACTION_DOWN -> { @@ -158,8 +196,6 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) { mLastTouchX = x mLastTouchY = y actionDown(newValueX, newValueY) - mUndoneOperations.clear() - updateRedoVisibility(false) } MotionEvent.ACTION_MOVE -> { @@ -184,9 +220,15 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) { mIgnoreMultitouchChanges = false } - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + MotionEvent.ACTION_UP -> { mActivePointerId = INVALID_POINTER_ID - actionUp(false) + val isInsideCanvas = x >= 0 && x < width && y >= 0 && y < height + val fillPoint = if (!mWasMultitouch && isInsideCanvas) { + PointF(newValueX, newValueY) + } else { + null + } + actionUp(false, fillPoint) mWasScalingInGesture = false mWasMovingCanvasInGesture = false } @@ -209,6 +251,7 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) { } } + mLastMotionEvent?.recycle() mLastMotionEvent = MotionEvent.obtain(event) invalidate() return true @@ -216,42 +259,35 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) { override fun onDraw(canvas: Canvas) { super.onDraw(canvas) - canvas.save() - - if (mCenter == null) { - mCenter = PointF(width / 2f, height / 2f) - } - - canvas.translate(mPosX, mPosY) - canvas.scale(mScaleFactor, mScaleFactor, mCenter!!.x, mCenter!!.y) + drawDrawing(canvas, true) + } - if (mBackgroundBitmap != null) { - val bitmap = mBackgroundBitmap!! - val left = (width - bitmap.width) / 2f - val top = (height - bitmap.height) / 2f - canvas.drawBitmap(bitmap, left, top, null) - } + private fun drawDrawing(canvas: Canvas, includePendingEdits: Boolean) { + canvas.withMatrix(canvasMatrix()) { + mRenderer.draw(this) - if (mOperations.isNotEmpty()) { - for ((path, paintOptions) in mOperations) { - changePaint(paintOptions) - canvas.drawPath(path, mPaint) + if (includePendingEdits) { + for (edit in mPendingEdits) { + if (edit is DrawingEdit.Stroke) { + changePaint(edit.options) + drawPath(edit.path, mPaint) + } + } + changePaint(mPaintOptions) + drawPath(mPath, mPaint) } } - - changePaint(mPaintOptions) - canvas.drawPath(mPath, mPaint) - canvas.restore() } fun undo() { + whenDrawingReady { applyUndo() } + } + + private fun applyUndo() { if (mOperations.isEmpty() && mLastOperations.isNotEmpty()) { mOperations = mLastOperations.clone() as LinkedHashMap mBackgroundBitmap = mLastBackgroundBitmap mLastOperations.clear() - updateUndoVisibility() - updateClearConfirmation() - invalidate() return } @@ -260,18 +296,18 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) { if (paintOptions != null && path != null) { mUndoneOperations[path] = paintOptions } - invalidate() } - updateUndoRedoVisibility() } fun redo() { + whenDrawingReady { applyRedo() } + } + + private fun applyRedo() { if (mUndoneOperations.isNotEmpty()) { val (path, paintOptions) = mUndoneOperations.removeLast() - addOperation(path, paintOptions) - invalidate() + recordOperation(path, paintOptions, clearRedo = false) } - updateUndoRedoVisibility() } fun toggleEraser(isEraserOn: Boolean) { @@ -289,6 +325,10 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) { } fun updateBackgroundColor(newColor: Int) { + whenDrawingReady { applyBackgroundColor(newColor) } + } + + private fun applyBackgroundColor(newColor: Int) { mBackgroundColor = newColor setBackgroundColor(newColor) mBackgroundBitmap = null @@ -311,11 +351,16 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) { setBrushSize(mCurrBrushSize) } - fun getBitmap(): Bitmap { - val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + fun getBitmap(includePendingEdits: Boolean = true): Bitmap { + val bitmap = createBitmap(width, height) val canvas = Canvas(bitmap) canvas.drawColor(Color.WHITE) - draw(canvas) + if (includePendingEdits) { + draw(canvas) + } else { + canvas.drawColor(mBackgroundColor) + drawDrawing(canvas, false) + } return bitmap } @@ -331,10 +376,9 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) { val builder = Glide.with(context).asBitmap().load(path).apply(options).submit(size.x, size.y) - mBackgroundBitmap = builder.get() + val bitmap = builder.get() activity.runOnUiThread { - invalidate() - updateClearConfirmation() + whenDrawingReady { mBackgroundBitmap = bitmap } } } catch (e: ExecutionException) { val errorMsg = @@ -345,21 +389,27 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) { } private fun changePaint(paintOptions: PaintOptions) { - mPaint.color = if (paintOptions.isEraser) mBackgroundColor else paintOptions.color - mPaint.strokeWidth = paintOptions.strokeWidth + paintOptions.applyTo(mPaint, mBackgroundColor) } fun clearCanvas() { + mPath.reset() + whenDrawingReady { applyClear() } + } + + private fun applyClear() { mLastOperations = mOperations.clone() as LinkedHashMap mLastBackgroundBitmap = mBackgroundBitmap mBackgroundBitmap = null - mPath.reset() mOperations.clear() mUndoneOperations.clear() - updateUndoVisibility() - updateRedoVisibility(false) - updateClearConfirmation() - invalidate() + } + + internal fun replaceDrawing(viewport: RectF, backgroundColor: Int, paths: Map) { + applyViewportBounds(viewport) + applyClear() + applyBackgroundColor(backgroundColor) + for ((path, options) in paths) recordOperation(path, options) } private fun actionDown(x: Float, y: Float) { @@ -375,15 +425,13 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) { mCurY = y } - private fun actionUp(forceLineDraw: Boolean) { + private fun actionUp(forceLineDraw: Boolean, fillPoint: PointF? = null) { if (mIsBucketFillOn) { - bucketFill() + fillPoint?.let { bucketFill(it.x, it.y) } } else if (!mWasMultitouch || forceLineDraw) { drawADot() } - updateUndoVisibility() - updateClearConfirmation() mPath = MyPath() mPaintOptions = PaintOptions(mPaintOptions.color, mPaintOptions.strokeWidth, mPaintOptions.isEraser) @@ -396,83 +444,189 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) { } private fun updateUndoVisibility() { - mListener?.toggleUndoVisibility(mOperations.isNotEmpty() || mLastOperations.isNotEmpty()) + val hasEdits = mOperations.isNotEmpty() || mLastOperations.isNotEmpty() + mListener?.toggleUndoVisibility(hasEdits || hasPendingEdits()) } - private fun updateRedoVisibility(visible: Boolean = mUndoneOperations.isNotEmpty()) { - mListener?.toggleRedoVisibility(visible) + private fun updateRedoVisibility() { + mListener?.toggleRedoVisibility(mUndoneOperations.isNotEmpty() && !hasPendingEdits()) } private fun updateClearConfirmation() { - val hasContent = mBackgroundBitmap != null || mOperations.isNotEmpty() + val hasContent = mBackgroundBitmap != null || mOperations.isNotEmpty() || hasPendingEdits() mListener?.toggleHasContent(hasContent) } - private fun bucketFill() { - val touchedX = mCurX.toInt() - val touchedY = mCurY.toInt() - if (contains(touchedX, touchedY)) { - val bitmap = getBitmap() - val color = mPaintOptions.color + private fun bucketFill(x: Float, y: Float) { + submitEdit(DrawingEdit.Fill(x, y, mPaintOptions.color)) + } + + fun whenDrawingReady(action: () -> Unit) { + submitEdit(DrawingEdit.Action(action)) + } + + private fun submitEdit(edit: DrawingEdit) { + mPendingEdits.addLast(edit) + processNextEdit() + updateUndoRedoVisibility() + invalidate() + } - ensureBackgroundThread { - val path = bitmap.vectorFloodFill( - color = color, - x = touchedX, - y = touchedY, - tolerance = FLOOD_FILL_TOLERANCE + private fun processNextEdit() { + if (mDrawingInProgress) return + while (!prepareDrawing()) { + when (val edit = mPendingEdits.removeFirstOrNull() ?: return) { + is DrawingEdit.Fill -> { + fillRegion(edit) + return + } + is DrawingEdit.Stroke -> recordOperation(edit.path, edit.options) + is DrawingEdit.Action -> edit.action() + } + } + } + + private fun drawingSnapshot(): DrawingRenderer.Snapshot { + val bitmap = mBackgroundBitmap + val origin = bitmap?.let { PointF((width - it.width) / 2f, (height - it.height) / 2f) } ?: PointF() + return DrawingRenderer.Snapshot(mOperations.toList(), mBackgroundColor, bitmap, origin) + } + + private fun prepareDrawing(): Boolean { + val snapshot = drawingSnapshot() + if (mRenderer.update(snapshot)) return false + val previous = mRenderer.drawing + mDrawingInProgress = true + drawingExecutor.execute { + val result = runCatching { DrawingRenderer.prepare(snapshot, previous) } + mDrawingHandler.post { + mRenderer.drawing = result.getOrElse { error -> + reportDrawingError(error) + DrawingRenderer.Drawing(snapshot) + } + finishDrawingWork() + } + } + return true + } + + private fun fillRegion(request: DrawingEdit.Fill) { + val snapshot = drawingSnapshot() + val previous = mRenderer.drawing + val filler = BucketFill( + snapshot.backgroundColor, + snapshot.operations.map { (path, options) -> Path(path) to options.copy() }, + snapshot.bitmap, + snapshot.origin + ) + mDrawingInProgress = true + drawingExecutor.execute { + val result = runCatching { + val path = filler.fill(request.x, request.y, request.color) ?: return@runCatching null + val options = PaintOptions(color = request.color, isFill = true) + val operations = (snapshot.operations + (path to options)).takeLast(MAX_HISTORY_COUNT) + path to DrawingRenderer.prepare(snapshot.copy(operations = operations), previous) + } + mDrawingHandler.post { + result.fold( + onSuccess = { completed -> + completed?.let { (path, drawing) -> + recordOperation(path, drawing.snapshot.operations.last().second) + mRenderer.drawing = drawing + } + }, + onFailure = ::reportDrawingError ) - val paintOpts = PaintOptions(color = color, strokeWidth = 5f) - addOperation(path, paintOpts) - post { invalidate() } + finishDrawingWork() } } } + private fun finishDrawingWork() { + mDrawingInProgress = false + processNextEdit() + updateUndoRedoVisibility() + invalidate() + } + + private fun reportDrawingError(error: Throwable) { + Log.e("Paint", "Failed to prepare drawing", error) + context.toast(R.string.unknown_error_occurred) + } + + private sealed interface DrawingEdit { + data class Fill(val x: Float, val y: Float, val color: Int) : DrawingEdit + data class Stroke(val path: MyPath, val options: PaintOptions) : DrawingEdit + data class Action(val action: () -> Unit) : DrawingEdit + } + private fun drawADot() { mPath.lineTo(mCurX, mCurY) - // draw a dot on click - if (mStartX == mCurX && mStartY == mCurY) { - mPath.lineTo(mCurX, mCurY + 2) - mPath.lineTo(mCurX + 1, mCurY + 2) - mPath.lineTo(mCurX + 1, mCurY) - } addOperation(mPath, mPaintOptions) } fun addOperation(path: MyPath, paintOptions: PaintOptions) { + submitEdit(DrawingEdit.Stroke(path, paintOptions.copy())) + } + + private fun recordOperation(path: MyPath, paintOptions: PaintOptions, clearRedo: Boolean = true) { mOperations[path] = paintOptions + if (clearRedo) mUndoneOperations.clear() - // maybe free up some memory while (mOperations.size > MAX_HISTORY_COUNT) { mOperations.removeFirst() } - updateClearConfirmation() + } + + private fun canvasMatrix() = Matrix().apply { + preTranslate(mPosX, mPosY) + preScale(mScaleFactor, mScaleFactor, width / 2f, height / 2f) + } + + fun getViewportBounds(): RectF { + val inverse = Matrix() + canvasMatrix().invert(inverse) + return RectF(0f, 0f, width.toFloat(), height.toFloat()).apply { inverse.mapRect(this) } + } + + fun setViewportBounds(bounds: RectF) { + doOnLayout { applyViewportBounds(bounds) } + } + + internal fun applyViewportBounds(bounds: RectF) { + require(bounds.width() > 0f && bounds.height() > 0f) + val scale = minOf(width / bounds.width(), height / bounds.height()) + val posX = (width / 2f - bounds.centerX()) * scale + val posY = (height / 2f - bounds.centerY()) * scale + require(scale > 0f && listOf(scale, posX, posY).all { it.isFinite() }) + mScaleFactor = scale + mPosX = posX + mPosY = posY + setBrushSize(mCurrBrushSize) + invalidate() } fun getPathsMap() = mOperations - fun getDrawingHashCode(): Long { - return if (mOperations.isEmpty()) { - 0 - } else { - mOperations.hashCode().toLong() + (mBackgroundBitmap?.hashCode()?.toLong() ?: 0L) - } + internal fun getPreparedDrawing() = mRenderer.drawing + + fun hasPendingEdits() = mDrawingInProgress || mPendingEdits.isNotEmpty() + + fun getDrawingHashCode(): Long = if (mOperations.isEmpty()) { + 0 + } else { + mOperations.hashCode().toLong() + (mBackgroundBitmap?.hashCode()?.toLong() ?: 0L) } private fun MotionEvent?.isTouchSlop(pointerIndex: Int, startX: Float, startY: Float): Boolean { - return if (this == null || actionMasked != MotionEvent.ACTION_MOVE) { - false - } else { - try { - val moveX = abs(getX(pointerIndex) - startX) - val moveY = abs(getY(pointerIndex) - startY) + return !(this == null || actionMasked != MotionEvent.ACTION_MOVE) && try { + val moveX = abs(getX(pointerIndex) - startX) + val moveY = abs(getY(pointerIndex) - startY) - moveX <= mScaledTouchSlop && moveY <= mScaledTouchSlop - } catch (e: Exception) { - false - } + moveX <= mScaledTouchSlop && moveY <= mScaledTouchSlop + } catch (_: Exception) { + false } } @@ -482,16 +636,17 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) { mPath.reset() } - if (mScaleFactor * detector.scaleFactor !in 0.1f..10.0f) { - return true - } - + val newScale = (mScaleFactor * detector.scaleFactor).coerceIn( + minOf(0.1f, mScaleFactor), maxOf(10.0f, mScaleFactor) + ) + if (newScale == mScaleFactor) return true + val factor = newScale / mScaleFactor mIgnoreTouches = true mWasScalingInGesture = true - mScaleFactor *= detector.scaleFactor + mScaleFactor = newScale - mPosX *= detector.scaleFactor - mPosY *= detector.scaleFactor + mPosX *= factor + mPosY *= factor setBrushSize(mCurrBrushSize) invalidate() @@ -500,7 +655,6 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) { } } -// since we don't use view models, this serves as a simple state holder to save drawing operations object DrawingStateHolder { var operations: LinkedHashMap? = null } diff --git a/app/src/test/kotlin/org/fossify/paint/helpers/BucketFillTest.kt b/app/src/test/kotlin/org/fossify/paint/helpers/BucketFillTest.kt new file mode 100644 index 00000000..46ae24da --- /dev/null +++ b/app/src/test/kotlin/org/fossify/paint/helpers/BucketFillTest.kt @@ -0,0 +1,299 @@ +package org.fossify.paint.helpers + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.graphics.PointF +import android.graphics.RectF +import org.fossify.paint.models.PaintOptions +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.ByteArrayOutputStream +import kotlin.math.hypot + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], manifest = Config.NONE) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class BucketFillTest { + @Test + fun blankAndTransparentCanvasesFillBeyondAnyViewport() { + val transparent = Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888) + for (image in listOf(null, transparent)) { + val filler = BucketFill(Color.WHITE, emptyList(), image) + assertNull(filler.fill(5f, 5f, Color.WHITE)) + for (seed in listOf(PointF(5f, 5f), PointF(-100000f, 200000f))) { + val fill = filler.fill(seed.x, seed.y, Color.RED)!! + assertTrue(fill.isInverseFill) + assertEquals(Color.RED, sample(fill, 5f, 5f)) + assertEquals(Color.RED, sample(fill, -500000f, -500000f)) + } + } + transparent.recycle() + } + + @Test + fun enclosedFillStopsAtStrokeEdgesAcrossSizesAndCoordinates() { + for ((bounds, width) in listOf( + RectF(20f, 20f, 80f, 80f) to 4f, + RectF(-3000f, -2000f, 4000f, 5000f) to 4f, + RectF(20f, 20f, 80f, 80f) to 0.2f + )) { + val fill = filler(box(bounds.left, bounds.top, bounds.right, bounds.bottom), width) + .fill(bounds.centerX(), bounds.centerY(), Color.RED)!! + assertFalse(fill.isInverseFill) + assertEquals(Color.RED, sample(fill, bounds.centerX(), bounds.centerY())) + assertEquals(Color.RED, sample(fill, bounds.left + width, bounds.centerY())) + assertEquals(Color.RED, sample(fill, bounds.right - width, bounds.bottom - width)) + assertEquals(Color.WHITE, sample(fill, bounds.left + width / 4, bounds.centerY())) + assertEquals(Color.WHITE, sample(fill, bounds.right + width, bounds.centerY())) + } + } + + @Test + fun thinStrokesOutlinesAndDotsCanBeRecoloredWithoutFillingTheirSurroundings() { + val strokes = listOf( + box(20f, 20f, 80f, 80f), + Path().apply { moveTo(10f, 50f); lineTo(90f, 50f) }, + Path().apply { moveTo(20f, 50f); lineTo(20f, 50f) } + ) + for (stroke in strokes) for (width in listOf(0.2f, 4f)) { + val fill = filler(stroke, width).fill(20f, 50f, Color.RED)!! + assertFalse(fill.isInverseFill) + assertEquals(Color.RED, sample(fill, 20f, 50f)) + assertEquals(Color.WHITE, sample(fill, 50f, 40f)) + assertEquals(Color.WHITE, sample(fill, 10f, 10f)) + } + } + + @Test + fun nestedHolesStayHolesWithoutFillingDisconnectedIslands() { + val operations = listOf( + box(0f, 0f, 100f, 100f) to PaintOptions(strokeWidth = 4f), + box(20f, 20f, 80f, 80f) to PaintOptions(strokeWidth = 4f), + box(40f, 40f, 60f, 60f) to PaintOptions(strokeWidth = 4f) + ) + val fill = BucketFill(Color.WHITE, operations).fill(10f, 10f, Color.RED)!! + assertEquals(Color.RED, sample(fill, 10f, 10f)) + assertEquals(Color.WHITE, sample(fill, 30f, 30f)) + assertEquals(Color.WHITE, sample(fill, 50f, 50f)) + val inner = BucketFill(Color.WHITE, operations).fill(50f, 50f, Color.BLUE)!! + assertEquals(Color.BLUE, sample(inner, 50f, 50f, Color.BLUE)) + assertEquals(Color.WHITE, sample(inner, 30f, 30f, Color.BLUE)) + } + + @Test + fun erasedGapConnectsInteriorToTheInfiniteOutside() { + val gap = Path().apply { moveTo(15f, 50f); lineTo(25f, 50f) } + val operations = listOf( + box(20f, 20f, 80f, 80f) to PaintOptions(strokeWidth = 4f), + gap to PaintOptions(strokeWidth = 8f, isEraser = true) + ) + val fill = BucketFill(Color.WHITE, operations).fill(50f, 50f, Color.RED)!! + assertTrue(fill.isInverseFill) + assertEquals(Color.RED, sample(fill, 50f, 50f)) + assertEquals(Color.RED, sample(fill, -100f, -100f)) + assertEquals(Color.WHITE, sample(fill, 50f, 20f)) + } + + @Test + fun laterStrokesCanSplitAnEarlierFill() { + val outline = box(20f, 20f, 80f, 80f) + val fill = filler(outline).fill(30f, 30f, Color.RED)!! + val divider = Path().apply { moveTo(20f, 50f); lineTo(80f, 50f) } + val operations = listOf( + outline to PaintOptions(strokeWidth = 4f), + fill to PaintOptions(color = Color.RED, isFill = true), + divider to PaintOptions(strokeWidth = 4f) + ) + val recolored = BucketFill(Color.WHITE, operations).fill(30f, 30f, Color.BLUE)!! + assertEquals(Color.BLUE, sample(recolored, 30f, 30f, Color.BLUE)) + assertEquals(Color.WHITE, sample(recolored, 30f, 70f, Color.BLUE)) + } + + @Test + fun outsideFillCanBeRecoloredAndLaterEnclosed() { + val outline = box(20f, 20f, 80f, 80f) + val outside = filler(outline).fill(10f, 10f, Color.RED)!! + val operations = listOf( + outline to PaintOptions(strokeWidth = 4f), + outside to PaintOptions(color = Color.RED, isFill = true), + box(100f, 100f, 200f, 200f) to PaintOptions(strokeWidth = 4f) + ) + val insideNewBox = BucketFill(Color.WHITE, operations).fill(150f, 150f, Color.BLUE)!! + assertFalse(insideNewBox.isInverseFill) + assertEquals(Color.BLUE, sample(insideNewBox, 150f, 150f, Color.BLUE)) + assertEquals(Color.WHITE, sample(insideNewBox, -100f, -100f, Color.BLUE)) + val recoloredOutside = BucketFill(Color.WHITE, operations).fill(10f, 10f, Color.BLUE)!! + assertTrue(recoloredOutside.isInverseFill) + assertEquals(Color.WHITE, sample(recoloredOutside, 150f, 150f, Color.BLUE)) + assertEquals(Color.WHITE, sample(recoloredOutside, 50f, 50f, Color.BLUE)) + } + + @Test + fun curvedBoundaryRetainsItsEnclosedRegion() { + val circle = Path().apply { addCircle(50f, 50f, 30f, Path.Direction.CW) } + val fill = filler(circle).fill(50f, 50f, Color.RED)!! + assertEquals(Color.RED, sample(fill, 50f, 50f)) + assertEquals(Color.RED, sample(fill, 65f, 65f)) + assertEquals(Color.WHITE, sample(fill, 75f, 75f)) + } + + @Test + fun importedBitmapAndVectorPathsShareTheSameFillRegion() { + val image = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + Canvas(image).apply { + drawColor(Color.WHITE) + drawRect(20f, 20f, 80f, 80f, Paint().apply { + color = Color.BLACK + style = Paint.Style.STROKE + strokeWidth = 4f + }) + } + val divider = Path().apply { moveTo(-30f, 0f); lineTo(30f, 0f) } + val operations = listOf(divider to PaintOptions(strokeWidth = 4f)) + val filler = BucketFill(Color.WHITE, operations, image, PointF(-50f, -50f)) + val inside = filler.fill(0f, -10f, Color.RED)!! + assertFalse(inside.isInverseFill) + assertEquals(Color.RED, sample(inside, 0f, -10f)) + assertEquals(Color.WHITE, sample(inside, 0f, 10f)) + assertEquals(Color.WHITE, sample(inside, 50f, 50f)) + val outside = filler.fill(500f, 500f, Color.RED)!! + assertTrue(outside.isInverseFill) + assertEquals(Color.WHITE, sample(outside, 0f, -10f)) + assertEquals(Color.WHITE, sample(outside, 0f, 10f)) + assertEquals(Color.RED, sample(outside, 5000f, 5000f)) + } + + @Test + fun jpegCompressionNoiseDoesNotLeaveWhiteSpecklesInTheFilledRegion() { + val source = Bitmap.createBitmap(256, 256, Bitmap.Config.ARGB_8888) + Canvas(source).apply { + drawColor(Color.WHITE) + val pen = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.BLACK + style = Paint.Style.STROKE + strokeWidth = 3f + } + drawCircle(128f, 128f, 95f, pen) + drawLine(60f, 50f, 195f, 195f, pen) + } + val encoded = ByteArrayOutputStream().also { + source.compress(Bitmap.CompressFormat.JPEG, 80, it) + }.toByteArray() + val image = BitmapFactory.decodeByteArray(encoded, 0, encoded.size) + val fill = BucketFill(Color.WHITE, emptyList(), image).fill(80f, 150f, Color.RED)!! + val result = Bitmap.createBitmap(256, 256, Bitmap.Config.ARGB_8888) + Canvas(result).apply { + drawBitmap(image, 0f, 0f, null) + drawPath(fill, Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.RED }) + } + for (y in 30 until 225) for (x in 30 until 225) { + val distanceFromDivider = (145.0 * x - 135.0 * y - 1950.0) / hypot(145.0, 135.0) + if (hypot(x - 128.0, y - 128.0) < 92 && distanceFromDivider < -2) { + assertTrue("pixel=$x,$y", Color.green(result.getPixel(x, y)) <= 128) + } + } + assertEquals(image.getPixel(170, 80), result.getPixel(170, 80)) + assertEquals(image.getPixel(128, 123), result.getPixel(128, 123)) + source.recycle() + image.recycle() + result.recycle() + } + + @Test + fun regionsConnectThroughOverlapButNotThroughOneCorner() { + for ((edge, expected) in listOf(40f to Color.WHITE, 45f to Color.RED)) { + val operations = listOf( + box(20f, 20f, edge, edge) to PaintOptions(color = Color.BLACK, isFill = true), + box(40f, 40f, 60f, 60f) to PaintOptions(color = Color.BLACK, isFill = true) + ) + val fill = BucketFill(Color.WHITE, operations).fill(30f, 30f, Color.RED)!! + assertEquals(Color.RED, sample(fill, 30f, 30f)) + assertEquals(expected, sample(fill, 50f, 50f)) + } + } + + @Test + fun bitmapFillsRespectHolesAndEdgeConnectivity() { + assertBitmapFill(listOf("#.", ".#"), PointF(0.5f, 0.5f), listOf("#.", "..")) + assertBitmapFill(listOf("##.#", ".#.#"), PointF(0.5f, 0.5f), listOf("##..", ".#..")) + assertBitmapFill(listOf("###", "#.#", "###"), PointF(0.5f, 0.5f), listOf("###", "#.#", "###")) + assertBitmapFill(listOf("###", "#.#", "###"), PointF(1.5f, 1.5f), listOf("...", ".#.", "...")) + } + + @Test + fun complexBoundariesKeepRegionsSeparateAfterRepeatedRecoloring() { + val strip = Path().apply { addRect(-200f, 40f, 200f, 60f, Path.Direction.CW) } + val count = 5000 + val boundary = Path() + boundary.moveTo(100f, 0f) + for (i in 1 until count) { + val angle = 2 * Math.PI * i / count + val radius = if (i % 2 == 0) 100 else 95 + boundary.lineTo( + (radius * kotlin.math.cos(angle)).toFloat(), (radius * kotlin.math.sin(angle)).toFloat() + ) + } + boundary.close() + val operations = mutableListOf( + strip to PaintOptions(color = Color.BLUE, isFill = true), + boundary to PaintOptions(color = Color.BLACK, isFill = true) + ) + val bitmap = Bitmap.createBitmap(400, 200, Bitmap.Config.ARGB_8888) + repeat(3) { iteration -> + val color = if (iteration % 2 == 0) Color.RED else Color.GREEN + val fill = BucketFill(Color.WHITE, operations).fill(-150f, 50f, color)!! + operations.add(fill to PaintOptions(color = color, isFill = true)) + Canvas(bitmap).apply { + drawColor(Color.WHITE) + translate(200f, 100f) + drawPath(fill, Paint().apply { this.color = color }) + } + assertEquals(color, bitmap.getPixel(50, 150)) + assertEquals(color, bitmap.getPixel(108, 150)) + assertEquals(Color.WHITE, bitmap.getPixel(120, 150)) + assertEquals(Color.WHITE, bitmap.getPixel(350, 150)) + } + bitmap.recycle() + } + + private fun assertBitmapFill(pixels: List, seed: PointF, expected: List) { + val image = Bitmap.createBitmap(pixels.first().length, pixels.size, Bitmap.Config.ARGB_8888) + for (y in pixels.indices) for (x in pixels[y].indices) { + image.setPixel(x, y, if (pixels[y][x] == '#') Color.BLACK else Color.WHITE) + } + val fill = BucketFill(Color.WHITE, emptyList(), image).fill(seed.x, seed.y, Color.RED)!! + assertFalse(fill.isInverseFill) + for (y in expected.indices) for (x in expected[y].indices) { + assertEquals("pixel=$x,$y", if (expected[y][x] == '#') Color.RED else Color.WHITE, + sample(fill, x + 0.5f, y + 0.5f)) + } + image.recycle() + } + + private fun filler(path: Path, width: Float = 4f) = + BucketFill(Color.WHITE, listOf(path to PaintOptions(strokeWidth = width))) + + private fun box(left: Float, top: Float, right: Float, bottom: Float) = + Path().apply { addRect(RectF(left, top, right, bottom), Path.Direction.CW) } + + private fun sample(path: Path, x: Float, y: Float, color: Int = Color.RED): Int { + val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) + Canvas(bitmap).apply { + drawColor(Color.WHITE) + translate(0.5f - x, 0.5f - y) + drawPath(path, Paint().apply { this.color = color; style = Paint.Style.FILL; isAntiAlias = false }) + } + return bitmap.getPixel(0, 0).also { bitmap.recycle() } + } +} diff --git a/app/src/test/kotlin/org/fossify/paint/helpers/DrawingRegionsTest.kt b/app/src/test/kotlin/org/fossify/paint/helpers/DrawingRegionsTest.kt new file mode 100644 index 00000000..137a02ec --- /dev/null +++ b/app/src/test/kotlin/org/fossify/paint/helpers/DrawingRegionsTest.kt @@ -0,0 +1,95 @@ +package org.fossify.paint.helpers + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Path +import android.graphics.PointF +import org.fossify.paint.models.PaintOptions +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import kotlin.math.hypot + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], manifest = Config.NONE) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class DrawingRegionsTest { + @Test + fun blackDotDisappearsIntoBlackFillAcrossZoomAndFractionalPan() { + val enclosure = Path().apply { addRect(-1000f, -1000f, 1000f, 1000f, Path.Direction.CW) } + val dot = Path().apply { moveTo(0f, 0f); lineTo(0f, 0f) } + val operations = mutableListOf( + enclosure to PaintOptions(color = Color.BLUE, strokeWidth = 105f), + dot to PaintOptions(strokeWidth = 105f) + ) + val fill = BucketFill(Color.WHITE, operations).fill(200f, 0f, Color.BLACK)!! + operations.add(fill to PaintOptions(color = Color.BLACK, isFill = true)) + val regions = DrawingRegions(Color.WHITE, operations) + for ((scale, pan) in viewports()) { + val bitmap = render(regions, scale, pan, 52.5f) + assertBlackPatch(bitmap) + bitmap.recycle() + } + } + + @Test + fun adjoiningColorsHaveNoBackgroundFringeAcrossBrushSizes() { + val stroke = Path().apply { addCircle(0f, 0f, 80f, Path.Direction.CW) } + val color = Color.rgb(25, 118, 210) + for (width in listOf(0.2f, 6f, 105f)) { + val operations = mutableListOf(stroke to PaintOptions(strokeWidth = width)) + val fill = BucketFill(Color.WHITE, operations).fill(0f, 0f, color)!! + operations.add(fill to PaintOptions(color = color, isFill = true)) + val regions = DrawingRegions(Color.WHITE, operations) + for ((scale, pan) in viewports()) { + val bitmap = render(regions, scale, pan, 80f) + assertNoBackgroundFringe(bitmap, scale, pan, width) + if (width * scale >= 2f) { + val strokePixel = bitmap.getPixel(128, 128) + assertTrue("stroke width=$width scale=$scale pan=$pan color=$strokePixel", + maxOf(Color.red(strokePixel), Color.green(strokePixel), Color.blue(strokePixel)) <= 8) + } + bitmap.recycle() + } + } + } + + private fun viewports() = listOf(0.1f, 1f, 2.3f, 10f).flatMap { scale -> + listOf(0f, 0.25f, 0.5f, 0.75f).map { pan -> scale to pan } + } + + private fun assertBlackPatch(bitmap: Bitmap) { + for (y in 64 until 192) for (x in 64 until 192) { + assertEquals("pixel=$x,$y", Color.BLACK, bitmap.getPixel(x, y)) + } + } + + private fun assertNoBackgroundFringe(bitmap: Bitmap, scale: Float, pan: Float, width: Float) { + for (y in 0 until bitmap.height) for (x in 0 until bitmap.width) { + val worldX = (x + 0.5 - 128 - pan) / scale + 80 + val worldY = (y + 0.5 - 128 - pan) / scale + if (hypot(worldX, worldY) < 80 + width / 2 - 1 / scale) { + val pixel = bitmap.getPixel(x, y) + assertTrue("width=$width scale=$scale pan=$pan pixel=$x,$y color=$pixel", + Color.red(pixel) <= 27 && Color.green(pixel) <= 120 && Color.blue(pixel) <= 212) + } + } + } + + private fun render(regions: DrawingRegions, scale: Float, pan: Float, centerX: Float = 0f): Bitmap { + return Bitmap.createBitmap(256, 256, Bitmap.Config.ARGB_8888).apply { + Canvas(this).apply { + drawColor(Color.WHITE) + translate(128f + pan, 128f + pan) + scale(scale, scale) + translate(-centerX, 0f) + regions.draw(this, null, PointF(), Color.WHITE) + } + } + } +} diff --git a/app/src/test/kotlin/org/fossify/paint/views/CanvasFillTest.kt b/app/src/test/kotlin/org/fossify/paint/views/CanvasFillTest.kt new file mode 100644 index 00000000..bb7e3471 --- /dev/null +++ b/app/src/test/kotlin/org/fossify/paint/views/CanvasFillTest.kt @@ -0,0 +1,526 @@ +package org.fossify.paint.views + +import android.app.Activity +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF +import android.net.Uri +import android.os.Looper +import android.os.SystemClock +import android.view.MotionEvent +import android.view.ViewGroup +import androidx.core.graphics.PathParser +import org.fossify.paint.helpers.BucketFill +import org.fossify.paint.activities.MainActivity +import org.fossify.paint.extensions.config +import org.fossify.paint.helpers.PNG +import org.fossify.paint.helpers.SVG +import org.fossify.paint.models.MyPath +import org.fossify.paint.models.PaintOptions +import org.fossify.paint.models.Svg +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.OutputStream +import java.util.concurrent.Executor +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.xpath.XPathConstants +import javax.xml.xpath.XPathFactory + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class CanvasFillTest { + + @Test + fun fillUsesLocalCoordinatesAcrossViewOffsetsZoomAndPan() { + for (scale in listOf(0.1f, 1f, 10f)) { + val canvas = canvas() + canvas.layout(40, 80, 140, 180) + canvas.addOperation(box(-50f, -50f, 150f, 150f), PaintOptions(strokeWidth = 4f)) + val size = 100f / scale + canvas.setViewportBounds(RectF(50f - size / 2, 50f - size / 2, 50f + size / 2, 50f + size / 2)) + canvas.toggleBucketFill(true) + canvas.setColor(Color.RED) + tap(canvas, 50f, 50f) + shadowOf(Looper.getMainLooper()).idle() + canvas.setViewportBounds(RectF(-100f, -100f, 200f, 200f)) + val bitmap = canvas.settledBitmap() + assertEquals(Color.RED, bitmap.getPixel(50, 50)) + assertEquals(Color.RED, bitmap.getPixel(20, 20)) + assertEquals(Color.WHITE, bitmap.getPixel(5, 5)) + } + } + + @Test + fun canceledGestureDoesNotFillOrDestroyRedo() { + val canvas = canvas() + val pendingFills = ArrayDeque() + canvas.drawingExecutor = Executor { pendingFills.addLast(it) } + canvas.addOperation(box(20f, 20f, 80f, 80f), PaintOptions(strokeWidth = 4f)) + canvas.undo() + canvas.toggleBucketFill(true) + event(canvas, MotionEvent.ACTION_DOWN, 50f, 50f) + event(canvas, MotionEvent.ACTION_CANCEL, 50f, 50f) + finishFills(pendingFills) + assertTrue(canvas.getPathsMap().isEmpty()) + canvas.redo() + assertEquals(1, canvas.getPathsMap().size) + assertEquals(Color.BLACK, canvas.settledBitmap().getPixel(20, 50)) + } + + @Test + fun fillsAndBrushStrokesKeepTheirAcceptedOrder() { + val canvas = canvas() + val pendingFills = ArrayDeque() + canvas.drawingExecutor = Executor { pendingFills.addLast(it) } + canvas.toggleBucketFill(true) + canvas.setColor(Color.RED) + tap(canvas, 50f, 10f) + assertEquals(1, pendingFills.size) + addStroke(canvas, Color.BLACK, 50f) + assertEquals(Color.BLACK, canvas.settledBitmap().getPixel(50, 50)) + canvas.toggleBucketFill(true) + canvas.setColor(Color.BLUE) + tap(canvas, 50f, 10f) + finishFills(pendingFills) + assertEquals(3, canvas.getPathsMap().size) + assertEquals(Color.BLUE, canvas.settledBitmap().getPixel(50, 10)) + assertEquals(Color.BLACK, canvas.settledBitmap().getPixel(50, 50)) + canvas.undo() + assertTrue(canvas.hasPendingEdits()) + assertEquals(Color.BLUE, canvas.getBitmap().getPixel(50, 10)) + finishFills(pendingFills) + assertEquals(Color.RED, canvas.settledBitmap().getPixel(50, 10)) + assertEquals(Color.BLACK, canvas.settledBitmap().getPixel(50, 50)) + canvas.undo() + finishFills(pendingFills) + assertEquals(Color.RED, canvas.settledBitmap().getPixel(50, 50)) + canvas.undo() + finishFills(pendingFills) + assertEquals(Color.WHITE, canvas.settledBitmap().getPixel(50, 50)) + repeat(3) { canvas.redo() } + finishFills(pendingFills) + assertEquals(Color.BLUE, canvas.settledBitmap().getPixel(50, 10)) + assertEquals(Color.BLACK, canvas.settledBitmap().getPixel(50, 50)) + } + + @Test + fun viewportBelowMinimumZoomStillRespondsToPinchWithoutFilling() { + val canvas = canvas() + canvas.layout(0, 0, 500, 1000) + canvas.setViewportBounds(RectF(0f, 0f, 10000f, 10000f)) + canvas.toggleBucketFill(true) + canvas.setColor(Color.RED) + val before = canvas.getViewportBounds().width() + pinch(canvas) + shadowOf(Looper.getMainLooper()).idle() + assertTrue(canvas.getViewportBounds().width() < before) + assertTrue(canvas.getPathsMap().isEmpty()) + assertFalse(canvas.hasPendingEdits()) + } + + @Test + fun invalidSvgViewportLeavesTheExistingDrawingUntouched() { + val activity = Robolectric.buildActivity(MainActivity::class.java).setup().get() + shadowOf(Looper.getMainLooper()).idle() + val canvas = activityCanvas(activity) + canvas.updateBackgroundColor(Color.WHITE) + val image = Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.YELLOW) } + canvas.mBackgroundBitmap = image + canvas.addOperation(box(20f, 20f, 80f, 80f), PaintOptions(color = Color.BLUE, isFill = true)) + shadowOf(Looper.getMainLooper()).idle() + canvas.setViewportBounds(RectF(-100f, -100f, canvas.width - 100f, canvas.height - 100f)) + val viewport = canvas.getViewportBounds() + val paths = canvas.getPathsMap().mapValues { it.value.copy() } + val file = File(activity.cacheDir, "invalid-viewport.svg") + for (bounds in listOf("0 0 0 0", "0 0 -100 100", "0 0 NaN 100", "0 0 Infinity 100", "1e20 0 1 100")) { + file.writeText("""""") + deliverResult(activity, 1, Intent().setDataAndType(Uri.fromFile(file), "image/svg+xml")) + shadowOf(Looper.getMainLooper()).idle() + assertSame(image, canvas.mBackgroundBitmap) + assertEquals(paths, canvas.getPathsMap()) + assertEquals(viewport, canvas.getViewportBounds()) + } + file.delete() + } + + @Test + fun undoingClearRestoresTheDrawingAtItsPlaceInTheQueue() { + for (undoFill in listOf(false, true)) { + val canvas = canvas() + val pendingFills = ArrayDeque() + canvas.drawingExecutor = Executor { pendingFills.addLast(it) } + canvas.addOperation(box(20f, 20f, 80f, 80f), PaintOptions(strokeWidth = 4f)) + canvas.toggleBucketFill(true) + canvas.setColor(Color.RED) + tap(canvas, 50f, 30f) + if (undoFill) canvas.undo() else addStroke(canvas, Color.BLACK, 50f) + canvas.clearCanvas() + finishFills(pendingFills) + assertTrue(canvas.getPathsMap().isEmpty()) + canvas.undo() + finishFills(pendingFills) + assertEquals(if (undoFill) 1 else 3, canvas.getPathsMap().size) + assertEquals(Color.BLACK, canvas.settledBitmap().getPixel(20, 50)) + assertEquals(if (undoFill) Color.WHITE else Color.RED, canvas.settledBitmap().getPixel(50, 30)) + assertEquals(if (undoFill) Color.WHITE else Color.BLACK, canvas.settledBitmap().getPixel(50, 50)) + } + } + + @Test + fun queuedClearPreservesTheStrokeStartedAfterIt() { + val canvas = canvas() + val pendingFills = ArrayDeque() + canvas.drawingExecutor = Executor { pendingFills.addLast(it) } + canvas.toggleBucketFill(true) + canvas.setColor(Color.RED) + tap(canvas, 50f, 10f) + canvas.clearCanvas() + canvas.toggleBucketFill(false) + canvas.setColor(Color.BLACK) + canvas.setBrushSize(10f) + event(canvas, MotionEvent.ACTION_DOWN, 10f, 50f) + event(canvas, MotionEvent.ACTION_MOVE, 80f, 50f) + finishFills(pendingFills) + event(canvas, MotionEvent.ACTION_UP, 80f, 50f) + val bitmap = canvas.settledBitmap() + assertEquals(Color.BLACK, bitmap.getPixel(20, 50)) + assertEquals(Color.BLACK, bitmap.getPixel(70, 50)) + assertEquals(Color.WHITE, bitmap.getPixel(20, 20)) + assertEquals(1, canvas.getPathsMap().size) + } + + @Test + fun failedDeferredSavesLeaveTheDrawingUnsavedAndContinueQueuedEdits() { + for (extension in listOf(SVG, PNG)) { + DrawingStateHolder.operations = null + val activity = Robolectric.buildActivity(MainActivity::class.java).setup().get() + shadowOf(Looper.getMainLooper()).idle() + val canvas = activityCanvas(activity) + canvas.clearCanvas() + canvas.updateBackgroundColor(Color.WHITE) + canvas.layout(0, 0, 100, 100) + val pendingFills = ArrayDeque() + canvas.drawingExecutor = Executor { pendingFills.addLast(it) } + canvas.toggleBucketFill(true) + canvas.setColor(Color.RED) + tap(canvas, 50f, 10f) + var saved = false + val output = object : OutputStream() { + override fun write(value: Int) = Unit + override fun close() = throw IOException("Storage disconnected") + } + val onSaved: (Long) -> Unit = { saved = true } + if (extension == SVG) { + Svg.saveToOutputStream(activity, output, canvas, onSaved) + } else { + MainActivity::class.java.getDeclaredMethod( + "saveToOutputStream", OutputStream::class.java, Bitmap.CompressFormat::class.java, + Boolean::class.javaPrimitiveType, Function1::class.java + ).apply { isAccessible = true }.invoke(activity, output, Bitmap.CompressFormat.PNG, true, onSaved) + } + addStroke(canvas, Color.BLUE, 80f) + finishFills(pendingFills) + assertFalse(saved) + assertFalse(activity.isFinishing) + assertFalse(canvas.hasPendingEdits()) + assertEquals(Color.BLUE, canvas.settledBitmap().getPixel(20, 80)) + val backPress = MainActivity::class.java.getDeclaredMethod("onBackPressedCompat").apply { + isAccessible = true + } + assertTrue(backPress.invoke(activity) as Boolean) + } + } + + @Test + fun successfulSvgAndBitmapSavesDoNotPromptOnBack() { + for (extension in listOf(SVG, PNG)) { + RuntimeEnvironment.getApplication().config.lastSaveExtension = extension + DrawingStateHolder.operations = null + val activity = Robolectric.buildActivity(MainActivity::class.java).setup().get() + shadowOf(Looper.getMainLooper()).idle() + val canvas = activityCanvas(activity) + canvas.clearCanvas() + canvas.layout(0, 0, 100, 100) + canvas.addOperation(box(20f, 20f, 80f, 80f), PaintOptions(strokeWidth = 4f)) + val file = File(activity.cacheDir, "settled-drawing.$extension") + deliverResult(activity, 2, Intent().setData(Uri.fromFile(file))) + assertTrue(file.length() > 0) + val backPress = MainActivity::class.java.getDeclaredMethod("onBackPressedCompat").apply { + isAccessible = true + } + assertFalse("$extension save should allow closing", backPress.invoke(activity) as Boolean) + assertFalse(canvas.hasPendingEdits()) + canvas.addOperation(box(30f, 30f, 70f, 70f), PaintOptions(strokeWidth = 4f)) + assertTrue("$extension save should leave later edits unsaved", backPress.invoke(activity) as Boolean) + file.delete() + } + } + + @Test + fun svgAndBitmapSavingWaitForTheAcceptedDrawing() { + for (extension in listOf(SVG, PNG)) { + RuntimeEnvironment.getApplication().config.lastSaveExtension = extension + DrawingStateHolder.operations = null + val activity = Robolectric.buildActivity(MainActivity::class.java).setup().get() + shadowOf(Looper.getMainLooper()).idle() + val canvas = activityCanvas(activity) + canvas.clearCanvas() + canvas.updateBackgroundColor(Color.WHITE) + canvas.layout(0, 0, 100, 100) + val pendingFills = ArrayDeque() + canvas.drawingExecutor = Executor { pendingFills.addLast(it) } + canvas.toggleBucketFill(true) + canvas.setColor(Color.RED) + tap(canvas, 50f, 10f) + addStroke(canvas, Color.BLACK, 50f) + assertEquals(Color.BLACK, canvas.settledBitmap().getPixel(50, 50)) + val file = File(activity.cacheDir, "pending-drawing.$extension") + deliverResult(activity, 2, Intent().setData(Uri.fromFile(file))) + assertEquals(0L, file.length()) + val backPress = MainActivity::class.java.getDeclaredMethod("onBackPressedCompat").apply { + isAccessible = true + } + assertTrue(canvas.hasPendingEdits()) + addStroke(canvas, Color.BLUE, 80f) + finishFills(pendingFills) + assertEquals(Color.BLUE, canvas.settledBitmap().getPixel(20, 80)) + assertFalse(canvas.hasPendingEdits()) + assertTrue(backPress.invoke(activity) as Boolean) + if (extension == SVG) { + val saved = file.inputStream().use { Svg.parseSvg(it) } + assertEquals(2, saved.paths.size) + assertTrue(saved.paths.values.first().isFill) + assertEquals(Color.BLACK, saved.paths.values.last().color) + } else { + val saved = BitmapFactory.decodeFile(file.path) + assertEquals(Color.RED, saved.getPixel(50, 10)) + assertEquals(Color.BLACK, saved.getPixel(50, 50)) + assertEquals(Color.RED, saved.getPixel(20, 80)) + saved.recycle() + } + file.delete() + } + } + + @Test + fun svgSaveAndLoadPreserveFiniteAndInfiniteFillsWithHolesAndViewport() { + for (inverse in listOf(false, true)) { + DrawingStateHolder.operations = null + val activity = Robolectric.buildActivity(MainActivity::class.java).setup().get() + shadowOf(Looper.getMainLooper()).idle() + val canvas = activityCanvas(activity) + canvas.clearCanvas() + canvas.updateBackgroundColor(Color.WHITE) + canvas.addOperation(box(-50f, -50f, 150f, 150f), PaintOptions(strokeWidth = 4f)) + canvas.addOperation(box(20f, 20f, 80f, 80f), PaintOptions(strokeWidth = 4f)) + val seed = if (inverse) -90f else 0f + val fill = BucketFill(Color.WHITE, canvas.getPathsMap().map { (path, options) -> Path(path) to options }) + .fill(seed, seed, Color.RED)!! + canvas.addOperation(fill, PaintOptions(color = Color.RED, isFill = true)) + canvas.setViewportBounds(RectF(-100f, -100f, canvas.width - 100f, canvas.height - 100f)) + val before = canvas.settledBitmap() + val viewport = canvas.getViewportBounds() + val file = File(activity.cacheDir, "fill-roundtrip.svg") + Svg.saveToOutputStream(activity, FileOutputStream(file), canvas) + canvas.clearCanvas() + canvas.setViewportBounds(RectF(0f, 0f, canvas.width.toFloat(), canvas.height.toFloat())) + Svg.loadSvg(activity, file, canvas) + shadowOf(Looper.getMainLooper()).idle() + assertEquals(viewport, canvas.getViewportBounds()) + assertEquals(inverse, canvas.getPathsMap().keys.last().isInverseFill) + assertBitmapEquals(before, canvas.settledBitmap()) + + val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file) + val visiblePaths = XPathFactory.newInstance().newXPath() + .evaluate("/svg/g/path", document, XPathConstants.NODESET) as org.w3c.dom.NodeList + assertTrue(visiblePaths.length > 0) + val bitmap = Bitmap.createBitmap(300, 300, Bitmap.Config.ARGB_8888) + Canvas(bitmap).apply { + drawColor(Color.WHITE) + translate(100f, 100f) + for (index in 0 until visiblePaths.length) { + val element = visiblePaths.item(index) as org.w3c.dom.Element + assertEquals("evenodd", element.getAttribute("fill-rule")) + val path = PathParser.createPathFromPathData(element.getAttribute("d"))!! + path.fillType = Path.FillType.EVEN_ODD + val paint = Paint() + paint.color = Color.parseColor(element.getAttribute("fill")) + drawPath(path, paint) + } + } + assertEquals(Color.RED, bitmap.getPixel((seed + 100f).toInt(), (seed + 100f).toInt())) + assertEquals(Color.WHITE, bitmap.getPixel(150, 150)) + assertEquals(if (inverse) Color.WHITE else Color.RED, bitmap.getPixel(100, 100)) + if (inverse) { + val operations = canvas.getPathsMap().map { (path, options) -> Path(path) to options } + val recolored = BucketFill(Color.WHITE, operations).fill(-10000f, 10000f, Color.BLUE)!! + assertTrue(recolored.isInverseFill) + } + bitmap.recycle() + file.delete() + } + } + + @Test + fun legacySvgStrokesAndErasersStillLoad() { + val xml = """ + + + + """ + val drawing = Svg.parseSvg(xml.byteInputStream()) + assertEquals(2, drawing.paths.size) + assertFalse(drawing.paths.values.first().isFill) + assertTrue(drawing.paths.values.last().isEraser) + assertEquals(RectF(0f, 0f, 100f, 100f), drawing.viewport) + } + + @Test + fun compositedFillPreservesImportedPixelsAndUndoRedo() { + val canvas = canvas() + val image = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + for (y in 0 until 100) for (x in 0 until 100) { + image.setPixel(x, y, if (x < 50) Color.WHITE else Color.rgb(x, y, 100)) + } + canvas.mBackgroundBitmap = image + val divider = MyPath().apply { moveTo(50f, 0f); lineTo(50f, 100f) } + canvas.addOperation(divider, PaintOptions(strokeWidth = 4f)) + canvas.toggleBucketFill(true) + canvas.setColor(Color.RED) + tap(canvas, 20f, 50f) + shadowOf(Looper.getMainLooper()).idle() + val filled = canvas.settledBitmap() + assertEquals(Color.RED, filled.getPixel(20, 50)) + assertEquals(Color.BLACK, filled.getPixel(50, 50)) + for (y in 0 until 100) for (x in 55 until 100) { + assertEquals(image.getPixel(x, y), filled.getPixel(x, y)) + } + canvas.undo() + assertEquals(Color.WHITE, canvas.settledBitmap().getPixel(20, 50)) + canvas.redo() + assertBitmapEquals(filled, canvas.settledBitmap()) + canvas.clearCanvas() + assertEquals(Color.WHITE, canvas.settledBitmap().getPixel(20, 50)) + canvas.undo() + assertBitmapEquals(filled, canvas.settledBitmap()) + } + + private fun activityCanvas(activity: MainActivity) = + activity.findViewById(org.fossify.paint.R.id.my_canvas).apply { + drawingExecutor = Executor { it.run() } + } + + private fun MyCanvas.settledBitmap(): Bitmap { + shadowOf(Looper.getMainLooper()).idle() + return getBitmap() + } + + private fun canvas(): MyCanvas { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val canvas = MyCanvas(activity, Robolectric.buildAttributeSet().build()) + canvas.drawingExecutor = Executor { it.run() } + canvas.updateBackgroundColor(Color.WHITE) + activity.setContentView(canvas, ViewGroup.LayoutParams(100, 100)) + shadowOf(Looper.getMainLooper()).idle() + canvas.layout(0, 0, 100, 100) + return canvas + } + + private fun deliverResult(activity: MainActivity, requestCode: Int, data: Intent) { + MainActivity::class.java.getDeclaredMethod( + "onActivityResult", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, Intent::class.java + ).apply { isAccessible = true }.invoke(activity, requestCode, Activity.RESULT_OK, data) + } + + private fun box(left: Float, top: Float, right: Float, bottom: Float) = MyPath().apply { + moveTo(left, top) + lineTo(right, top) + lineTo(right, bottom) + lineTo(left, bottom) + close() + } + + private fun tap(canvas: MyCanvas, x: Float, y: Float) { + event(canvas, MotionEvent.ACTION_DOWN, x, y) + event(canvas, MotionEvent.ACTION_UP, x, y) + } + + private fun event(canvas: MyCanvas, action: Int, x: Float, y: Float) { + MotionEvent.obtain(0, 10, action, x, y, 0).also { + canvas.onTouchEvent(it) + it.recycle() + } + } + + private fun pinch(canvas: MyCanvas) { + val start = SystemClock.uptimeMillis() + val centerX = canvas.width / 2f + val centerY = canvas.height / 2f + val points = arrayOf( + MotionEvent.PointerCoords().apply { x = centerX - 150f; y = centerY; pressure = 1f; size = 1f }, + MotionEvent.PointerCoords().apply { x = centerX + 150f; y = centerY; pressure = 1f; size = 1f } + ) + val properties = Array(2) { index -> + MotionEvent.PointerProperties().apply { id = index; toolType = MotionEvent.TOOL_TYPE_FINGER } + } + fun send(action: Int, count: Int, time: Long) { + MotionEvent.obtain(start, time, action, count, properties, points, 0, 0, 1f, 1f, 0, 0, 0, 0).also { + canvas.onTouchEvent(it) + it.recycle() + } + } + send(MotionEvent.ACTION_DOWN, 1, start) + send(MotionEvent.ACTION_POINTER_DOWN or (1 shl MotionEvent.ACTION_POINTER_INDEX_SHIFT), 2, start + 10) + var span = 300f + for (step in 1..8) { + span *= 1.05f + points[0].x = centerX - span / 2 + points[1].x = centerX + span / 2 + send(MotionEvent.ACTION_MOVE, 2, start + 10 + step * 20) + } + send(MotionEvent.ACTION_POINTER_UP or (1 shl MotionEvent.ACTION_POINTER_INDEX_SHIFT), 2, start + 200) + send(MotionEvent.ACTION_UP, 1, start + 210) + } + + private fun addStroke(canvas: MyCanvas, color: Int, y: Float) { + val path = MyPath().apply { moveTo(10f, y); lineTo(80f, y) } + canvas.addOperation(path, PaintOptions(color = color, strokeWidth = 5f)) + } + + private fun finishFills(pendingFills: ArrayDeque) { + shadowOf(Looper.getMainLooper()).idle() + while (pendingFills.isNotEmpty()) { + pendingFills.removeFirst().run() + shadowOf(Looper.getMainLooper()).idle() + } + } + + private fun assertBitmapEquals(expected: Bitmap, actual: Bitmap) { + val first = IntArray(expected.width * expected.height) + val second = IntArray(actual.width * actual.height) + expected.getPixels(first, 0, expected.width, 0, 0, expected.width, expected.height) + actual.getPixels(second, 0, actual.width, 0, 0, actual.width, actual.height) + assertArrayEquals(first, second) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7b50211b..bfb2b02e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,4 +1,9 @@ [versions] +junit = "4.13.2" +robolectric = "4.17" +androidx-test = "1.7.0" +androidx-test-junit = "1.3.0" +clipper2 = "2.0.1b" #jetbrains kotlin = "2.3.10" #Detekt @@ -17,6 +22,11 @@ app-build-minimumSDK = "26" app-build-javaVersion = "VERSION_17" app-build-kotlinJVMTarget = "17" [libraries] +clipper2 = { module = "com.github.micycle1:Clipper2-java", version.ref = "clipper2" } +androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test" } +androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" } +junit = { module = "junit:junit", version.ref = "junit" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } #AndroidX androidx-print = { module = "androidx.print:print", version.ref = "androidx-print" } #Compose