Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2026 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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)
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -765,146 +762,6 @@ public interface Listener{

}

public static class MediaThumbnailGenerationTask extends AsyncTask<Object, Void, Bitmap> {

private static final int IMAGE_KEY_PARAMS_LENGTH = 2;

private enum Type {IMAGE, VIDEO}

private final WeakReference<ImageView> 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);

Expand Down Expand Up @@ -973,14 +830,6 @@ private ResizedImageGenerationTask getBitmapWorkerTask() {
}
}

public static class AsyncMediaThumbnailDrawable extends BitmapDrawable {

public AsyncMediaThumbnailDrawable(Resources res, Bitmap bitmap) {

super(res, bitmap);
}
}

/**
* adapted from <a href="https://stackoverflow.com/a/8113368">...</a>
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -38,17 +39,13 @@ 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
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

/**
Expand All @@ -71,7 +68,7 @@ class SyncedFolderAdapter(
private val syncFolderItems: MutableList<SyncedFolderDisplayItem> = ArrayList()
private val filteredSyncFolderItems: MutableList<SyncedFolderDisplayItem> = 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) }
Expand Down Expand Up @@ -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

Expand Down
Loading