diff --git a/.gitignore b/.gitignore
index ea7379898..1b0f1c72f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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###
diff --git a/.idea/misc.xml b/.idea/misc.xml
deleted file mode 100755
index 1d3e3ba73..000000000
--- a/.idea/misc.xml
+++ /dev/null
@@ -1,63 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/presentation/src/main/java/org/cryptomator/presentation/di/component/ApplicationComponent.java b/presentation/src/main/java/org/cryptomator/presentation/di/component/ApplicationComponent.java
index ca0232028..edd4721c8 100644
--- a/presentation/src/main/java/org/cryptomator/presentation/di/component/ApplicationComponent.java
+++ b/presentation/src/main/java/org/cryptomator/presentation/di/component/ApplicationComponent.java
@@ -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;
@@ -45,6 +46,8 @@ public interface ApplicationComponent {
ContentResolverUtil contentResolverUtil();
+ ThumbnailUtil thumbnailUtil();
+
NetworkConnectionCheck networkConnectionCheck();
}
diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/BrowseFilesAdapter.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/BrowseFilesAdapter.kt
index 5585d8848..88e3dfd00 100644
--- a/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/BrowseFilesAdapter.kt
+++ b/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/BrowseFilesAdapter.kt
@@ -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
@@ -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, BrowseFilesAdapter.ItemClickListener, VaultContentViewHolder, ItemBrowseFilesNodeBinding>(CloudNodeModelNameAZComparator()), FastScrollRecyclerView.SectionedAdapter {
private var chooseCloudNodeSettings: ChooseCloudNodeSettings? = null
@@ -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)
@@ -114,7 +121,7 @@ constructor(
}
}
- inner class VaultContentViewHolder internal constructor(private val binding: ItemBrowseFilesNodeBinding) : RecyclerViewBaseAdapter, BrowseFilesAdapter.ItemClickListener, VaultContentViewHolder, ItemBrowseFilesNodeBinding>.ItemViewHolder(binding.root) {
+ inner class VaultContentViewHolder internal constructor(internal val binding: ItemBrowseFilesNodeBinding) : RecyclerViewBaseAdapter, ItemClickListener, VaultContentViewHolder, ItemBrowseFilesNodeBinding>.ItemViewHolder(binding.root) {
private var uiState: UiStateTest? = null
@@ -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))
+ }
}
private fun bindCloudNodeImage(cloudNodeModel: CloudNodeModel<*>): Int {
diff --git a/presentation/src/main/java/org/cryptomator/presentation/util/ThumbnailUtil.kt b/presentation/src/main/java/org/cryptomator/presentation/util/ThumbnailUtil.kt
new file mode 100644
index 000000000..eac8fc646
--- /dev/null
+++ b/presentation/src/main/java/org/cryptomator/presentation/util/ThumbnailUtil.kt
@@ -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)
+ 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
+ }
+}
diff --git a/presentation/src/main/res/layout/item_browse_files_node.xml b/presentation/src/main/res/layout/item_browse_files_node.xml
index a79841409..b8db915d5 100644
--- a/presentation/src/main/res/layout/item_browse_files_node.xml
+++ b/presentation/src/main/res/layout/item_browse_files_node.xml
@@ -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" />
16dp
16dp
16dp
- 36dp
+ 72dp
16dp
420dp
diff --git a/util/src/main/java/org/cryptomator/util/file/LruFileCacheUtil.kt b/util/src/main/java/org/cryptomator/util/file/LruFileCacheUtil.kt
index b3d2fbee3..a3691d5d6 100644
--- a/util/src/main/java/org/cryptomator/util/file/LruFileCacheUtil.kt
+++ b/util/src/main/java/org/cryptomator/util/file/LruFileCacheUtil.kt
@@ -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 {
@@ -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()
}
}