From 6817b9ef062bab0efd67cb13582a496d060e4e38 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 8 Oct 2026 12:24:35 +0200 Subject: [PATCH] refactor(media-thumbnail-generation): kotlin Signed-off-by: alperozturk96 --- .../job/MediaThumbnailGenerationJob.kt | 129 +++++++++++++++ .../datamodel/ThumbnailsCacheManager.java | 151 ------------------ .../android/ui/adapter/SyncedFolderAdapter.kt | 29 +--- 3 files changed, 135 insertions(+), 174 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/utils/thumbnail/job/MediaThumbnailGenerationJob.kt diff --git a/app/src/main/java/com/nextcloud/utils/thumbnail/job/MediaThumbnailGenerationJob.kt b/app/src/main/java/com/nextcloud/utils/thumbnail/job/MediaThumbnailGenerationJob.kt new file mode 100644 index 000000000000..d8b7d5a1fc91 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/thumbnail/job/MediaThumbnailGenerationJob.kt @@ -0,0 +1,129 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.thumbnail.job + +import android.content.Context +import android.graphics.Bitmap +import android.media.MediaMetadataRetriever +import android.widget.ImageView +import com.owncloud.android.datamodel.ThumbnailsCacheManager +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.utils.BitmapUtils +import com.owncloud.android.utils.MimeTypeUtil +import com.owncloud.android.utils.theme.ViewThemeUtils +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File + +class MediaThumbnailGenerationJob( + private val context: Context, + private val viewThemeUtils: ViewThemeUtils, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO +) { + companion object { + private const val TAG = "MediaThumbnailGenerationJob" + private const val ANY_FRAME_TIME_US = -1L + } + + suspend fun load(file: File, imageView: ImageView) { + val key = file.cacheKey() + val thumbnail = withContext(ioDispatcher) { generateThumbnail(file, key) } + + if (imageView.tag?.toString() != key) { + return + } + + if (thumbnail != null) { + imageView.setImageBitmap(thumbnail) + } else { + setFallbackIcon(imageView, file) + } + } + + private fun File.cacheKey(): String = hashCode().toString() + + @Suppress("TooGenericExceptionCaught") + private fun generateThumbnail(file: File, key: String): Bitmap? = try { + when { + MimeTypeUtil.isImage(file) -> ThumbnailsCacheManager.getBitmapFromDiskCache(key) + ?: generateImageThumbnail(file, key) + + MimeTypeUtil.isVideo(file) -> ThumbnailsCacheManager.getBitmapFromDiskCache(key) + ?: generateVideoThumbnail(file, key) + + else -> null + } + } catch (t: Throwable) { + Log_OC.e(TAG, "Generation of thumbnail for ${file.absolutePath} failed", t) + null + } + + private fun generateImageThumbnail(file: File, key: String): Bitmap? { + val px = ThumbnailsCacheManager.getThumbnailDimension() + val bitmap = BitmapUtils.decodeSampledBitmapFromFile(file.absolutePath, px, px) ?: return null + return ThumbnailsCacheManager.addThumbnailToCache(key, bitmap, file.path, px, px) + } + + private fun generateVideoThumbnail(file: File, key: String): Bitmap? = + extractVideoFrame(file)?.let { frame -> downscaleAndCache(frame, file, key) } + + private fun downscaleAndCache(frame: Bitmap, file: File, key: String): Bitmap { + val px = ThumbnailsCacheManager.getThumbnailDimension() + val width = frame.width + val height = frame.height + val longestSide = maxOf(width, height) + + if (longestSide <= px) { + return frame + } + + val scaled = BitmapUtils.scaleBitmap(frame, px.toFloat(), width, height, longestSide) + return ThumbnailsCacheManager.addThumbnailToCache(key, scaled, file.path, px, px) + } + + @Suppress("TooGenericExceptionCaught") + private fun extractVideoFrame(file: File): Bitmap? { + if (!file.exists()) { + Log_OC.w(TAG, "Cannot extract thumbnail: file does not exist") + return null + } + + val retriever = MediaMetadataRetriever() + return try { + retriever.setDataSource(file.absolutePath) + retriever.getFrameAtTime(ANY_FRAME_TIME_US) + } catch (_: Throwable) { + Log_OC.w(TAG, "Failed to create bitmap from video ${file.absolutePath}") + null + } finally { + retriever.releaseSafely() + } + } + + @Suppress("TooGenericExceptionCaught") + private fun MediaMetadataRetriever.releaseSafely() { + try { + release() + } catch (_: Throwable) { + Log_OC.w(TAG, "Failed to release retriever") + } + } + + private fun setFallbackIcon(imageView: ImageView, file: File) { + when { + file.isDirectory -> imageView.setImageDrawable(MimeTypeUtil.getDefaultFolderIcon(context, viewThemeUtils)) + + MimeTypeUtil.isVideo(file) -> imageView.setImageBitmap(ThumbnailsCacheManager.mDefaultVideo) + + else -> imageView.setImageDrawable( + MimeTypeUtil.getFileTypeIcon(null, file.name, context, viewThemeUtils) + ) + } + } +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java b/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java index deb5c72070de..744e39d835a4 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java @@ -12,7 +12,6 @@ */ package com.owncloud.android.datamodel; -import android.annotation.SuppressLint; import android.content.Context; import android.content.res.Resources; import android.graphics.Bitmap; @@ -23,7 +22,6 @@ import android.graphics.RectF; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; -import android.media.MediaMetadataRetriever; import android.media.ThumbnailUtils; import android.net.Uri; import android.os.AsyncTask; @@ -57,7 +55,6 @@ import com.owncloud.android.utils.BitmapUtils; import com.owncloud.android.utils.FileStorageUtils; import com.owncloud.android.utils.MimeTypeUtil; -import com.owncloud.android.utils.theme.ViewThemeUtils; import org.apache.commons.httpclient.HttpStatus; import org.apache.commons.httpclient.methods.GetMethod; @@ -765,146 +762,6 @@ public interface Listener{ } - public static class MediaThumbnailGenerationTask extends AsyncTask { - - private static final int IMAGE_KEY_PARAMS_LENGTH = 2; - - private enum Type {IMAGE, VIDEO} - - private final WeakReference mImageViewReference; - private File mFile; - private String mImageKey; - @SuppressLint("StaticFieldLeak") private final Context mContext; - private final ViewThemeUtils viewThemeUtils; - - public MediaThumbnailGenerationTask(ImageView imageView, - Context context, - ViewThemeUtils viewThemeUtils) { - // Use a WeakReference to ensure the ImageView can be garbage collected - mImageViewReference = new WeakReference<>(imageView); - mContext = context; - this.viewThemeUtils = viewThemeUtils; - } - - @Override - protected Bitmap doInBackground(Object... params) { - Bitmap thumbnail = null; - - try { - if (params[0] instanceof File) { - mFile = (File) params[0]; - if (params.length == IMAGE_KEY_PARAMS_LENGTH) { - mImageKey = (String) params[1]; - } - - if (MimeTypeUtil.isImage(mFile)) { - thumbnail = doFileInBackground(mFile, Type.IMAGE); - } else if (MimeTypeUtil.isVideo(mFile)) { - thumbnail = doFileInBackground(mFile, Type.VIDEO); - } - } - } // the app should never break due to a problem with thumbnails - catch (OutOfMemoryError t) { - Log_OC.e(TAG, "Generation of thumbnail for " + mFile.getAbsolutePath() + " failed", t); - Log_OC.e(TAG, "Out of memory"); - } catch (Throwable t) { - // the app should never break due to a problem with thumbnails - Log_OC.e(TAG, "Generation of thumbnail for " + mFile.getAbsolutePath() + " failed", t); - } - - return thumbnail; - } - - protected void onPostExecute(Bitmap bitmap) { - String tagId = ""; - final ImageView imageView = mImageViewReference.get(); - if (imageView != null) { - if (mFile != null) { - tagId = String.valueOf(mFile.hashCode()); - } - - if (bitmap != null) { - if (tagId.equals(String.valueOf(imageView.getTag()))) { - imageView.setImageBitmap(bitmap); - } - } else { - if (mFile != null) { - if (mFile.isDirectory()) { - imageView.setImageDrawable(MimeTypeUtil.getDefaultFolderIcon(mContext, viewThemeUtils)); - } else { - if (MimeTypeUtil.isVideo(mFile)) { - imageView.setImageBitmap(ThumbnailsCacheManager.mDefaultVideo); - } else { - imageView.setImageDrawable(MimeTypeUtil.getFileTypeIcon(null, - mFile.getName(), - mContext, - viewThemeUtils)); - } - } - } - } - } - } - - private Bitmap doFileInBackground(File file, Type type) { - final String imageKey = Objects.requireNonNullElseGet(mImageKey, () -> String.valueOf(file.hashCode())); - - // Check disk cache in background thread - Bitmap thumbnail = getBitmapFromDiskCache(imageKey); - - // Not found in disk cache - if (thumbnail == null) { - - if (Type.IMAGE == type) { - int px = getThumbnailDimension(); - - Bitmap bitmap = BitmapUtils.decodeSampledBitmapFromFile(file.getAbsolutePath(), px, px); - - if (bitmap != null) { - thumbnail = addThumbnailToCache(imageKey, bitmap, file.getPath(), px, px); - } - } else if (Type.VIDEO == type) { - thumbnail = getThumbnailFromMediaRetriever(file); - if (thumbnail != null) { - // Scale down bitmap if too large. - int px = getThumbnailDimension(); - int width = thumbnail.getWidth(); - int height = thumbnail.getHeight(); - int max = Math.max(width, height); - if (max > px) { - thumbnail = BitmapUtils.scaleBitmap(thumbnail, px, width, height, max); - thumbnail = addThumbnailToCache(imageKey, thumbnail, file.getPath(), px, px); - } - } - } - } - - return thumbnail; - } - - private Bitmap getThumbnailFromMediaRetriever(File file) { - if (file == null || !file.exists()) { - Log_OC.w(TAG, "Cannot extract thumbnail: file is null or does not exist"); - return null; - } - - var retriever = new MediaMetadataRetriever(); - try { - retriever.setDataSource(file.getAbsolutePath()); - return retriever.getFrameAtTime(-1); - } catch (Throwable t) { - Log_OC.w(TAG, "Failed to create bitmap from video " + file.getAbsolutePath()); - return null; - } finally { - try { - retriever.release(); - } catch (Throwable t) { - Log_OC.w(TAG, "Failed to release retriever"); - } - } - } - } - public static boolean cancelPotentialThumbnailWork(Object file, ImageView imageView) { final ThumbnailGenerationTask bitmapWorkerTask = getBitmapWorkerTask(imageView); @@ -973,14 +830,6 @@ private ResizedImageGenerationTask getBitmapWorkerTask() { } } - public static class AsyncMediaThumbnailDrawable extends BitmapDrawable { - - public AsyncMediaThumbnailDrawable(Resources res, Bitmap bitmap) { - - super(res, bitmap); - } - } - /** * adapted from ... */ diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/SyncedFolderAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/SyncedFolderAdapter.kt index 67b92725c408..63e953b3425d 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/SyncedFolderAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/SyncedFolderAdapter.kt @@ -29,6 +29,7 @@ import com.nextcloud.utils.extensions.calculateScanInterval import com.nextcloud.utils.extensions.filterEnabledOrWithoutEnabledParent import com.nextcloud.utils.extensions.hasEnabledParent import com.nextcloud.utils.extensions.setVisibleIf +import com.nextcloud.utils.thumbnail.job.MediaThumbnailGenerationJob import com.owncloud.android.R import com.owncloud.android.databinding.GridSyncItemBinding import com.owncloud.android.databinding.SyncedFoldersEmptyBinding @@ -38,8 +39,6 @@ import com.owncloud.android.datamodel.MediaFolderType import com.owncloud.android.datamodel.SyncedFolder import com.owncloud.android.datamodel.SyncedFolderDisplayItem import com.owncloud.android.datamodel.ThumbnailsCacheManager -import com.owncloud.android.datamodel.ThumbnailsCacheManager.AsyncMediaThumbnailDrawable -import com.owncloud.android.datamodel.ThumbnailsCacheManager.MediaThumbnailGenerationTask import com.owncloud.android.utils.FileUtil import com.owncloud.android.utils.theme.ViewThemeUtils import kotlinx.coroutines.Dispatchers @@ -47,8 +46,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File import java.util.Locale -import java.util.concurrent.Executor -import java.util.concurrent.Executors import java.util.concurrent.TimeUnit /** @@ -71,7 +68,7 @@ class SyncedFolderAdapter( private val syncFolderItems: MutableList = ArrayList() private val filteredSyncFolderItems: MutableList = ArrayList() private var hideItems = true - private val thumbnailThreadPool: Executor = Executors.newCachedThreadPool() + private val mediaThumbnailGenerationJob = MediaThumbnailGenerationJob(context, viewThemeUtils) private val minimumSizeForTouchableArea by lazy { context.resources.getDimensionPixelSize(R.dimen.minimum_size_for_touchable_area) } @@ -429,24 +426,10 @@ class SyncedFolderAdapter( val file = File(filteredSyncFolderItems[section].filePaths[relativePosition]) - val task = - MediaThumbnailGenerationTask( - holder.binding.thumbnail, - context, - viewThemeUtils - ) - - val asyncDrawable = - AsyncMediaThumbnailDrawable( - context.resources, - ThumbnailsCacheManager.mDefaultImg - ) - holder.binding.thumbnail.setImageDrawable(asyncDrawable) - - task.executeOnExecutor(thumbnailThreadPool, file) - - // set proper tag - holder.binding.thumbnail.tag = file.hashCode() + val thumbnail = holder.binding.thumbnail + thumbnail.setImageBitmap(ThumbnailsCacheManager.mDefaultImg) + thumbnail.tag = file.hashCode() + lifecycleScope.launch { mediaThumbnailGenerationJob.load(file, thumbnail) } holder.itemView.tag = relativePosition % gridWidth