Skip to content
Open
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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@
.idea/encodings.xml
.idea/compiler.xml
.idea/jarRepositories.xml
.idea/misc.xml
.idea/deploymentTargetSelector.xml
.idea/deviceManager.xml
.idea/markdown.xml
.idea/migrations.xml

###Android###

Expand Down
63 changes: 0 additions & 63 deletions .idea/misc.xml

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import org.cryptomator.presentation.di.module.ThreadModule;
import org.cryptomator.presentation.util.ContentResolverUtil;
import org.cryptomator.presentation.util.FileUtil;
import org.cryptomator.presentation.util.ThumbnailUtil;

import javax.inject.Singleton;

Expand Down Expand Up @@ -45,6 +46,8 @@ public interface ApplicationComponent {

ContentResolverUtil contentResolverUtil();

ThumbnailUtil thumbnailUtil();

NetworkConnectionCheck networkConnectionCheck();

}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import org.cryptomator.presentation.util.FileIcon
import org.cryptomator.presentation.util.FileSizeHelper
import org.cryptomator.presentation.util.FileUtil
import org.cryptomator.presentation.util.ResourceHelper.Companion.getDrawable
import org.cryptomator.presentation.util.ThumbnailUtil
import org.cryptomator.util.SharedPreferencesHandler
import javax.inject.Inject

Expand All @@ -37,7 +38,8 @@ constructor(
private val dateHelper: DateHelper, //
private val fileSizeHelper: FileSizeHelper, //
private val fileUtil: FileUtil, //
private val sharedPreferencesHandler: SharedPreferencesHandler
private val sharedPreferencesHandler: SharedPreferencesHandler,
private val thumbnailUtil: ThumbnailUtil
) : RecyclerViewBaseAdapter<CloudNodeModel<*>, BrowseFilesAdapter.ItemClickListener, VaultContentViewHolder, ItemBrowseFilesNodeBinding>(CloudNodeModelNameAZComparator()), FastScrollRecyclerView.SectionedAdapter {

private var chooseCloudNodeSettings: ChooseCloudNodeSettings? = null
Expand All @@ -54,6 +56,11 @@ constructor(
return ItemBrowseFilesNodeBinding.inflate(inflater, parent, false)
}

override fun onViewRecycled(holder: VaultContentViewHolder) {
super.onViewRecycled(holder)
thumbnailUtil.cancelLoad(holder.binding.cloudNodeImage)
}

fun addOrReplaceCloudNode(cloudNodeModel: CloudNodeModel<*>) {
if (contains(cloudNodeModel)) {
replaceItem(cloudNodeModel)
Expand Down Expand Up @@ -114,7 +121,7 @@ constructor(
}
}

inner class VaultContentViewHolder internal constructor(private val binding: ItemBrowseFilesNodeBinding) : RecyclerViewBaseAdapter<CloudNodeModel<*>, BrowseFilesAdapter.ItemClickListener, VaultContentViewHolder, ItemBrowseFilesNodeBinding>.ItemViewHolder(binding.root) {
inner class VaultContentViewHolder internal constructor(internal val binding: ItemBrowseFilesNodeBinding) : RecyclerViewBaseAdapter<CloudNodeModel<*>, ItemClickListener, VaultContentViewHolder, ItemBrowseFilesNodeBinding>.ItemViewHolder(binding.root) {

private var uiState: UiStateTest? = null

Expand All @@ -135,7 +142,14 @@ constructor(
}

private fun bindNodeImage(node: CloudNodeModel<*>) {
binding.cloudNodeImage.setImageResource(bindCloudNodeImage(node))
val icon = if (node is CloudFileModel) FileIcon.fileIconFor(node.name, fileUtil) else null
if (node is CloudFileModel && (icon == FileIcon.IMAGE || icon == FileIcon.MOVIE)) {
binding.cloudNodeImage.setImageResource(icon.iconResource)
thumbnailUtil.loadThumbnail(node, binding.cloudNodeImage)
} else {
thumbnailUtil.cancelLoad(binding.cloudNodeImage)
binding.cloudNodeImage.setImageResource(bindCloudNodeImage(node))
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

private fun bindCloudNodeImage(cloudNodeModel: CloudNodeModel<*>): Int {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package org.cryptomator.presentation.util

import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.media.MediaMetadataRetriever
import android.os.Handler
import android.os.Looper
import android.widget.ImageView
import org.cryptomator.data.repository.DispatchingCloudContentRepository
import org.cryptomator.domain.usecases.ProgressAware
import org.cryptomator.presentation.model.CloudFileModel
import org.cryptomator.util.file.LruFileCacheUtil
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.util.concurrent.Executors
import javax.inject.Inject
import javax.inject.Singleton
import timber.log.Timber

@Singleton
class ThumbnailUtil @Inject constructor(
context: Context,
private val cloudContentRepository: DispatchingCloudContentRepository,
) {

private val executor = Executors.newFixedThreadPool(3)
private val mainHandler = Handler(Looper.getMainLooper())
private val thumbnailDir: File = LruFileCacheUtil(context).resolve(LruFileCacheUtil.Cache.THUMBNAILS)

init {
thumbnailDir.mkdirs()
}

fun loadThumbnail(file: CloudFileModel, target: ImageView) {
val cacheKey = cacheKey(file)
target.tag = cacheKey

val cachedFile = File(thumbnailDir, "$cacheKey.jpg")

executor.execute {
runCatching {
val bitmap = if (cachedFile.exists()) {
BitmapFactory.decodeFile(cachedFile.absolutePath)
} else {
when (file.icon) {
FileIcon.MOVIE -> generateVideoThumbnail(file, cachedFile)
else -> generateImageThumbnail(file, cachedFile)
}
} ?: return@execute

mainHandler.post {
if (target.tag == cacheKey) {
target.setImageBitmap(bitmap)
}
}
}.onFailure { e ->
Timber.tag("ThumbnailUtil").w(e, "Failed to generate thumbnail for ${file.name}")
}
}
}

fun cancelLoad(target: ImageView) {
target.tag = null
}

private fun generateImageThumbnail(file: CloudFileModel, cachedFile: File): Bitmap? {
val bytes = downloadToByteArray(file) ?: return null
val bitmap = decodeScaledBitmap(bytes) ?: return null
saveThumbnail(bitmap, cachedFile)
Comment on lines +69 to +72
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Current image path loads entire files into heap before sampling.

Downloading full image bytes first defeats memory savings from inSampleSize and can trigger OOM on large photos. Prefer file-backed decode (download to temp file, bounds decode + sampled decode from file).

Also applies to: 95-100

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@presentation/src/main/java/org/cryptomator/presentation/util/ThumbnailUtil.kt`
around lines 71 - 74, The current generateImageThumbnail uses
downloadToByteArray and decodeScaledBitmap which load the entire image into
memory; instead, change the flow to download the image to a temporary file
(file-backed), then perform a bounds-only decode
(BitmapFactory.Options.inJustDecodeBounds = true) on that temp file to compute
an appropriate inSampleSize, and finally perform a sampled decode from the file
(e.g., decodeFile or decodeStream) and pass that Bitmap to saveThumbnail;
replace uses of downloadToByteArray/decodeScaledBitmap with a file-backed
download + bounds decode + sampled decode pattern, and apply the same fix to the
analogous code referenced at lines ~95-100 so both image thumbnail paths use
file-backed decoding to avoid OOMs.

return bitmap
}

private fun generateVideoThumbnail(file: CloudFileModel, cachedFile: File): Bitmap? {
val tmpFile = File(thumbnailDir, "${cacheKey(file)}.tmp")
val retriever = MediaMetadataRetriever()
return try {
downloadToFile(file, tmpFile) ?: return null
retriever.setDataSource(tmpFile.absolutePath)
val frame = retriever.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
?: return null
val bitmap = scaleBitmap(frame)
saveThumbnail(bitmap, cachedFile)
bitmap
} finally {
retriever.release()
tmpFile.delete()
}
}

private fun downloadToByteArray(file: CloudFileModel): ByteArray? {
return try {
ByteArrayOutputStream().also { out ->
cloudContentRepository.read(file.toCloudNode(), null, out, ProgressAware.NO_OP_PROGRESS_AWARE_DOWNLOAD)
}.toByteArray()
} catch (e: Exception) {
Timber.tag("ThumbnailUtil").w(e, "Download failed for ${file.name}")
null
}
}

private fun downloadToFile(file: CloudFileModel, dest: File): File? {
return try {
FileOutputStream(dest).use { out ->
cloudContentRepository.read(file.toCloudNode(), null, out, ProgressAware.NO_OP_PROGRESS_AWARE_DOWNLOAD)
}
dest
} catch (e: Exception) {
Timber.tag("ThumbnailUtil").w(e, "Download failed for ${file.name}")
null
}
}

private fun decodeScaledBitmap(bytes: ByteArray): Bitmap? {
val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts)
if (opts.outWidth <= 0 || opts.outHeight <= 0) return null // unsupported format e.g. SVG
opts.inSampleSize = computeSampleSize(opts.outWidth, opts.outHeight)
opts.inJustDecodeBounds = false
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts)
}

private fun scaleBitmap(src: Bitmap): Bitmap {
val sampleSize = computeSampleSize(src.width, src.height)
if (sampleSize == 1) return src
return Bitmap.createScaledBitmap(src, src.width / sampleSize, src.height / sampleSize, true)
}

private fun computeSampleSize(width: Int, height: Int): Int {
var n = 1
while (minOf(width, height) / (n * 2) >= THUMBNAIL_PX) n *= 2
return n
}

private fun saveThumbnail(bitmap: Bitmap, dest: File) {
try {
FileOutputStream(dest).use { bitmap.compress(Bitmap.CompressFormat.JPEG, 80, it) }
} catch (e: IOException) {
Timber.tag("ThumbnailUtil").w(e, "Could not save thumbnail to disk")
}
}

private fun cacheKey(file: CloudFileModel): String =
"${file.path}_${file.size}".hashCode().toString().replace('-', 'n')

companion object {
private const val THUMBNAIL_PX = 144
}
}
4 changes: 3 additions & 1 deletion presentation/src/main/res/layout/item_browse_files_node.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
android:layout_width="@dimen/thumbnail_size"
android:layout_height="@dimen/thumbnail_size"
android:layout_centerVertical="true"
android:layout_marginStart="16dp" />
android:layout_marginStart="16dp"
android:scaleType="centerCrop"
android:clipToOutline="true" />

<include
android:id="@+id/ll_cloud_folder_content"
Expand Down
2 changes: 1 addition & 1 deletion presentation/src/main/res/values/dimens.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<dimen name="activity_horizontal_margin">16dp</dimen>
<dimen name="activity_vertical_margin">16dp</dimen>
<dimen name="global_padding">16dp</dimen>
<dimen name="thumbnail_size">36dp</dimen>
<dimen name="thumbnail_size">72dp</dimen>
<dimen name="fab_margin">16dp</dimen>

<dimen name="landscape_bottom_sheet_width">420dp</dimen>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class LruFileCacheUtil(context: Context) {
private val parent: File = context.cacheDir

enum class Cache {
DROPBOX, WEBDAV, PCLOUD, S3, ONEDRIVE, GOOGLE_DRIVE
DROPBOX, WEBDAV, PCLOUD, S3, ONEDRIVE, GOOGLE_DRIVE, THUMBNAILS
}

fun resolve(cache: Cache?): File {
Expand All @@ -31,6 +31,7 @@ class LruFileCacheUtil(context: Context) {
Cache.S3 -> File(parent, "LruCacheS3")
Cache.ONEDRIVE -> File(parent, "LruCacheOneDrive")
Cache.GOOGLE_DRIVE -> File(parent, "LruCacheGoogleDrive")
Cache.THUMBNAILS -> File(parent, "thumbnails")
else -> throw IllegalStateException()
}
}
Expand Down