Skip to content

Commit 9a7c346

Browse files
committed
feat(imageprocessing): add ThumbnailGenerator, blurhash and cache support
- Add ThumbnailGenerator for efficient thumbnail creation - Add blurhash module for image placeholders - Add cache module for image caching - Add ImageProcessingModule DI - Add Base64ImageUtils
1 parent 3cca660 commit 9a7c346

File tree

7 files changed

+437
-0
lines changed

7 files changed

+437
-0
lines changed

core/imageprocessing/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ dependencies {
1111
implementation(libs.koin.core)
1212
implementation(libs.koin.android)
1313
implementation(libs.rx.kotlin)
14+
implementation(libs.blurhash)
1415
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
package dev.minios.pdaiv1.core.imageprocessing
2+
3+
import android.graphics.Bitmap
4+
import android.graphics.BitmapFactory
5+
import dev.minios.pdaiv1.core.imageprocessing.cache.ImageCacheManager
6+
import dev.minios.pdaiv1.core.imageprocessing.utils.base64ToBitmap
7+
import dev.minios.pdaiv1.core.imageprocessing.utils.base64ToThumbnailBitmap
8+
import io.reactivex.rxjava3.core.Scheduler
9+
import io.reactivex.rxjava3.core.Single
10+
import java.io.File
11+
12+
/**
13+
* Generates thumbnail images from Base64 encoded images or files.
14+
* Uses ImageCacheManager for caching.
15+
*/
16+
class ThumbnailGenerator(
17+
private val processingScheduler: Scheduler,
18+
private val imageCacheManager: ImageCacheManager,
19+
private val fallbackBitmap: Bitmap,
20+
) {
21+
22+
/**
23+
* Generates a thumbnail from a file path.
24+
* First checks cache, then generates if not found.
25+
* Uses inSampleSize decoding for memory efficiency.
26+
*/
27+
fun generateFromFile(
28+
id: String,
29+
filePath: String,
30+
targetSize: Int = ImageCacheManager.THUMBNAIL_SIZE,
31+
): Single<Bitmap> = Single
32+
.defer {
33+
// Check cache first
34+
val cached = imageCacheManager.getThumbnail(id)
35+
if (cached != null) {
36+
return@defer Single.just(cached)
37+
}
38+
39+
// Generate thumbnail from file with subsampled decoding
40+
Single.fromCallable {
41+
val file = File(filePath)
42+
if (!file.exists()) {
43+
return@fromCallable fallbackBitmap
44+
}
45+
46+
// First decode bounds only
47+
val options = BitmapFactory.Options().apply {
48+
inJustDecodeBounds = true
49+
}
50+
BitmapFactory.decodeFile(filePath, options)
51+
52+
// Calculate inSampleSize
53+
options.inSampleSize = calculateInSampleSize(options, targetSize, targetSize)
54+
options.inJustDecodeBounds = false
55+
56+
// Decode with inSampleSize
57+
val subsampledBitmap = BitmapFactory.decodeFile(filePath, options)
58+
?: return@fromCallable fallbackBitmap
59+
60+
// Final scale to exact target size if needed
61+
val thumbnail = createThumbnail(subsampledBitmap, targetSize)
62+
63+
// Cache the thumbnail
64+
imageCacheManager.putThumbnail(id, thumbnail)
65+
66+
// Recycle intermediate bitmap if different from result
67+
if (subsampledBitmap != thumbnail && !subsampledBitmap.isRecycled) {
68+
subsampledBitmap.recycle()
69+
}
70+
71+
thumbnail
72+
}
73+
}
74+
.onErrorReturnItem(fallbackBitmap)
75+
.subscribeOn(processingScheduler)
76+
77+
/**
78+
* Generates a thumbnail from base64 string.
79+
* First checks cache, then generates if not found.
80+
* Uses inSampleSize decoding for memory efficiency.
81+
*/
82+
fun generate(
83+
id: String,
84+
base64ImageString: String,
85+
targetSize: Int = ImageCacheManager.THUMBNAIL_SIZE,
86+
): Single<Bitmap> = Single
87+
.defer {
88+
// Check cache first
89+
val cached = imageCacheManager.getThumbnail(id)
90+
if (cached != null) {
91+
return@defer Single.just(cached)
92+
}
93+
94+
// Generate thumbnail with subsampled decoding
95+
Single.fromCallable {
96+
// Decode with inSampleSize for memory efficiency
97+
val subsampledBitmap = base64ToThumbnailBitmap(base64ImageString, targetSize)
98+
99+
// Final scale to exact target size if needed
100+
val thumbnail = createThumbnail(subsampledBitmap, targetSize)
101+
102+
// Cache the thumbnail
103+
imageCacheManager.putThumbnail(id, thumbnail)
104+
105+
// Recycle intermediate bitmap if different from result
106+
if (subsampledBitmap != thumbnail && !subsampledBitmap.isRecycled) {
107+
subsampledBitmap.recycle()
108+
}
109+
110+
thumbnail
111+
}
112+
}
113+
.onErrorReturnItem(fallbackBitmap)
114+
.subscribeOn(processingScheduler)
115+
116+
/**
117+
* Gets or generates a full-size image from base64.
118+
*/
119+
fun getFullImage(
120+
id: String,
121+
base64ImageString: String,
122+
): Single<Bitmap> = Single
123+
.defer {
124+
// Check cache first
125+
val cached = imageCacheManager.getFullImage(id)
126+
if (cached != null) {
127+
return@defer Single.just(cached)
128+
}
129+
130+
// Load full image
131+
Single.fromCallable {
132+
val bitmap = base64ToBitmap(base64ImageString)
133+
imageCacheManager.putFullImage(id, bitmap)
134+
bitmap
135+
}
136+
}
137+
.onErrorReturnItem(fallbackBitmap)
138+
.subscribeOn(processingScheduler)
139+
140+
private fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
141+
val (height: Int, width: Int) = options.run { outHeight to outWidth }
142+
var inSampleSize = 1
143+
144+
if (height > reqHeight || width > reqWidth) {
145+
val halfHeight: Int = height / 2
146+
val halfWidth: Int = width / 2
147+
148+
while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
149+
inSampleSize *= 2
150+
}
151+
}
152+
153+
return inSampleSize
154+
}
155+
156+
private fun createThumbnail(source: Bitmap, targetSize: Int): Bitmap {
157+
if (source.width <= targetSize && source.height <= targetSize) {
158+
return source
159+
}
160+
161+
val aspectRatio = source.width.toFloat() / source.height.toFloat()
162+
val targetWidth: Int
163+
val targetHeight: Int
164+
165+
if (aspectRatio > 1) {
166+
targetWidth = targetSize
167+
targetHeight = (targetSize / aspectRatio).toInt()
168+
} else {
169+
targetHeight = targetSize
170+
targetWidth = (targetSize * aspectRatio).toInt()
171+
}
172+
173+
return Bitmap.createScaledBitmap(source, targetWidth, targetHeight, true)
174+
}
175+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package dev.minios.pdaiv1.core.imageprocessing.blurhash
2+
3+
import android.graphics.Bitmap
4+
import com.vanniktech.blurhash.BlurHash
5+
import io.reactivex.rxjava3.core.Scheduler
6+
import io.reactivex.rxjava3.core.Single
7+
8+
/**
9+
* Decodes BlurHash strings to Bitmap images for placeholders.
10+
*/
11+
class BlurHashDecoder(
12+
private val processingScheduler: Scheduler,
13+
private val fallbackBitmap: Bitmap,
14+
) {
15+
16+
/**
17+
* Decodes a BlurHash string to a Bitmap synchronously.
18+
* Use for inline Composable rendering.
19+
*/
20+
fun decodeSync(
21+
hash: String,
22+
width: Int = DEFAULT_SIZE,
23+
height: Int = DEFAULT_SIZE,
24+
): Bitmap? {
25+
if (hash.isBlank()) return null
26+
return try {
27+
BlurHash.decode(hash, width, height)
28+
} catch (e: Exception) {
29+
null
30+
}
31+
}
32+
33+
/**
34+
* Decodes a BlurHash string to a Bitmap.
35+
* @param hash The BlurHash string to decode
36+
* @param width Target width of the decoded bitmap
37+
* @param height Target height of the decoded bitmap
38+
* @return Single emitting the decoded Bitmap
39+
*/
40+
fun decode(
41+
hash: String,
42+
width: Int = DEFAULT_SIZE,
43+
height: Int = DEFAULT_SIZE,
44+
): Single<Bitmap> = Single
45+
.fromCallable {
46+
if (hash.isBlank()) {
47+
return@fromCallable fallbackBitmap
48+
}
49+
BlurHash.decode(hash, width, height) ?: fallbackBitmap
50+
}
51+
.onErrorReturnItem(fallbackBitmap)
52+
.subscribeOn(processingScheduler)
53+
54+
companion object {
55+
const val DEFAULT_SIZE = 32 // Small size for blur placeholder
56+
57+
/**
58+
* Static decode for simple use cases without dependency injection.
59+
*/
60+
fun decodeStatic(hash: String, width: Int = DEFAULT_SIZE, height: Int = DEFAULT_SIZE): Bitmap? {
61+
if (hash.isBlank()) return null
62+
return try {
63+
BlurHash.decode(hash, width, height)
64+
} catch (e: Exception) {
65+
null
66+
}
67+
}
68+
}
69+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package dev.minios.pdaiv1.core.imageprocessing.blurhash
2+
3+
import android.graphics.Bitmap
4+
import com.vanniktech.blurhash.BlurHash
5+
import io.reactivex.rxjava3.core.Scheduler
6+
import io.reactivex.rxjava3.core.Single
7+
8+
/**
9+
* Encodes Bitmap images to BlurHash strings for progressive loading placeholders.
10+
*/
11+
class BlurHashEncoder(
12+
private val processingScheduler: Scheduler,
13+
) {
14+
15+
/**
16+
* Encodes a bitmap to a BlurHash string synchronously.
17+
* Use this for inline processing where async is not needed.
18+
*/
19+
fun encodeSync(
20+
bitmap: Bitmap,
21+
componentX: Int = DEFAULT_COMPONENT_X,
22+
componentY: Int = DEFAULT_COMPONENT_Y,
23+
): String = BlurHash.encode(bitmap, componentX, componentY) ?: ""
24+
25+
/**
26+
* Encodes a bitmap to a BlurHash string.
27+
* @param bitmap The source bitmap to encode
28+
* @param componentX Horizontal components (1-9), higher = more detail
29+
* @param componentY Vertical components (1-9), higher = more detail
30+
* @return Single emitting the BlurHash string
31+
*/
32+
fun encode(
33+
bitmap: Bitmap,
34+
componentX: Int = DEFAULT_COMPONENT_X,
35+
componentY: Int = DEFAULT_COMPONENT_Y,
36+
): Single<String> = Single
37+
.fromCallable {
38+
BlurHash.encode(bitmap, componentX, componentY)
39+
?: throw IllegalStateException("Failed to encode BlurHash")
40+
}
41+
.subscribeOn(processingScheduler)
42+
43+
companion object {
44+
const val DEFAULT_COMPONENT_X = 4
45+
const val DEFAULT_COMPONENT_Y = 3
46+
}
47+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package dev.minios.pdaiv1.core.imageprocessing.cache
2+
3+
import android.graphics.Bitmap
4+
import android.util.LruCache
5+
6+
/**
7+
* Manages dual-layer image caching for gallery:
8+
* - Thumbnail cache: stores small preview images (256x256)
9+
* - Full image cache: stores full resolution images for viewing
10+
*/
11+
class ImageCacheManager(
12+
thumbnailCacheSize: Int = DEFAULT_THUMBNAIL_CACHE_SIZE,
13+
fullImageCacheSize: Int = DEFAULT_FULL_IMAGE_CACHE_SIZE,
14+
) {
15+
16+
private val thumbnailCache = object : LruCache<String, Bitmap>(thumbnailCacheSize) {
17+
override fun sizeOf(key: String, bitmap: Bitmap): Int {
18+
return bitmap.byteCount / 1024 // Size in KB
19+
}
20+
}
21+
22+
private val fullImageCache = object : LruCache<String, Bitmap>(fullImageCacheSize) {
23+
override fun sizeOf(key: String, bitmap: Bitmap): Int {
24+
return bitmap.byteCount / 1024 // Size in KB
25+
}
26+
}
27+
28+
fun getThumbnail(id: String): Bitmap? = thumbnailCache.get(id)
29+
30+
fun putThumbnail(id: String, bitmap: Bitmap) {
31+
thumbnailCache.put(id, bitmap)
32+
}
33+
34+
fun getFullImage(id: String): Bitmap? = fullImageCache.get(id)
35+
36+
fun putFullImage(id: String, bitmap: Bitmap) {
37+
fullImageCache.put(id, bitmap)
38+
}
39+
40+
fun removeThumbnail(id: String) {
41+
thumbnailCache.remove(id)
42+
}
43+
44+
fun removeFullImage(id: String) {
45+
fullImageCache.remove(id)
46+
}
47+
48+
fun clearThumbnails() {
49+
thumbnailCache.evictAll()
50+
}
51+
52+
fun clearFullImages() {
53+
fullImageCache.evictAll()
54+
}
55+
56+
fun clear() {
57+
clearThumbnails()
58+
clearFullImages()
59+
}
60+
61+
companion object {
62+
const val THUMBNAIL_SIZE = 256 // px
63+
64+
// Cache size in KB (about 2500 thumbnails of ~50KB each = ~125MB)
65+
// Increased for smoother gallery scrolling like Immich
66+
private const val DEFAULT_THUMBNAIL_CACHE_SIZE = 125_000
67+
68+
// Cache size in KB (about 10 full images of ~5MB each = ~50MB)
69+
private const val DEFAULT_FULL_IMAGE_CACHE_SIZE = 50_000
70+
}
71+
}

0 commit comments

Comments
 (0)