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
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" }