Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <[email protected]>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

package com.nextcloud.client.jobs.gallery

import android.graphics.Bitmap
import android.widget.ImageView
import androidx.core.content.ContextCompat
import com.nextcloud.client.account.User
import com.nextcloud.utils.OCFileUtils
import com.nextcloud.utils.allocationKilobyte
import com.owncloud.android.MainApp
import com.owncloud.android.R
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.datamodel.ThumbnailsCacheManager
import com.owncloud.android.lib.common.OwnCloudClientManagerFactory
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.utils.MimeTypeUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext

class GalleryImageGenerationJob(private val user: User, private val storageManager: FileDataStorageManager) {
companion object {
private const val TAG = "GalleryImageGenerationJob"
private val semaphore = Semaphore(
maxOf(
3,
Runtime.getRuntime().availableProcessors() / 2
)
)
}

@Suppress("TooGenericExceptionCaught")
suspend fun run(
file: OCFile,
imageView: ImageView,
imageDimension: Pair<Int, Int>,
listener: GalleryImageGenerationListener
) {
try {
var newImage = false

if (file.remoteId == null && !file.isPreviewAvailable) {
listener.onError()
return
}

val bitmap: Bitmap? = getBitmap(imageView, file, imageDimension, onThumbnailGeneration = {
newImage = true
})

if (bitmap == null) {
listener.onError()
return
}

setThumbnail(bitmap, file, imageView, newImage, listener)
} catch (e: Exception) {
Log_OC.e(TAG, "gallery image generation job: ", e)
withContext(Dispatchers.Main) {
listener.onError()
}
}
}

private suspend fun getBitmap(
imageView: ImageView,
file: OCFile,
imageDimension: Pair<Int, Int>,
onThumbnailGeneration: () -> Unit
): Bitmap? = withContext(Dispatchers.IO) {
if (file.remoteId == null && !file.isPreviewAvailable) {
Log_OC.w(TAG, "file has no remoteId and no preview")
return@withContext null
}

val key = file.remoteId
val cachedThumbnail = ThumbnailsCacheManager.getBitmapFromDiskCache(
ThumbnailsCacheManager.PREFIX_RESIZED_IMAGE + file.remoteId
)
if (cachedThumbnail != null && !file.isUpdateThumbnailNeeded) {
Log_OC.d(TAG, "cached thumbnail is used for: ${file.fileName}")
return@withContext getThumbnailFromCache(file, cachedThumbnail, key)
}

Log_OC.d(TAG, "generating new thumbnail for: ${file.fileName}")

// only add placeholder if new thumbnail will be generated because cached image will appear so quickly
withContext(Dispatchers.Main) {
val placeholderDrawable = OCFileUtils.getMediaPlaceholder(file, imageDimension)
imageView.setImageDrawable(placeholderDrawable)
}

onThumbnailGeneration()
semaphore.withPermit {
return@withContext getThumbnailFromServerAndAddToCache(file, cachedThumbnail)
}
}

private suspend fun setThumbnail(
bitmap: Bitmap,
file: OCFile,
imageView: ImageView,
newImage: Boolean,
listener: GalleryImageGenerationListener
) = withContext(Dispatchers.Main) {
val tagId = file.fileId.toString()
if (imageView.tag?.toString() != tagId) return@withContext

if ("image/png".equals(file.mimeType, ignoreCase = true)) {
imageView.setBackgroundColor(
ContextCompat.getColor(
MainApp.getAppContext(),
R.color.bg_default
)
)
}

if (newImage) {
listener.onNewGalleryImage()
}

imageView.setImageBitmap(bitmap)
imageView.invalidate()
listener.onSuccess()
}

private fun getThumbnailFromCache(file: OCFile, thumbnail: Bitmap, key: String): Bitmap {
var result = thumbnail
if (MimeTypeUtil.isVideo(file)) {
result = ThumbnailsCacheManager.addVideoOverlay(thumbnail, MainApp.getAppContext())
}

if (thumbnail.allocationKilobyte() > ThumbnailsCacheManager.THUMBNAIL_SIZE_IN_KB) {
result = ThumbnailsCacheManager.getScaledThumbnailAfterSave(result, key)
}

return result
}

@Suppress("DEPRECATION", "TooGenericExceptionCaught")
private suspend fun getThumbnailFromServerAndAddToCache(file: OCFile, thumbnail: Bitmap?): Bitmap? {
var thumbnail = thumbnail
try {
val client = withContext(Dispatchers.IO) {
OwnCloudClientManagerFactory.getDefaultSingleton().getClientFor(
user.toOwnCloudAccount(),
MainApp.getAppContext()
)
}
ThumbnailsCacheManager.setClient(client)
thumbnail = ThumbnailsCacheManager.doResizedImageInBackground(file, storageManager)

if (MimeTypeUtil.isVideo(file) && thumbnail != null) {
thumbnail = ThumbnailsCacheManager.addVideoOverlay(thumbnail, MainApp.getAppContext())
}
} catch (t: Throwable) {
Log_OC.e(TAG, "Generation of gallery image for $file failed", t)
}

return thumbnail
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <[email protected]>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

package com.nextcloud.client.jobs.gallery

interface GalleryImageGenerationListener {
fun onSuccess()
fun onNewGalleryImage()
fun onError()
}
85 changes: 61 additions & 24 deletions app/src/main/java/com/nextcloud/utils/OCFileUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,18 @@
*/
package com.nextcloud.utils

import android.graphics.Color
import android.graphics.drawable.BitmapDrawable
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.core.graphics.drawable.toDrawable
import androidx.exifinterface.media.ExifInterface
import com.owncloud.android.MainApp
import com.owncloud.android.R
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.utils.BitmapUtils
import com.owncloud.android.utils.MimeTypeUtil

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

if (!ocFile.exists()) {
ocFile.imageDimension?.width?.let { w ->
ocFile.imageDimension?.height?.let { h ->
return w.toInt() to h.toInt()
}
}
val size = defaultThumbnailSize.toInt().coerceAtLeast(1)
return size to size
val widthFromDimension = ocFile.imageDimension?.width
val heightFromDimension = ocFile.imageDimension?.height
if (widthFromDimension != null && heightFromDimension != null) {
val width = widthFromDimension.toInt()
val height = heightFromDimension.toInt()
Log_OC.d(TAG, "Image dimensions are used, width: $width, height: $height")
return width to height
}

val exif = ExifInterface(ocFile.storagePath)
val width = exif.getAttributeInt(ExifInterface.TAG_IMAGE_WIDTH, 0)
val height = exif.getAttributeInt(ExifInterface.TAG_IMAGE_LENGTH, 0)
return if (ocFile.exists()) {
val exif = ExifInterface(ocFile.storagePath)
val width = exif.getAttributeInt(ExifInterface.TAG_IMAGE_WIDTH, 0)
val height = exif.getAttributeInt(ExifInterface.TAG_IMAGE_LENGTH, 0)

if (width > 0 && height > 0) {
Log_OC.d(TAG, "Exif used width: $width and height: $height")
return width to height
}
if (width > 0 && height > 0) {
Log_OC.d(TAG, "Exif used width: $width and height: $height")
width to height
}

val (bitmapWidth, bitmapHeight) = BitmapUtils.getImageResolution(ocFile.storagePath)
.let { it[0] to it[1] }
val (bitmapWidth, bitmapHeight) = BitmapUtils.getImageResolution(ocFile.storagePath)
.let { it[0] to it[1] }

if (bitmapWidth > 0 && bitmapHeight > 0) {
Log_OC.d(TAG, "BitmapUtils.getImageResolution used width: $bitmapWidth and height: $bitmapHeight")
return bitmapWidth to bitmapHeight
}
if (bitmapWidth > 0 && bitmapHeight > 0) {
Log_OC.d(TAG, "BitmapUtils.getImageResolution used width: $bitmapWidth and height: $bitmapHeight")
bitmapWidth to bitmapHeight
}

val fallback = defaultThumbnailSize.toInt().coerceAtLeast(1)
Log_OC.d(TAG, "Default size used width: $fallback and height: $fallback")
return fallback to fallback
val fallback = defaultThumbnailSize.toInt().coerceAtLeast(1)
Log_OC.d(TAG, "Default size used width: $fallback and height: $fallback")
fallback to fallback
} else {
Log_OC.d(TAG, "Default size is used: $defaultThumbnailSize")
val size = defaultThumbnailSize.toInt().coerceAtLeast(1)
size to size
}
} finally {
Log_OC.d(TAG, "-----------------------------")
}
}

fun getMediaPlaceholder(file: OCFile, imageDimension: Pair<Int, Int>): BitmapDrawable {
val context = MainApp.getAppContext()

val drawableId = if (MimeTypeUtil.isImage(file)) {
R.drawable.file_image
} else if (MimeTypeUtil.isVideo(file)) {
R.drawable.file_movie
} else {
R.drawable.file
}

val drawable = ContextCompat.getDrawable(context, drawableId)
?: return Color.GRAY.toDrawable().toBitmap(imageDimension.first, imageDimension.second)
.toDrawable(context.resources)

val bitmap = BitmapUtils.drawableToBitmap(
drawable,
imageDimension.first,
imageDimension.second
)

return bitmap.toDrawable(context.resources)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,9 @@ fun List<OCFile>.limitToPersonalFiles(userId: String): List<OCFile> = filter { f
ownerId == userId && !file.isSharedWithMe && !file.mounted()
} == true
}

fun OCFile.mediaSize(defaultThumbnailSize: Float): Pair<Int, Int> {
val width = (imageDimension?.width?.toInt() ?: defaultThumbnailSize.toInt())
val height = (imageDimension?.height?.toInt() ?: defaultThumbnailSize.toInt())
return width to height
}
Loading
Loading