From a73d58ee147adfe1593ce125f91179a47033b41d Mon Sep 17 00:00:00 2001 From: Sebastian Jug Date: Wed, 29 Apr 2026 15:17:58 -0400 Subject: [PATCH 1/2] fix(editor): remove unaligned native photo filters library --- app/build.gradle.kts | 1 - app/lint-baseline.xml | 25 -- .../com/zomato/photofilters/FilterPack.kt | 313 ++++++++++++++++++ .../kotlin/com/zomato/photofilters/README.md | 5 + .../photofilters/geometry/BezierSpline.kt | 129 ++++++++ .../com/zomato/photofilters/geometry/Point.kt | 5 + .../photofilters/imageprocessors/Filter.kt | 121 +++++++ .../imageprocessors/ImageProcessor.kt | 204 ++++++++++++ .../photofilters/imageprocessors/SubFilter.kt | 11 + .../imageprocessors/subfilters/SubFilters.kt | 154 +++++++++ .../gallery/activities/EditActivity.kt | 4 - gradle/libs.versions.toml | 2 - 12 files changed, 942 insertions(+), 32 deletions(-) create mode 100644 app/src/main/kotlin/com/zomato/photofilters/FilterPack.kt create mode 100644 app/src/main/kotlin/com/zomato/photofilters/README.md create mode 100644 app/src/main/kotlin/com/zomato/photofilters/geometry/BezierSpline.kt create mode 100644 app/src/main/kotlin/com/zomato/photofilters/geometry/Point.kt create mode 100644 app/src/main/kotlin/com/zomato/photofilters/imageprocessors/Filter.kt create mode 100644 app/src/main/kotlin/com/zomato/photofilters/imageprocessors/ImageProcessor.kt create mode 100644 app/src/main/kotlin/com/zomato/photofilters/imageprocessors/SubFilter.kt create mode 100644 app/src/main/kotlin/com/zomato/photofilters/imageprocessors/subfilters/SubFilters.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 847dab505b..1889a308c8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -153,7 +153,6 @@ dependencies { implementation(libs.androidx.documentfile) implementation(libs.androidx.media3.exoplayer) implementation(libs.sanselan) - implementation(libs.androidphotofilters) implementation(libs.androidsvg.aar) implementation(libs.gestureviews) implementation(libs.subsamplingscaleimageview) diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index 4c2117613e..9331bd5be8 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -210,31 +210,6 @@ column="17"/> - - - - - - - - - - - - { + val filters: MutableList = ArrayList() + filters.add(getAweStruckVibeFilter(context)) + filters.add(getClarendon(context)) + filters.add(getOldManFilter(context)) + filters.add(getMarsFilter(context)) + filters.add(getRiseFilter(context)) + filters.add(getAprilFilter(context)) + filters.add(getAmazonFilter(context)) + filters.add(getStarLitFilter(context)) + filters.add(getNightWhisperFilter(context)) + filters.add(getLimeStutterFilter(context)) + filters.add(getHaanFilter(context)) + filters.add(getBlueMessFilter(context)) + filters.add(getAdeleFilter(context)) + filters.add(getCruzFilter(context)) + filters.add(getMetropolis(context)) + filters.add(getAudreyFilter(context)) + return filters + } + + fun getStarLitFilter(context: Context): Filter { + val rgbKnots = arrayOfNulls(8) + rgbKnots[0] = Point(0f, 0f) + rgbKnots[1] = Point(34f, 6f) + rgbKnots[2] = Point(69f, 23f) + rgbKnots[3] = Point(100f, 58f) + rgbKnots[4] = Point(150f, 154f) + rgbKnots[5] = Point(176f, 196f) + rgbKnots[6] = Point(207f, 233f) + rgbKnots[7] = Point(255f, 255f) + val filter = Filter() + filter.name = "Starlit" + filter.addSubFilter(ToneCurveSubFilter(rgbKnots, null, null, null)) + return filter + } + + fun getBlueMessFilter(context: Context): Filter { + val redKnots = arrayOfNulls(8) + redKnots[0] = Point(0f, 0f) + redKnots[1] = Point(86f, 34f) + redKnots[2] = Point(117f, 41f) + redKnots[3] = Point(146f, 80f) + redKnots[4] = Point(170f, 151f) + redKnots[5] = Point(200f, 214f) + redKnots[6] = Point(225f, 242f) + redKnots[7] = Point(255f, 255f) + val filter = Filter() + filter.name = "BlueMess" + filter.addSubFilter(ToneCurveSubFilter(null, redKnots, null, null)) + filter.addSubFilter(BrightnessSubFilter(30)) + filter.addSubFilter(ContrastSubFilter(1f)) + return filter + } + + fun getAweStruckVibeFilter(context: Context): Filter { + val rgbKnots = arrayOfNulls(5) + rgbKnots[0] = Point(0f, 0f) + rgbKnots[1] = Point(80f, 43f) + rgbKnots[2] = Point(149f, 102f) + rgbKnots[3] = Point(201f, 173f) + rgbKnots[4] = Point(255f, 255f) + + val redKnots = arrayOfNulls(5) + redKnots[0] = Point(0f, 0f) + redKnots[1] = Point(125f, 147f) + redKnots[2] = Point(177f, 199f) + redKnots[3] = Point(213f, 228f) + redKnots[4] = Point(255f, 255f) + + + val greenKnots = arrayOfNulls(6) + greenKnots[0] = Point(0f, 0f) + greenKnots[1] = Point(57f, 76f) + greenKnots[2] = Point(103f, 130f) + greenKnots[3] = Point(167f, 192f) + greenKnots[4] = Point(211f, 229f) + greenKnots[5] = Point(255f, 255f) + + + val blueKnots = arrayOfNulls(7) + blueKnots[0] = Point(0f, 0f) + blueKnots[1] = Point(38f, 62f) + blueKnots[2] = Point(75f, 112f) + blueKnots[3] = Point(116f, 158f) + blueKnots[4] = Point(171f, 204f) + blueKnots[5] = Point(212f, 233f) + blueKnots[6] = Point(255f, 255f) + + val filter = Filter() + filter.name = "Struck" + filter.addSubFilter(ToneCurveSubFilter(rgbKnots, redKnots, greenKnots, blueKnots)) + return filter + } + + fun getLimeStutterFilter(context: Context): Filter { + val blueKnots = arrayOfNulls(3) + blueKnots[0] = Point(0f, 0f) + blueKnots[1] = Point(165f, 114f) + blueKnots[2] = Point(255f, 255f) + val filter = Filter() + filter.name = "Lime" + filter.addSubFilter(ToneCurveSubFilter(null, null, null, blueKnots)) + return filter + } + + fun getNightWhisperFilter(context: Context): Filter { + val rgbKnots = arrayOfNulls(3) + rgbKnots[0] = Point(0f, 0f) + rgbKnots[1] = Point(174f, 109f) + rgbKnots[2] = Point(255f, 255f) + + val redKnots = arrayOfNulls(4) + redKnots[0] = Point(0f, 0f) + redKnots[1] = Point(70f, 114f) + redKnots[2] = Point(157f, 145f) + redKnots[3] = Point(255f, 255f) + + val greenKnots = arrayOfNulls(3) + greenKnots[0] = Point(0f, 0f) + greenKnots[1] = Point(109f, 138f) + greenKnots[2] = Point(255f, 255f) + + val blueKnots = arrayOfNulls(3) + blueKnots[0] = Point(0f, 0f) + blueKnots[1] = Point(113f, 152f) + blueKnots[2] = Point(255f, 255f) + + val filter = Filter() + filter.name = "Whisper" + filter.addSubFilter(ContrastSubFilter(1.5f)) + filter.addSubFilter(ToneCurveSubFilter(rgbKnots, redKnots, greenKnots, blueKnots)) + return filter + } + + fun getAmazonFilter(context: Context): Filter { + val blueKnots = arrayOfNulls(6) + blueKnots[0] = Point(0f, 0f) + blueKnots[1] = Point(11f, 40f) + blueKnots[2] = Point(36f, 99f) + blueKnots[3] = Point(86f, 151f) + blueKnots[4] = Point(167f, 209f) + blueKnots[5] = Point(255f, 255f) + val filter = Filter("Amazon") + filter.addSubFilter(ContrastSubFilter(1.2f)) + filter.addSubFilter(ToneCurveSubFilter(null, null, null, blueKnots)) + return filter + } + + fun getAdeleFilter(context: Context): Filter { + val filter = Filter("Adele") + filter.addSubFilter(SaturationSubFilter(-100f)) + return filter + } + + fun getCruzFilter(context: Context): Filter { + val filter = Filter("Cruz") + filter.addSubFilter(SaturationSubFilter(-100f)) + filter.addSubFilter(ContrastSubFilter(1.3f)) + filter.addSubFilter(BrightnessSubFilter(20)) + return filter + } + + fun getMetropolis(context: Context): Filter { + val filter = Filter("Metropolis") + filter.addSubFilter(SaturationSubFilter(-1f)) + filter.addSubFilter(ContrastSubFilter(1.7f)) + filter.addSubFilter(BrightnessSubFilter(70)) + return filter + } + + fun getAudreyFilter(context: Context): Filter { + val filter = Filter("Audrey") + val redKnots = arrayOfNulls(3) + redKnots[0] = Point(0f, 0f) + redKnots[1] = Point(124f, 138f) + redKnots[2] = Point(255f, 255f) + + filter.addSubFilter(SaturationSubFilter(-100f)) + filter.addSubFilter(ContrastSubFilter(1.3f)) + filter.addSubFilter(BrightnessSubFilter(20)) + filter.addSubFilter(ToneCurveSubFilter(null, redKnots, null, null)) + return filter + } + + fun getRiseFilter(context: Context): Filter { + val blueKnots = arrayOfNulls(4) + blueKnots[0] = Point(0f, 0f) + blueKnots[1] = Point(39f, 70f) + blueKnots[2] = Point(150f, 200f) + blueKnots[3] = Point(255f, 255f) + + val redKnots = arrayOfNulls(4) + redKnots[0] = Point(0f, 0f) + redKnots[1] = Point(45f, 64f) + redKnots[2] = Point(170f, 190f) + redKnots[3] = Point(255f, 255f) + + val filter = Filter("Rise") + filter.addSubFilter(ContrastSubFilter(1.9f)) + filter.addSubFilter(BrightnessSubFilter(60)) + filter.addSubFilter(VignetteSubFilter(context, 200)) + filter.addSubFilter(ToneCurveSubFilter(null, redKnots, null, blueKnots)) + return filter + } + + fun getMarsFilter(context: Context): Filter { + val filter = Filter("Mars") + filter.addSubFilter(ContrastSubFilter(1.5f)) + filter.addSubFilter(BrightnessSubFilter(10)) + return filter + } + + fun getAprilFilter(context: Context): Filter { + val blueKnots = arrayOfNulls(4) + blueKnots[0] = Point(0f, 0f) + blueKnots[1] = Point(39f, 70f) + blueKnots[2] = Point(150f, 200f) + blueKnots[3] = Point(255f, 255f) + + val redKnots = arrayOfNulls(4) + redKnots[0] = Point(0f, 0f) + redKnots[1] = Point(45f, 64f) + redKnots[2] = Point(170f, 190f) + redKnots[3] = Point(255f, 255f) + + val filter = Filter("April") + filter.addSubFilter(ContrastSubFilter(1.5f)) + filter.addSubFilter(BrightnessSubFilter(5)) + filter.addSubFilter(VignetteSubFilter(context, 150)) + filter.addSubFilter(ToneCurveSubFilter(null, redKnots, null, blueKnots)) + return filter + } + + fun getHaanFilter(context: Context): Filter { + val greenKnots = arrayOfNulls(3) + greenKnots[0] = Point(0f, 0f) + greenKnots[1] = Point(113f, 142f) + greenKnots[2] = Point(255f, 255f) + + val filter = Filter("Haan") + filter.addSubFilter(ContrastSubFilter(1.3f)) + filter.addSubFilter(BrightnessSubFilter(60)) + filter.addSubFilter(VignetteSubFilter(context, 200)) + filter.addSubFilter(ToneCurveSubFilter(null, null, greenKnots, null)) + return filter + } + + fun getOldManFilter(context: Context): Filter { + val filter = Filter("OldMan") + filter.addSubFilter(BrightnessSubFilter(30)) + filter.addSubFilter(SaturationSubFilter(0.8f)) + filter.addSubFilter(ContrastSubFilter(1.3f)) + filter.addSubFilter(VignetteSubFilter(context, 100)) + filter.addSubFilter(ColorOverlaySubFilter(100, .2f, .2f, .1f)) + return filter + } + + fun getClarendon(context: Context): Filter { + val redKnots = arrayOfNulls(4) + redKnots[0] = Point(0f, 0f) + redKnots[1] = Point(56f, 68f) + redKnots[2] = Point(196f, 206f) + redKnots[3] = Point(255f, 255f) + + + val greenKnots = arrayOfNulls(4) + greenKnots[0] = Point(0f, 0f) + greenKnots[1] = Point(46f, 77f) + greenKnots[2] = Point(160f, 200f) + greenKnots[3] = Point(255f, 255f) + + + val blueKnots = arrayOfNulls(4) + blueKnots[0] = Point(0f, 0f) + blueKnots[1] = Point(33f, 86f) + blueKnots[2] = Point(126f, 220f) + blueKnots[3] = Point(255f, 255f) + + val filter = Filter("Clarendon") + filter.addSubFilter(ContrastSubFilter(1.5f)) + filter.addSubFilter(BrightnessSubFilter(-10)) + filter.addSubFilter(ToneCurveSubFilter(null, redKnots, greenKnots, blueKnots)) + return filter + } +} diff --git a/app/src/main/kotlin/com/zomato/photofilters/README.md b/app/src/main/kotlin/com/zomato/photofilters/README.md new file mode 100644 index 0000000000..bab60e4fba --- /dev/null +++ b/app/src/main/kotlin/com/zomato/photofilters/README.md @@ -0,0 +1,5 @@ +# AndroidPhotoFilters compatibility shim + +This package keeps the small AndroidPhotoFilters API surface used by Gallery while avoiding the upstream prebuilt native library. + +The filter API and preset definitions are based on `naveensingh/AndroidPhotoFilters` / `com.github.naveensingh:androidphotofilters` (Apache-2.0). The pixel operations formerly delegated to `libNativeImageProcessor.so` are implemented in Kotlin so the APK no longer ships the 4 KB-aligned native library that fails 16 KB page-size compatibility checks. diff --git a/app/src/main/kotlin/com/zomato/photofilters/geometry/BezierSpline.kt b/app/src/main/kotlin/com/zomato/photofilters/geometry/BezierSpline.kt new file mode 100644 index 0000000000..2bbf982c70 --- /dev/null +++ b/app/src/main/kotlin/com/zomato/photofilters/geometry/BezierSpline.kt @@ -0,0 +1,129 @@ +@file:Suppress("MagicNumber", "LongMethod", "ReturnCount", "LongParameterList") + +package com.zomato.photofilters.geometry + +import android.graphics.Path +import android.view.animation.PathInterpolator + +object BezierSpline { + /** + * Generates Curve {in a plane ranging from 0-255} using the knots provided + */ + fun curveGenerator(knots: Array?): IntArray { + if (knots == null) { + throw NullPointerException("Knots cannot be null") + } + + val n = knots.size - 1 + require(n >= 1) { "At least two points are required" } + + return getOutputPointsForNewerDevices(knots) + } + + // This is for lollipop and newer devices + private fun getOutputPointsForNewerDevices(knots: Array): IntArray { + val controlPoints = calculateControlPoints(knots) + val path = Path() + path.moveTo(0f, 0f) + path.lineTo(knots[0]!!.x / 255.0f, knots[0]!!.y / 255.0f) + path.moveTo(knots[0]!!.x / 255.0f, knots[0]!!.y / 255.0f) + + for (index in 1 until knots.size) { + path.quadTo( + controlPoints[index - 1]!!.x / 255.0f, + controlPoints[index - 1]!!.y / 255.0f, + knots[index]!!.x / 255.0f, + knots[index]!!.y / 255.0f + ) + path.moveTo(knots[index]!!.x / 255.0f, knots[index]!!.y / 255.0f) + } + + path.lineTo(1f, 1f) + path.moveTo(1f, 1f) + + val allPoints = FloatArray(256) + + for (x in 0..255) { + val pathInterpolator = PathInterpolator(path) + allPoints[x] = 255.0f * pathInterpolator.getInterpolation(x.toFloat() / 255.0f) + } + + allPoints[0] = knots[0]!!.y + allPoints[255] = knots[knots.size - 1]!!.y + return validateCurve(allPoints) + } + + private fun validateCurve(allPoints: FloatArray): IntArray { + val curvedPath = IntArray(256) + for (x in 0..255) { + if (allPoints[x] > 255.0f) { + curvedPath[x] = 255 + } else if (allPoints[x] < 0.0f) { + curvedPath[x] = 0 + } else { + curvedPath[x] = Math.round(allPoints[x]) + } + } + return curvedPath + } + + // Calculates the control points for the specified knots + private fun calculateControlPoints(knots: Array): Array { + val n = knots.size - 1 + val controlPoints = arrayOfNulls(n) + + if (n == 1) { // Special case: Bezier curve should be a straight line. + // 3P1 = 2P0 + P3 + controlPoints[0] = + Point((2 * knots[0]!!.x + knots[1]!!.x) / 3, (2 * knots[0]!!.y + knots[1]!!.y) / 3) + // P2 = 2P1 – P0 + //controlPoints[1][0] = new Point(2*controlPoints[0][0].x - knots[0].x, 2*controlPoints[0][0].y-knots[0].y); + } else { + // Calculate first Bezier control points + // Right hand side vector + val rhs = FloatArray(n) + + // Set right hand side x values + for (i in 1 until n - 1) { + rhs[i] = 4 * knots[i]!!.x + 2 * knots[i + 1]!!.x + } + rhs[0] = knots[0]!!.x + 2 * knots[1]!!.x + rhs[n - 1] = (8 * knots[n - 1]!!.x + knots[n]!!.x) / 2.0f + // Get first control points x-values + val x = getFirstControlPoints(rhs) + + // Set right hand side y values + for (i in 1 until n - 1) { + rhs[i] = 4 * knots[i]!!.y + 2 * knots[i + 1]!!.y + } + rhs[0] = knots[0]!!.y + 2 * knots[1]!!.y + rhs[n - 1] = (8 * knots[n - 1]!!.y + knots[n]!!.y) / 2.0f + // Get first control points y-values + val y = getFirstControlPoints(rhs) + for (i in 0 until n) { + controlPoints[i] = Point(x[i], y[i]) + } + } + + return controlPoints + } + + private fun getFirstControlPoints(rhs: FloatArray): FloatArray { + val n = rhs.size + val x = FloatArray(n) // Solution vector. + val tmp = FloatArray(n) // Temp workspace. + + var b = 1.0f // Control Point Factor + x[0] = rhs[0] / b + for (i in 1 until n) // Decomposition and forward substitution. + { + tmp[i] = 1 / b + b = (if (i < n - 1) 4.0f else 3.5f) - tmp[i] + x[i] = (rhs[i] - x[i - 1]) / b + } + for (i in 1 until n) { + x[n - i - 1] -= tmp[n - i] * x[n - i] // Back substitution. + } + return x + } +} diff --git a/app/src/main/kotlin/com/zomato/photofilters/geometry/Point.kt b/app/src/main/kotlin/com/zomato/photofilters/geometry/Point.kt new file mode 100644 index 0000000000..65261e992e --- /dev/null +++ b/app/src/main/kotlin/com/zomato/photofilters/geometry/Point.kt @@ -0,0 +1,5 @@ +@file:Suppress("MagicNumber", "LongMethod", "ReturnCount", "LongParameterList") + +package com.zomato.photofilters.geometry + +class Point(var x: Float, var y: Float) diff --git a/app/src/main/kotlin/com/zomato/photofilters/imageprocessors/Filter.kt b/app/src/main/kotlin/com/zomato/photofilters/imageprocessors/Filter.kt new file mode 100644 index 0000000000..743617cd72 --- /dev/null +++ b/app/src/main/kotlin/com/zomato/photofilters/imageprocessors/Filter.kt @@ -0,0 +1,121 @@ +@file:Suppress( + "ExplicitGarbageCollectionCall", + "LongMethod", + "LongParameterList", + "MagicNumber", + "NestedBlockDepth", + "ReturnCount", + "SwallowedException" +) + +package com.zomato.photofilters.imageprocessors + +import android.graphics.Bitmap +import com.zomato.photofilters.imageprocessors.subfilters.BrightnessSubFilter +import com.zomato.photofilters.imageprocessors.subfilters.ColorOverlaySubFilter +import com.zomato.photofilters.imageprocessors.subfilters.ContrastSubFilter +import com.zomato.photofilters.imageprocessors.subfilters.SaturationSubFilter +import com.zomato.photofilters.imageprocessors.subfilters.ToneCurveSubFilter +import com.zomato.photofilters.imageprocessors.subfilters.VignetteSubFilter + +/** + * Represents an image filter containing subfilters that are applied in insertion order. + */ +class Filter { + private val subFilters: MutableList = ArrayList() + var name: String? = null + + constructor(filter: Filter) { + subFilters.addAll(filter.subFilters) + name = filter.name + } + + constructor() + + constructor(name: String?) { + this.name = name + } + + /** + * Adds a Subfilter to the Main Filter. + * + * @see BrightnessSubFilter + * @see ColorOverlaySubFilter + * @see ContrastSubFilter + * @see ToneCurveSubFilter + * @see VignetteSubFilter + * @see SaturationSubFilter + */ + fun addSubFilter(subFilter: SubFilter) { + subFilters.add(subFilter) + } + + /** + * Adds all [SubFilter]s from the List to the Main Filter. + */ + fun addSubFilters(subFilterList: List?) { + subFilterList?.let(subFilters::addAll) + } + + /** + * Get a new list of currently applied subfilters. + */ + fun getSubFilters(): List { + if (subFilters.isEmpty()) return ArrayList(0) + return ArrayList(subFilters) + } + + /** + * Clears all the subfilters from the Parent Filter. + */ + fun clearSubFilters() { + subFilters.clear() + } + + /** + * Removes the subfilter containing Tag from the Parent Filter. + */ + fun removeSubFilterWithTag(tag: String) { + val iterator = subFilters.iterator() + while (iterator.hasNext()) { + val subFilter = iterator.next() + if (subFilter.tag == tag) { + iterator.remove() + } + } + } + + /** + * Returns The filter containing Tag. + */ + fun getSubFilterByTag(tag: String): SubFilter? { + for (subFilter in subFilters) { + if (subFilter.tag == tag) { + return subFilter + } + } + return null + } + + /** + * Give the output Bitmap by applying the defined filter. + */ + fun processFilter(inputImage: Bitmap?): Bitmap? { + var outputImage = inputImage + if (outputImage != null) { + for (subFilter in subFilters) { + try { + outputImage = subFilter.process(outputImage) + } catch (oe: OutOfMemoryError) { + System.gc() + try { + outputImage = subFilter.process(outputImage) + } catch (ignored: OutOfMemoryError) { + } + } + } + } + + return outputImage + } +} diff --git a/app/src/main/kotlin/com/zomato/photofilters/imageprocessors/ImageProcessor.kt b/app/src/main/kotlin/com/zomato/photofilters/imageprocessors/ImageProcessor.kt new file mode 100644 index 0000000000..1a7acf3506 --- /dev/null +++ b/app/src/main/kotlin/com/zomato/photofilters/imageprocessors/ImageProcessor.kt @@ -0,0 +1,204 @@ +@file:Suppress("LongMethod", "MagicNumber", "MaxLineLength", "TooManyFunctions") + +package com.zomato.photofilters.imageprocessors + +import android.graphics.Bitmap +import kotlin.math.max +import kotlin.math.min + +/** + * Pure Kotlin implementation of AndroidPhotoFilters' pixel operations. + * + * The upstream dependency ships libNativeImageProcessor.so, whose prebuilt arm64 ELF is only 4 KB + * aligned. Keeping these operations in Kotlin removes that native library from Gallery's APK and + * makes the editor compatible with 16 KB page-size devices. + */ +object ImageProcessor { + fun applyCurves( + rgb: IntArray?, + red: IntArray?, + green: IntArray?, + blue: IntArray?, + inputImage: Bitmap? + ): Bitmap { + val outputImage = requireNotNull(inputImage) + val width = outputImage.width + val height = outputImage.height + var pixels = IntArray(width * height) + outputImage.getPixels(pixels, 0, width, 0, 0, width, height) + + if (rgb != null) { + pixels = applyRGBCurve(pixels, rgb) + } + + if (!(red == null && green == null && blue == null)) { + pixels = applyChannelCurves(pixels, red, green, blue) + } + + try { + outputImage.setPixels(pixels, 0, width, 0, 0, width, height) + } catch (_: IllegalStateException) { + } + return outputImage + } + + fun doBrightness(value: Int, inputImage: Bitmap?): Bitmap { + return processPixels(requireNotNull(inputImage)) { pixel -> + val red = clampColor(((pixel shr RED_SHIFT) and CHANNEL_MASK) + value) + val green = clampColor(((pixel shr GREEN_SHIFT) and CHANNEL_MASK) + value) + val blue = clampColor((pixel and CHANNEL_MASK) + value) + (pixel and ALPHA_MASK) or (red shl RED_SHIFT) or (green shl GREEN_SHIFT) or blue + } + } + + fun doContrast(value: Float, inputImage: Bitmap?): Bitmap { + return processPixels(requireNotNull(inputImage)) { pixel -> + val red = contrastChannel((pixel shr RED_SHIFT) and CHANNEL_MASK, value) + val green = contrastChannel((pixel shr GREEN_SHIFT) and CHANNEL_MASK, value) + val blue = contrastChannel(pixel and CHANNEL_MASK, value) + (pixel and ALPHA_MASK) or (red shl RED_SHIFT) or (green shl GREEN_SHIFT) or blue + } + } + + fun doColorOverlay( + depth: Int, + red: Float, + green: Float, + blue: Float, + inputImage: Bitmap? + ): Bitmap { + return processPixels(requireNotNull(inputImage)) { pixel -> + val outRed = clampColor(((pixel shr RED_SHIFT) and CHANNEL_MASK) + (depth * red).toInt()) + val outGreen = clampColor(((pixel shr GREEN_SHIFT) and CHANNEL_MASK) + (depth * green).toInt()) + val outBlue = clampColor((pixel and CHANNEL_MASK) + (depth * blue).toInt()) + (pixel and ALPHA_MASK) or (outRed shl RED_SHIFT) or (outGreen shl GREEN_SHIFT) or outBlue + } + } + + fun doSaturation(inputImage: Bitmap?, level: Float): Bitmap { + return processPixels(requireNotNull(inputImage)) { pixel -> + saturatePixel(pixel, level) + } + } + + private fun applyRGBCurve(pixels: IntArray, rgb: IntArray): IntArray { + for (index in pixels.indices) { + val pixel = pixels[index] + val red = rgb[(pixel shr RED_SHIFT) and CHANNEL_MASK] + val green = rgb[(pixel shr GREEN_SHIFT) and CHANNEL_MASK] + val blue = rgb[pixel and CHANNEL_MASK] + pixels[index] = (pixel and ALPHA_MASK) or (red shl RED_SHIFT) or (green shl GREEN_SHIFT) or blue + } + return pixels + } + + private fun applyChannelCurves(pixels: IntArray, red: IntArray?, green: IntArray?, blue: IntArray?): IntArray { + for (index in pixels.indices) { + val pixel = pixels[index] + val outRed = red?.get((pixel shr RED_SHIFT) and CHANNEL_MASK) ?: ((pixel shr RED_SHIFT) and CHANNEL_MASK) + val outGreen = green?.get((pixel shr GREEN_SHIFT) and CHANNEL_MASK) ?: ((pixel shr GREEN_SHIFT) and CHANNEL_MASK) + val outBlue = blue?.get(pixel and CHANNEL_MASK) ?: (pixel and CHANNEL_MASK) + pixels[index] = (pixel and ALPHA_MASK) or (outRed shl RED_SHIFT) or (outGreen shl GREEN_SHIFT) or outBlue + } + return pixels + } + + private inline fun processPixels(inputImage: Bitmap, transform: (Int) -> Int): Bitmap { + val width = inputImage.width + val height = inputImage.height + val pixels = IntArray(width * height) + inputImage.getPixels(pixels, 0, width, 0, 0, width, height) + for (index in pixels.indices) { + pixels[index] = transform(pixels[index]) + } + inputImage.setPixels(pixels, 0, width, 0, 0, width, height) + return inputImage + } + + private fun contrastChannel(channel: Int, value: Float): Int { + val contrasted = (((channel / COLOR_MAX_FLOAT - HALF) * value) + HALF) * COLOR_MAX_FLOAT + return clampColor(contrasted.toInt()) + } + + private fun saturatePixel(pixel: Int, level: Float): Int { + val redPercent = ((pixel shr RED_SHIFT) and CHANNEL_MASK) / COLOR_MAX_FLOAT + val greenPercent = ((pixel shr GREEN_SHIFT) and CHANNEL_MASK) / COLOR_MAX_FLOAT + val bluePercent = (pixel and CHANNEL_MASK) / COLOR_MAX_FLOAT + + val maxColor = max(redPercent, max(greenPercent, bluePercent)) + val minColor = min(redPercent, min(greenPercent, bluePercent)) + var luminance = ((maxColor + minColor) / 2f * PERCENT).toInt() / PERCENT_FLOAT + + if (maxColor == minColor) { + val gray = clampColor((luminance * COLOR_MAX_FLOAT).toInt()) + return (pixel and ALPHA_MASK) or (gray shl RED_SHIFT) or (gray shl GREEN_SHIFT) or gray + } + + var saturation = if (luminance < HALF) { + (maxColor - minColor) / (maxColor + minColor) + } else { + (maxColor - minColor) / (TWO - maxColor - minColor) + } + + var hue = when (maxColor) { + redPercent -> (greenPercent - bluePercent) / (maxColor - minColor) + greenPercent -> TWO + (bluePercent - redPercent) / (maxColor - minColor) + else -> FOUR + (redPercent - greenPercent) / (maxColor - minColor) + } * HUE_DEGREES + if (hue < 0f) { + hue += FULL_CIRCLE_DEGREES + } + + saturation = ((saturation * PERCENT).toInt() * level).coerceIn(0f, PERCENT_FLOAT) / PERCENT_FLOAT + hue /= FULL_CIRCLE_DEGREES + + val temp1 = if (luminance < HALF) { + luminance * (1f + saturation) + } else { + luminance + saturation - luminance * saturation + } + val temp2 = TWO * luminance - temp1 + + val outRed = hslComponentToColor(temp1, temp2, normalizeHue(hue + ONE_THIRD)) + val outGreen = hslComponentToColor(temp1, temp2, hue) + val outBlue = hslComponentToColor(temp1, temp2, normalizeHue(hue - ONE_THIRD)) + return (pixel and ALPHA_MASK) or (outRed shl RED_SHIFT) or (outGreen shl GREEN_SHIFT) or outBlue + } + + private fun hslComponentToColor(temp1: Float, temp2: Float, temp3: Float): Int { + val component = when { + temp3 * SIX < 1f -> temp2 + (temp1 - temp2) * SIX * temp3 + temp3 * TWO < 1f -> temp1 + temp3 * THREE < TWO -> temp2 + (temp1 - temp2) * (TWO_THIRDS - temp3) * SIX + else -> temp2 + } + return clampColor(((component * PERCENT).toInt() / PERCENT_FLOAT * COLOR_MAX_FLOAT).toInt()) + } + + private fun normalizeHue(hue: Float): Float { + return when { + hue > 1f -> hue - 1f + hue < 0f -> hue + 1f + else -> hue + } + } + + private fun clampColor(value: Int): Int = value.coerceIn(0, CHANNEL_MASK) + + private const val RED_SHIFT = 16 + private const val GREEN_SHIFT = 8 + private const val CHANNEL_MASK = 0xFF + private const val ALPHA_MASK = -0x1000000 + private const val COLOR_MAX_FLOAT = 255f + private const val HALF = 0.5f + private const val PERCENT = 100 + private const val PERCENT_FLOAT = 100f + private const val HUE_DEGREES = 60f + private const val FULL_CIRCLE_DEGREES = 360f + private const val ONE_THIRD = 0.33333f + private const val TWO_THIRDS = 0.66666f + private const val TWO = 2f + private const val THREE = 3f + private const val FOUR = 4f + private const val SIX = 6f +} diff --git a/app/src/main/kotlin/com/zomato/photofilters/imageprocessors/SubFilter.kt b/app/src/main/kotlin/com/zomato/photofilters/imageprocessors/SubFilter.kt new file mode 100644 index 0000000000..08adf903b8 --- /dev/null +++ b/app/src/main/kotlin/com/zomato/photofilters/imageprocessors/SubFilter.kt @@ -0,0 +1,11 @@ +@file:Suppress("MagicNumber", "LongMethod", "ReturnCount", "LongParameterList") + +package com.zomato.photofilters.imageprocessors + +import android.graphics.Bitmap + +interface SubFilter { + fun process(inputImage: Bitmap?): Bitmap? + + var tag: Any +} diff --git a/app/src/main/kotlin/com/zomato/photofilters/imageprocessors/subfilters/SubFilters.kt b/app/src/main/kotlin/com/zomato/photofilters/imageprocessors/subfilters/SubFilters.kt new file mode 100644 index 0000000000..8f6af4d413 --- /dev/null +++ b/app/src/main/kotlin/com/zomato/photofilters/imageprocessors/subfilters/SubFilters.kt @@ -0,0 +1,154 @@ +@file:Suppress("LongParameterList", "MagicNumber", "UnusedPrivateProperty") + +package com.zomato.photofilters.imageprocessors.subfilters + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RadialGradient +import android.graphics.Shader +import com.zomato.photofilters.geometry.BezierSpline +import com.zomato.photofilters.geometry.Point +import com.zomato.photofilters.imageprocessors.ImageProcessor +import com.zomato.photofilters.imageprocessors.SubFilter +import kotlin.math.hypot + +class BrightnessSubFilter(private var brightness: Int) : SubFilter { + override fun process(inputImage: Bitmap?): Bitmap { + return ImageProcessor.doBrightness(brightness, inputImage) + } + + override var tag: Any = "" + + fun changeBrightness(value: Int) { + brightness += value + } +} + +class ColorOverlaySubFilter( + private val colorOverlayDepth: Int, + private val colorOverlayRed: Float, + private val colorOverlayGreen: Float, + private val colorOverlayBlue: Float +) : SubFilter { + override fun process(inputImage: Bitmap?): Bitmap { + return ImageProcessor.doColorOverlay( + colorOverlayDepth, + colorOverlayRed, + colorOverlayGreen, + colorOverlayBlue, + inputImage + ) + } + + override var tag: Any = "" +} + +class ContrastSubFilter(var contrast: Float) : SubFilter { + override fun process(inputImage: Bitmap?): Bitmap { + return ImageProcessor.doContrast(contrast, inputImage) + } + + override var tag: Any = "" + + fun changeContrast(value: Float) { + contrast += value + } +} + +class SaturationSubFilter(var saturation: Float) : SubFilter { + override fun process(inputImage: Bitmap?): Bitmap { + return ImageProcessor.doSaturation(inputImage, saturation) + } + + override var tag: Any = "" + + fun setLevel(level: Float) { + saturation = level + } +} + +class ToneCurveSubFilter( + rgbKnots: Array?, + redKnots: Array?, + greenKnots: Array?, + blueKnots: Array? +) : SubFilter { + private var rgbKnots: Array? = rgbKnots ?: straightKnots() + private var redKnots: Array? = redKnots ?: straightKnots() + private var greenKnots: Array? = greenKnots ?: straightKnots() + private var blueKnots: Array? = blueKnots ?: straightKnots() + private var rgb: IntArray? = null + private var red: IntArray? = null + private var green: IntArray? = null + private var blue: IntArray? = null + + override fun process(inputImage: Bitmap?): Bitmap { + rgbKnots = sortPointsOnXAxis(rgbKnots) + redKnots = sortPointsOnXAxis(redKnots) + greenKnots = sortPointsOnXAxis(greenKnots) + blueKnots = sortPointsOnXAxis(blueKnots) + + if (rgb == null) rgb = BezierSpline.curveGenerator(rgbKnots) + if (red == null) red = BezierSpline.curveGenerator(redKnots) + if (green == null) green = BezierSpline.curveGenerator(greenKnots) + if (blue == null) blue = BezierSpline.curveGenerator(blueKnots) + + return ImageProcessor.applyCurves(rgb, red, green, blue, inputImage) + } + + fun sortPointsOnXAxis(points: Array?): Array? { + points ?: return null + repeat((points.size - 2).coerceAtLeast(0)) { + for (index in 0..points.size - 2) { + if (points[index]!!.x > points[index + 1]!!.x) { + val temp = points[index]!!.x + points[index]!!.x = points[index + 1]!!.x + points[index + 1]!!.x = temp + } + } + } + return points + } + + override var tag: Any = "" + + companion object { + private fun straightKnots() = arrayOf(Point(0f, 0f), Point(255f, 255f)) + } +} + +class VignetteSubFilter(@Suppress("UNUSED_PARAMETER") context: Context, private var alpha: Int) : SubFilter { + override fun process(inputImage: Bitmap?): Bitmap { + val bitmap = requireNotNull(inputImage) + val centerX = bitmap.width / 2f + val centerY = bitmap.height / 2f + val radius = hypot(centerX, centerY) + val vignette = RadialGradient( + centerX, + centerY, + radius, + intArrayOf(Color.TRANSPARENT, Color.BLACK), + floatArrayOf(0.55f, 1f), + Shader.TileMode.CLAMP + ) + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + this.alpha = alpha.coerceIn(0, 255) + shader = vignette + } + Canvas(bitmap).drawRect(0f, 0f, bitmap.width.toFloat(), bitmap.height.toFloat(), paint) + return bitmap + } + + override var tag: Any = "" + + fun setAlpha(alpha: Int) { + this.alpha = alpha + } + + fun changeAlpha(value: Int) { + alpha = (alpha + value).coerceIn(0, 255) + } +} diff --git a/app/src/main/kotlin/org/fossify/gallery/activities/EditActivity.kt b/app/src/main/kotlin/org/fossify/gallery/activities/EditActivity.kt index 4d846b562c..7674cea7b0 100644 --- a/app/src/main/kotlin/org/fossify/gallery/activities/EditActivity.kt +++ b/app/src/main/kotlin/org/fossify/gallery/activities/EditActivity.kt @@ -87,10 +87,6 @@ import kotlin.math.max class EditActivity : BaseCropActivity() { companion object { - init { - System.loadLibrary("NativeImageProcessor") - } - private const val ASPECT_X = "aspectX" private const val ASPECT_Y = "aspectY" private const val CROP = "crop" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5c45171dbf..005193c029 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,7 +29,6 @@ zjupureWebpdecoder = "2.7.4.16.0" jxlDecoder = "2.6.0" gestureviews = "e706487a14" androidsvgAar = "1.4" -androidphotofilters = "193f2ae509" sanselan = "0.97-incubator" media3Exoplayer = "1.10.0" okhttp = "5.3.2" @@ -66,7 +65,6 @@ android-image-cropper = { module = "com.vanniktech:android-image-cropper", versi subsamplingscaleimageview = { module = "org.fossify:subsampling-scale-image-view", version.ref = "subsamplingScaleImageView" } androidsvg-aar = { module = "com.caverock:androidsvg-aar", version.ref = "androidsvgAar" } gestureviews = { module = "org.fossify:gestureviews", version.ref = "gestureviews" } -androidphotofilters = { module = "com.github.naveensingh:androidphotofilters", version.ref = "androidphotofilters" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } okio = { module = "com.squareup.okio:okio", version.ref = "okio" } sanselan = { module = "org.apache.sanselan:sanselan", version.ref = "sanselan" } From c125bade3572272a2c36b2cdd3ee09dbc6e0ca14 Mon Sep 17 00:00:00 2001 From: Sebastian Jug Date: Wed, 29 Apr 2026 15:30:15 -0400 Subject: [PATCH 2/2] docs: update changelog for 16 KB compatibility fix --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f899049000..9f24369f07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed +- Fixed 16 KB page-size compatibility by replacing the editor photo filters native library. ## [1.13.1] - 2026-02-14 ### Changed