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() } }