Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
.PHONY: build clean install install-debug install-release test help
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Incomplete .PHONY declaration.

The .PHONY declaration is missing several targets defined later in the file: build-release, assemble, uninstall, lint, and run. This could cause make to misinterpret these targets as files if files with matching names exist in the directory.

🔧 Proposed fix
-.PHONY: build clean install install-debug install-release test help
+.PHONY: build build-release assemble clean install install-debug install-release uninstall test lint run help
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.PHONY: build clean install install-debug install-release test help
.PHONY: build build-release assemble clean install install-debug install-release uninstall test lint run help
🧰 Tools
🪛 checkmake (0.2.2)

[warning] 1-1: Missing required phony target "all"

(minphony)

🤖 Prompt for AI Agents
In @Makefile at line 1, The .PHONY line is incomplete and should list all
non-file targets to avoid Make treating them as files; update the .PHONY
declaration to include the missing targets build-release, assemble, uninstall,
lint, and run (in addition to the existing build, clean, install, install-debug,
install-release, test, help) so that targets like build-release, assemble,
uninstall, lint, and run are always treated as phony.


# Default target
help:
@echo "Available targets:"
@echo " build - Build debug APK"
@echo " build-release - Build release APK"
@echo " clean - Clean build artifacts"
@echo " install - Install debug APK to connected device"
@echo " install-release- Install release APK to connected device"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix formatting inconsistency.

Missing space after the hyphen in the help text for install-release.

🔧 Proposed fix
-	@echo "  install-release- Install release APK to connected device"
+	@echo "  install-release - Install release APK to connected device"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@echo " install-release- Install release APK to connected device"
@echo " install-release - Install release APK to connected device"
🤖 Prompt for AI Agents
In @Makefile at line 10, Help text for the Makefile target install-release is
missing a space after the hyphen; update the echo string for the install-release
target (the line that prints "install-release- Install release APK to connected
device") to include a space after the hyphen so it reads "install-release -
Install release APK to connected device" to keep help formatting consistent.

@echo " uninstall - Uninstall app from connected device"
@echo " test - Run tests"
@echo " lint - Run lint checks"
@echo " assemble - Build all variants"

# Build debug APK
build:
./gradlew assembleDebug

# Build release APK
build-release:
./gradlew assembleRelease

# Build all variants
assemble:
./gradlew assemble

# Clean build artifacts
clean:
./gradlew clean

# Install debug APK to connected device
install: build
./gradlew installDebug

# Install debug APK without building (if already built)
install-debug:
./gradlew installDebug

# Install release APK to connected device
install-release: build-release
./gradlew installRelease

# Uninstall app from device
uninstall:
./gradlew uninstallAll

# Run tests
test:
./gradlew test

# Run lint checks
lint:
./gradlew lint

# Run app on connected device
run: install
adb shell am start -n com.immichframe.immichframe/.MainActivity
265 changes: 261 additions & 4 deletions app/src/main/java/com/immichframe/immichframe/Helpers.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@ import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.ColorMatrix
import android.graphics.ColorMatrixColorFilter
import android.graphics.Paint
import android.util.Base64
import android.util.Log
import androidx.preference.PreferenceManager
import retrofit2.Call
import retrofit2.http.GET
import androidx.core.graphics.scale
Expand All @@ -16,6 +20,12 @@ import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit

object Helpers {
// Cache for image adjustment filter to avoid recreating on every image load
// Thread-safe via synchronization
private var cachedFilterSettings: IntArray? = null
private var cachedFilter: ColorMatrixColorFilter? = null
private val filterCacheLock = Any()

fun textSizeMultiplier(context: Context, currentSizeSp: Float, multiplier: Float): Float {
val resources = context.resources
val fontScale = resources.configuration.fontScale
Expand Down Expand Up @@ -99,16 +109,36 @@ object Helpers {
return BitmapFactory.decodeByteArray(decodedImage, 0, decodedImage.size, options)
}

/**
* Reduces bitmap size while maintaining aspect ratio.
*
* IMPORTANT: This function RECYCLES the input bitmap.
* After calling this function, the input bitmap is no longer valid and must not be used.
*
* @param bitmap The bitmap to resize (will be recycled)
* @param maxSize Maximum dimension (width or height)
* @return New resized bitmap
*/
fun reduceBitmapQuality(bitmap: Bitmap, maxSize: Int = 1000): Bitmap {
val width = bitmap.width
val height = bitmap.height

// Calculate new dimensions while maintaining aspect ratio
// If already smaller than maxSize, still create a copy and recycle original for consistency
val scaleFactor = maxSize.toFloat() / width.coerceAtLeast(height)
val newWidth = (width * scaleFactor).toInt()
val newHeight = (height * scaleFactor).toInt()

val resizedBitmap = bitmap.scale(newWidth, newHeight)
val resizedBitmap = if (scaleFactor >= 1f) {
// Image is already small enough - create explicit copy to avoid scale() returning same bitmap
val copy = bitmap.copy(bitmap.config ?: Bitmap.Config.ARGB_8888, false)
bitmap.recycle()
copy
} else {
// Need to scale down
val newWidth = (width * scaleFactor).toInt()
val newHeight = (height * scaleFactor).toInt()
val scaled = bitmap.scale(newWidth, newHeight)
bitmap.recycle()
scaled
}

return resizedBitmap
}
Expand Down Expand Up @@ -205,4 +235,231 @@ object Helpers {
}
}

fun getImageAdjustmentFilter(context: Context, includeGamma: Boolean = true): ColorMatrixColorFilter? {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)

// Check if image adjustments are enabled
val adjustmentsEnabled = prefs.getBoolean("imageAdjustments", false)
if (!adjustmentsEnabled) {
synchronized(filterCacheLock) {
cachedFilterSettings = null
cachedFilter = null
}
return null
}

// Validate and clamp preference values to prevent corruption issues
val brightness = prefs.getInt("image_brightness", 0).coerceIn(-50, 50)
val contrast = prefs.getInt("image_contrast", 0).coerceIn(-50, 50)
val red = prefs.getInt("image_red_channel", 0).coerceIn(-50, 50)
val green = prefs.getInt("image_green_channel", 0).coerceIn(-50, 50)
val blue = prefs.getInt("image_blue_channel", 0).coerceIn(-50, 50)
val gamma = if (includeGamma) prefs.getInt("image_gamma", 100).coerceIn(10, 200) else 100

// If all default, return null (no filter needed)
if (brightness == 0 && contrast == 0 && red == 0 &&
green == 0 && blue == 0 && gamma == 100) {
synchronized(filterCacheLock) {
cachedFilterSettings = null
cachedFilter = null
}
return null
}

// Check cache (thread-safe)
// Include includeGamma flag in cache key (use -1 for false, 1 for true)
val gammaKey = if (includeGamma) 1 else -1
val currentSettings = intArrayOf(brightness, contrast, red, green, blue, gamma, gammaKey)
synchronized(filterCacheLock) {
if (cachedFilterSettings != null && cachedFilterSettings!!.contentEquals(currentSettings)) {
return cachedFilter
}
}

// Create new filter and cache it
val newFilter = createColorMatrixFilter(brightness, contrast, red, green, blue, gamma)
synchronized(filterCacheLock) {
cachedFilterSettings = currentSettings
cachedFilter = newFilter
}
return newFilter
}

private fun createColorMatrixFilter(
brightness: Int,
contrast: Int,
red: Int,
green: Int,
blue: Int,
gamma: Int
): ColorMatrixColorFilter {
val finalMatrix = ColorMatrix()

// Apply brightness (-50 to +50 range)
if (brightness != 0) {
val brightnessValue = brightness.toFloat()
val brightnessMatrix = ColorMatrix(floatArrayOf(
1f, 0f, 0f, 0f, brightnessValue,
0f, 1f, 0f, 0f, brightnessValue,
0f, 0f, 1f, 0f, brightnessValue,
0f, 0f, 0f, 1f, 0f
))
finalMatrix.postConcat(brightnessMatrix)
}

// Apply contrast (-50 to +50 range)
if (contrast != 0) {
val scale = (100 + contrast) / 100f
val translate = (1 - scale) * 128
val contrastMatrix = ColorMatrix(floatArrayOf(
scale, 0f, 0f, 0f, translate,
0f, scale, 0f, 0f, translate,
0f, 0f, scale, 0f, translate,
0f, 0f, 0f, 1f, 0f
))
finalMatrix.postConcat(contrastMatrix)
}

// Apply RGB channel adjustments (-50 to +50 range as gain/multiplier)
if (red != 0 || green != 0 || blue != 0) {
val redScale = 1f + (red / 100f)
val greenScale = 1f + (green / 100f)
val blueScale = 1f + (blue / 100f)
val rgbMatrix = ColorMatrix(floatArrayOf(
redScale, 0f, 0f, 0f, 0f,
0f, greenScale, 0f, 0f, 0f,
0f, 0f, blueScale, 0f, 0f,
0f, 0f, 0f, 1f, 0f
))
finalMatrix.postConcat(rgbMatrix)
}

// Apply gamma (10 to 200 range, representing 0.1 to 2.0)
// NOTE: This is an APPROXIMATION for ColorFilter mode (ImageView/WebView)
// True gamma correction requires non-linear per-pixel transformation
// For widgets (bitmap mode), proper gamma is applied via applyGammaToBitmap()
if (gamma != 100) {
val gammaValue = gamma / 100f
// Better approximation: combine contrast and brightness to approximate gamma curve
// For gamma > 1: increases contrast in midtones (darkens image)
// For gamma < 1: decreases contrast in midtones (lightens image)
val contrastFactor = if (gammaValue > 1f) {
0.7f + (gammaValue - 1f) * 0.3f // Reduce contrast for darkening
} else {
1f + (1f - gammaValue) * 0.5f // Increase contrast for lightening
}
val brightnessFactor = if (gammaValue > 1f) {
-(gammaValue - 1f) * 30f // Darken
} else {
(1f - gammaValue) * 40f // Lighten
}

val gammaMatrix = ColorMatrix(floatArrayOf(
contrastFactor, 0f, 0f, 0f, brightnessFactor,
0f, contrastFactor, 0f, 0f, brightnessFactor,
0f, 0f, contrastFactor, 0f, brightnessFactor,
0f, 0f, 0f, 1f, 0f
))
finalMatrix.postConcat(gammaMatrix)
}

return ColorMatrixColorFilter(finalMatrix)
}

/**
* Applies image adjustments to a bitmap by creating a new bitmap with filters applied.
*
* IMPORTANT: This function ALWAYS RECYCLES the input bitmap to prevent memory leaks.
* After calling this function, the input bitmap is no longer valid and must not be used.
* A new bitmap is always returned, even if no adjustments are applied.
*
* Note: For bitmap mode, gamma correction is applied accurately using per-pixel transformation.
*
* @param bitmap The source bitmap to apply adjustments to (will be recycled)
* @param context Context for accessing SharedPreferences
* @return A new bitmap with adjustments applied (or copy if no adjustments)
*/
fun applyImageAdjustmentsToBitmap(bitmap: Bitmap, context: Context): Bitmap {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val gamma = prefs.getInt("image_gamma", 100).coerceIn(10, 200)

// Apply ColorMatrix-based adjustments (brightness, contrast, RGB)
// Exclude gamma from ColorFilter - we'll apply it properly via LUT
val filter = getImageAdjustmentFilter(context, includeGamma = false)
val config = bitmap.config ?: Bitmap.Config.ARGB_8888
var result = Bitmap.createBitmap(bitmap.width, bitmap.height, config)
val canvas = Canvas(result)
val paint = Paint()

if (filter != null) {
paint.colorFilter = filter
}

canvas.drawBitmap(bitmap, 0f, 0f, paint)

// ALWAYS recycle original bitmap to maintain consistent contract
bitmap.recycle()

// Apply proper gamma correction if needed (per-pixel operation)
if (gamma != 100) {
result = applyGammaToBitmap(result, gamma / 100f)
}

return result
}

/**
* Applies proper gamma correction to a bitmap using per-pixel transformation.
* Uses a lookup table for performance.
*
* @param bitmap The bitmap to apply gamma to (will be recycled)
* @param gamma The gamma value (0.1 to 2.0, where 1.0 is no change)
* @return New bitmap with gamma applied
*/
private fun applyGammaToBitmap(bitmap: Bitmap, gamma: Float): Bitmap {
val width = bitmap.width
val height = bitmap.height

// Safety check: prevent excessive memory allocation
// Max 4000x4000 = 16M pixels = 64MB for ARGB_8888
val maxPixels = 4000 * 4000
if (width * height > maxPixels) {
Log.w("Helpers", "Bitmap too large for gamma correction: ${width}x${height}, skipping gamma")
return bitmap
}

// Build lookup table for gamma correction
val gammaLUT = IntArray(256) { i ->
val normalized = i / 255f
val corrected = Math.pow(normalized.toDouble(), gamma.toDouble()).toFloat()
(corrected * 255f).coerceIn(0f, 255f).toInt()
}

val pixels = IntArray(width * height)
bitmap.getPixels(pixels, 0, width, 0, 0, width, height)

// Apply gamma correction to each pixel
for (i in pixels.indices) {
val pixel = pixels[i]
val a = (pixel shr 24) and 0xff
val r = (pixel shr 16) and 0xff
val g = (pixel shr 8) and 0xff
val b = pixel and 0xff

val rCorrected = gammaLUT[r]
val gCorrected = gammaLUT[g]
val bCorrected = gammaLUT[b]

pixels[i] = (a shl 24) or (rCorrected shl 16) or (gCorrected shl 8) or bCorrected
}

val result = Bitmap.createBitmap(width, height, bitmap.config ?: Bitmap.Config.ARGB_8888)
result.setPixels(pixels, 0, width, 0, 0, width, height)

// Recycle input bitmap
bitmap.recycle()

return result
}
Comment on lines +419 to +463
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: Fix contract violation in safety check.

Line 428 returns the input bitmap without recycling it when the size check fails, violating the function's documented contract that the input "will be recycled" (line 415). This creates a memory leak path.

🐛 Proposed fix
         // Safety check: prevent excessive memory allocation
         // Max 4000x4000 = 16M pixels = 64MB for ARGB_8888
         val maxPixels = 4000 * 4000
         if (width * height > maxPixels) {
-            Log.w("Helpers", "Bitmap too large for gamma correction: ${width}x${height}, skipping gamma")
-            return bitmap
+            Log.w("Helpers", "Bitmap too large for gamma correction: ${width}x${height}, creating copy without gamma")
+            val copy = bitmap.copy(bitmap.config ?: Bitmap.Config.RGB_565, false)
+            bitmap.recycle()
+            return copy
         }

Additionally, consider enhancing the warning log to inform users that gamma was skipped, as this could affect the visual output unexpectedly for large images.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private fun applyGammaToBitmap(bitmap: Bitmap, gamma: Float): Bitmap {
val width = bitmap.width
val height = bitmap.height
// Safety check: prevent excessive memory allocation
// Max 4000x4000 = 16M pixels = 64MB for ARGB_8888
val maxPixels = 4000 * 4000
if (width * height > maxPixels) {
Log.w("Helpers", "Bitmap too large for gamma correction: ${width}x${height}, skipping gamma")
return bitmap
}
// Build lookup table for gamma correction
val gammaLUT = IntArray(256) { i ->
val normalized = i / 255f
val corrected = Math.pow(normalized.toDouble(), gamma.toDouble()).toFloat()
(corrected * 255f).coerceIn(0f, 255f).toInt()
}
val pixels = IntArray(width * height)
bitmap.getPixels(pixels, 0, width, 0, 0, width, height)
// Apply gamma correction to each pixel
for (i in pixels.indices) {
val pixel = pixels[i]
val a = (pixel shr 24) and 0xff
val r = (pixel shr 16) and 0xff
val g = (pixel shr 8) and 0xff
val b = pixel and 0xff
val rCorrected = gammaLUT[r]
val gCorrected = gammaLUT[g]
val bCorrected = gammaLUT[b]
pixels[i] = (a shl 24) or (rCorrected shl 16) or (gCorrected shl 8) or bCorrected
}
val result = Bitmap.createBitmap(width, height, bitmap.config ?: Bitmap.Config.ARGB_8888)
result.setPixels(pixels, 0, width, 0, 0, width, height)
// Recycle input bitmap
bitmap.recycle()
return result
}
private fun applyGammaToBitmap(bitmap: Bitmap, gamma: Float): Bitmap {
val width = bitmap.width
val height = bitmap.height
// Safety check: prevent excessive memory allocation
// Max 4000x4000 = 16M pixels = 64MB for ARGB_8888
val maxPixels = 4000 * 4000
if (width * height > maxPixels) {
Log.w("Helpers", "Bitmap too large for gamma correction: ${width}x${height}, creating copy without gamma")
val copy = bitmap.copy(bitmap.config ?: Bitmap.Config.RGB_565, false)
bitmap.recycle()
return copy
}
// Build lookup table for gamma correction
val gammaLUT = IntArray(256) { i ->
val normalized = i / 255f
val corrected = Math.pow(normalized.toDouble(), gamma.toDouble()).toFloat()
(corrected * 255f).coerceIn(0f, 255f).toInt()
}
val pixels = IntArray(width * height)
bitmap.getPixels(pixels, 0, width, 0, 0, width, height)
// Apply gamma correction to each pixel
for (i in pixels.indices) {
val pixel = pixels[i]
val a = (pixel shr 24) and 0xff
val r = (pixel shr 16) and 0xff
val g = (pixel shr 8) and 0xff
val b = pixel and 0xff
val rCorrected = gammaLUT[r]
val gCorrected = gammaLUT[g]
val bCorrected = gammaLUT[b]
pixels[i] = (a shl 24) or (rCorrected shl 16) or (gCorrected shl 8) or bCorrected
}
val result = Bitmap.createBitmap(width, height, bitmap.config ?: Bitmap.Config.ARGB_8888)
result.setPixels(pixels, 0, width, 0, 0, width, height)
// Recycle input bitmap
bitmap.recycle()
return result
}
🤖 Prompt for AI Agents
In @app/src/main/java/com/immichframe/immichframe/Helpers.kt around lines 419 -
463, The safety check in applyGammaToBitmap currently returns the original
bitmap when it’s too large, violating the function contract that the input
bitmap will be recycled; change the early-return path to call bitmap.recycle()
before returning and update the Log.w message to clearly state that gamma
correction was skipped due to size (e.g., include "${width}x${height}, skipping
gamma and recycling input bitmap") so callers are informed and no leak occurs.


}
Loading