Skip to content

Commit d02b341

Browse files
Merge pull request #15987 from nextcloud/perf/gallery-image-task
perf: gallery image task
2 parents 022124a + 2757665 commit d02b341

File tree

7 files changed

+307
-316
lines changed

7 files changed

+307
-316
lines changed
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
/*
2+
* Nextcloud - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2025 Alper Ozturk <[email protected]>
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
package com.nextcloud.client.jobs.gallery
9+
10+
import android.graphics.Bitmap
11+
import android.widget.ImageView
12+
import androidx.core.content.ContextCompat
13+
import com.nextcloud.client.account.User
14+
import com.nextcloud.utils.OCFileUtils
15+
import com.nextcloud.utils.allocationKilobyte
16+
import com.owncloud.android.MainApp
17+
import com.owncloud.android.R
18+
import com.owncloud.android.datamodel.FileDataStorageManager
19+
import com.owncloud.android.datamodel.OCFile
20+
import com.owncloud.android.datamodel.ThumbnailsCacheManager
21+
import com.owncloud.android.lib.common.OwnCloudClientManagerFactory
22+
import com.owncloud.android.lib.common.utils.Log_OC
23+
import com.owncloud.android.utils.MimeTypeUtil
24+
import kotlinx.coroutines.Dispatchers
25+
import kotlinx.coroutines.sync.Semaphore
26+
import kotlinx.coroutines.sync.withPermit
27+
import kotlinx.coroutines.withContext
28+
29+
class GalleryImageGenerationJob(private val user: User, private val storageManager: FileDataStorageManager) {
30+
companion object {
31+
private const val TAG = "GalleryImageGenerationJob"
32+
private val semaphore = Semaphore(
33+
maxOf(
34+
3,
35+
Runtime.getRuntime().availableProcessors() / 2
36+
)
37+
)
38+
}
39+
40+
@Suppress("TooGenericExceptionCaught")
41+
suspend fun run(
42+
file: OCFile,
43+
imageView: ImageView,
44+
imageDimension: Pair<Int, Int>,
45+
listener: GalleryImageGenerationListener
46+
) {
47+
try {
48+
var newImage = false
49+
50+
if (file.remoteId == null && !file.isPreviewAvailable) {
51+
listener.onError()
52+
return
53+
}
54+
55+
val bitmap: Bitmap? = getBitmap(imageView, file, imageDimension, onThumbnailGeneration = {
56+
newImage = true
57+
})
58+
59+
if (bitmap == null) {
60+
listener.onError()
61+
return
62+
}
63+
64+
setThumbnail(bitmap, file, imageView, newImage, listener)
65+
} catch (e: Exception) {
66+
Log_OC.e(TAG, "gallery image generation job: ", e)
67+
withContext(Dispatchers.Main) {
68+
listener.onError()
69+
}
70+
}
71+
}
72+
73+
private suspend fun getBitmap(
74+
imageView: ImageView,
75+
file: OCFile,
76+
imageDimension: Pair<Int, Int>,
77+
onThumbnailGeneration: () -> Unit
78+
): Bitmap? = withContext(Dispatchers.IO) {
79+
if (file.remoteId == null && !file.isPreviewAvailable) {
80+
Log_OC.w(TAG, "file has no remoteId and no preview")
81+
return@withContext null
82+
}
83+
84+
val key = file.remoteId
85+
val cachedThumbnail = ThumbnailsCacheManager.getBitmapFromDiskCache(
86+
ThumbnailsCacheManager.PREFIX_RESIZED_IMAGE + file.remoteId
87+
)
88+
if (cachedThumbnail != null && !file.isUpdateThumbnailNeeded) {
89+
Log_OC.d(TAG, "cached thumbnail is used for: ${file.fileName}")
90+
return@withContext getThumbnailFromCache(file, cachedThumbnail, key)
91+
}
92+
93+
Log_OC.d(TAG, "generating new thumbnail for: ${file.fileName}")
94+
95+
// only add placeholder if new thumbnail will be generated because cached image will appear so quickly
96+
withContext(Dispatchers.Main) {
97+
val placeholderDrawable = OCFileUtils.getMediaPlaceholder(file, imageDimension)
98+
imageView.setImageDrawable(placeholderDrawable)
99+
}
100+
101+
onThumbnailGeneration()
102+
semaphore.withPermit {
103+
return@withContext getThumbnailFromServerAndAddToCache(file, cachedThumbnail)
104+
}
105+
}
106+
107+
private suspend fun setThumbnail(
108+
bitmap: Bitmap,
109+
file: OCFile,
110+
imageView: ImageView,
111+
newImage: Boolean,
112+
listener: GalleryImageGenerationListener
113+
) = withContext(Dispatchers.Main) {
114+
val tagId = file.fileId.toString()
115+
if (imageView.tag?.toString() != tagId) return@withContext
116+
117+
if ("image/png".equals(file.mimeType, ignoreCase = true)) {
118+
imageView.setBackgroundColor(
119+
ContextCompat.getColor(
120+
MainApp.getAppContext(),
121+
R.color.bg_default
122+
)
123+
)
124+
}
125+
126+
if (newImage) {
127+
listener.onNewGalleryImage()
128+
}
129+
130+
imageView.setImageBitmap(bitmap)
131+
imageView.invalidate()
132+
listener.onSuccess()
133+
}
134+
135+
private fun getThumbnailFromCache(file: OCFile, thumbnail: Bitmap, key: String): Bitmap {
136+
var result = thumbnail
137+
if (MimeTypeUtil.isVideo(file)) {
138+
result = ThumbnailsCacheManager.addVideoOverlay(thumbnail, MainApp.getAppContext())
139+
}
140+
141+
if (thumbnail.allocationKilobyte() > ThumbnailsCacheManager.THUMBNAIL_SIZE_IN_KB) {
142+
result = ThumbnailsCacheManager.getScaledThumbnailAfterSave(result, key)
143+
}
144+
145+
return result
146+
}
147+
148+
@Suppress("DEPRECATION", "TooGenericExceptionCaught")
149+
private suspend fun getThumbnailFromServerAndAddToCache(file: OCFile, thumbnail: Bitmap?): Bitmap? {
150+
var thumbnail = thumbnail
151+
try {
152+
val client = withContext(Dispatchers.IO) {
153+
OwnCloudClientManagerFactory.getDefaultSingleton().getClientFor(
154+
user.toOwnCloudAccount(),
155+
MainApp.getAppContext()
156+
)
157+
}
158+
ThumbnailsCacheManager.setClient(client)
159+
thumbnail = ThumbnailsCacheManager.doResizedImageInBackground(file, storageManager)
160+
161+
if (MimeTypeUtil.isVideo(file) && thumbnail != null) {
162+
thumbnail = ThumbnailsCacheManager.addVideoOverlay(thumbnail, MainApp.getAppContext())
163+
}
164+
} catch (t: Throwable) {
165+
Log_OC.e(TAG, "Generation of gallery image for $file failed", t)
166+
}
167+
168+
return thumbnail
169+
}
170+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/*
2+
* Nextcloud - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2025 Alper Ozturk <[email protected]>
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
package com.nextcloud.client.jobs.gallery
9+
10+
interface GalleryImageGenerationListener {
11+
fun onSuccess()
12+
fun onNewGalleryImage()
13+
fun onError()
14+
}

app/src/main/java/com/nextcloud/utils/OCFileUtils.kt

Lines changed: 61 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,18 @@
66
*/
77
package com.nextcloud.utils
88

9+
import android.graphics.Color
10+
import android.graphics.drawable.BitmapDrawable
11+
import androidx.core.content.ContextCompat
12+
import androidx.core.graphics.drawable.toBitmap
13+
import androidx.core.graphics.drawable.toDrawable
914
import androidx.exifinterface.media.ExifInterface
15+
import com.owncloud.android.MainApp
16+
import com.owncloud.android.R
1017
import com.owncloud.android.datamodel.OCFile
1118
import com.owncloud.android.lib.common.utils.Log_OC
1219
import com.owncloud.android.utils.BitmapUtils
20+
import com.owncloud.android.utils.MimeTypeUtil
1321

1422
object OCFileUtils {
1523
private const val TAG = "OCFileUtils"
@@ -19,38 +27,67 @@ object OCFileUtils {
1927
try {
2028
Log_OC.d(TAG, "Getting image size for: ${ocFile.fileName}")
2129

22-
if (!ocFile.exists()) {
23-
ocFile.imageDimension?.width?.let { w ->
24-
ocFile.imageDimension?.height?.let { h ->
25-
return w.toInt() to h.toInt()
26-
}
27-
}
28-
val size = defaultThumbnailSize.toInt().coerceAtLeast(1)
29-
return size to size
30+
val widthFromDimension = ocFile.imageDimension?.width
31+
val heightFromDimension = ocFile.imageDimension?.height
32+
if (widthFromDimension != null && heightFromDimension != null) {
33+
val width = widthFromDimension.toInt()
34+
val height = heightFromDimension.toInt()
35+
Log_OC.d(TAG, "Image dimensions are used, width: $width, height: $height")
36+
return width to height
3037
}
3138

32-
val exif = ExifInterface(ocFile.storagePath)
33-
val width = exif.getAttributeInt(ExifInterface.TAG_IMAGE_WIDTH, 0)
34-
val height = exif.getAttributeInt(ExifInterface.TAG_IMAGE_LENGTH, 0)
39+
return if (ocFile.exists()) {
40+
val exif = ExifInterface(ocFile.storagePath)
41+
val width = exif.getAttributeInt(ExifInterface.TAG_IMAGE_WIDTH, 0)
42+
val height = exif.getAttributeInt(ExifInterface.TAG_IMAGE_LENGTH, 0)
3543

36-
if (width > 0 && height > 0) {
37-
Log_OC.d(TAG, "Exif used width: $width and height: $height")
38-
return width to height
39-
}
44+
if (width > 0 && height > 0) {
45+
Log_OC.d(TAG, "Exif used width: $width and height: $height")
46+
width to height
47+
}
4048

41-
val (bitmapWidth, bitmapHeight) = BitmapUtils.getImageResolution(ocFile.storagePath)
42-
.let { it[0] to it[1] }
49+
val (bitmapWidth, bitmapHeight) = BitmapUtils.getImageResolution(ocFile.storagePath)
50+
.let { it[0] to it[1] }
4351

44-
if (bitmapWidth > 0 && bitmapHeight > 0) {
45-
Log_OC.d(TAG, "BitmapUtils.getImageResolution used width: $bitmapWidth and height: $bitmapHeight")
46-
return bitmapWidth to bitmapHeight
47-
}
52+
if (bitmapWidth > 0 && bitmapHeight > 0) {
53+
Log_OC.d(TAG, "BitmapUtils.getImageResolution used width: $bitmapWidth and height: $bitmapHeight")
54+
bitmapWidth to bitmapHeight
55+
}
4856

49-
val fallback = defaultThumbnailSize.toInt().coerceAtLeast(1)
50-
Log_OC.d(TAG, "Default size used width: $fallback and height: $fallback")
51-
return fallback to fallback
57+
val fallback = defaultThumbnailSize.toInt().coerceAtLeast(1)
58+
Log_OC.d(TAG, "Default size used width: $fallback and height: $fallback")
59+
fallback to fallback
60+
} else {
61+
Log_OC.d(TAG, "Default size is used: $defaultThumbnailSize")
62+
val size = defaultThumbnailSize.toInt().coerceAtLeast(1)
63+
size to size
64+
}
5265
} finally {
5366
Log_OC.d(TAG, "-----------------------------")
5467
}
5568
}
69+
70+
fun getMediaPlaceholder(file: OCFile, imageDimension: Pair<Int, Int>): BitmapDrawable {
71+
val context = MainApp.getAppContext()
72+
73+
val drawableId = if (MimeTypeUtil.isImage(file)) {
74+
R.drawable.file_image
75+
} else if (MimeTypeUtil.isVideo(file)) {
76+
R.drawable.file_movie
77+
} else {
78+
R.drawable.file
79+
}
80+
81+
val drawable = ContextCompat.getDrawable(context, drawableId)
82+
?: return Color.GRAY.toDrawable().toBitmap(imageDimension.first, imageDimension.second)
83+
.toDrawable(context.resources)
84+
85+
val bitmap = BitmapUtils.drawableToBitmap(
86+
drawable,
87+
imageDimension.first,
88+
imageDimension.second
89+
)
90+
91+
return bitmap.toDrawable(context.resources)
92+
}
5693
}

app/src/main/java/com/nextcloud/utils/extensions/OCFileExtensions.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,9 @@ fun List<OCFile>.limitToPersonalFiles(userId: String): List<OCFile> = filter { f
3131
ownerId == userId && !file.isSharedWithMe && !file.mounted()
3232
} == true
3333
}
34+
35+
fun OCFile.mediaSize(defaultThumbnailSize: Float): Pair<Int, Int> {
36+
val width = (imageDimension?.width?.toInt() ?: defaultThumbnailSize.toInt())
37+
val height = (imageDimension?.height?.toInt() ?: defaultThumbnailSize.toInt())
38+
return width to height
39+
}

0 commit comments

Comments
 (0)