diff --git a/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoCloudContentRepository.kt b/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoCloudContentRepository.kt index 76da49932..48b00bc1a 100644 --- a/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoCloudContentRepository.kt +++ b/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoCloudContentRepository.kt @@ -13,6 +13,7 @@ import org.cryptomator.domain.repository.CloudContentRepository import org.cryptomator.domain.usecases.ProgressAware import org.cryptomator.domain.usecases.cloud.DataSource import org.cryptomator.domain.usecases.cloud.DownloadState +import org.cryptomator.domain.usecases.cloud.FileTransferState import org.cryptomator.domain.usecases.cloud.UploadState import java.io.File import java.io.OutputStream @@ -95,6 +96,11 @@ internal class CryptoCloudContentRepository(context: Context, cloudContentReposi cryptoImpl.read(file, data, progressAware) } + @Throws(BackendException::class) + override fun associateThumbnails(list: List, progressAware: ProgressAware) { + cryptoImpl.associateThumbnails(list, progressAware) + } + @Throws(BackendException::class) override fun delete(node: CryptoNode) { cryptoImpl.delete(node) diff --git a/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoFile.kt b/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoFile.kt index a8284f602..143fbb563 100644 --- a/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoFile.kt +++ b/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoFile.kt @@ -2,6 +2,7 @@ package org.cryptomator.data.cloud.crypto import org.cryptomator.domain.Cloud import org.cryptomator.domain.CloudFile +import java.io.File import java.util.Date class CryptoFile( @@ -12,6 +13,8 @@ class CryptoFile( val cloudFile: CloudFile ) : CloudFile, CryptoNode { + var thumbnail : File? = null + override val cloud: Cloud? get() = parent.cloud diff --git a/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoImplDecorator.kt b/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoImplDecorator.kt index 6e5c0ad83..f77259b50 100644 --- a/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoImplDecorator.kt +++ b/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoImplDecorator.kt @@ -1,6 +1,11 @@ package org.cryptomator.data.cloud.crypto import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.media.ThumbnailUtils +import com.google.common.util.concurrent.ThreadFactoryBuilder +import com.tomclaw.cache.DiskLruCache import org.cryptomator.cryptolib.api.Cryptor import org.cryptomator.cryptolib.common.DecryptingReadableByteChannel import org.cryptomator.cryptolib.common.EncryptingWritableByteChannel @@ -9,6 +14,7 @@ import org.cryptomator.domain.Cloud import org.cryptomator.domain.CloudFile import org.cryptomator.domain.CloudFolder import org.cryptomator.domain.CloudNode +import org.cryptomator.domain.CloudType import org.cryptomator.domain.exception.BackendException import org.cryptomator.domain.exception.CloudNodeAlreadyExistsException import org.cryptomator.domain.exception.EmptyDirFileException @@ -22,20 +28,36 @@ import org.cryptomator.domain.usecases.UploadFileReplacingProgressAware import org.cryptomator.domain.usecases.cloud.DataSource import org.cryptomator.domain.usecases.cloud.DownloadState import org.cryptomator.domain.usecases.cloud.FileBasedDataSource.Companion.from +import org.cryptomator.domain.usecases.cloud.FileTransferState import org.cryptomator.domain.usecases.cloud.Progress import org.cryptomator.domain.usecases.cloud.UploadState +import org.cryptomator.util.SharedPreferencesHandler +import org.cryptomator.util.ThumbnailsOption +import org.cryptomator.util.file.LruFileCacheUtil +import org.cryptomator.util.file.MimeType +import org.cryptomator.util.file.MimeTypeMap +import org.cryptomator.util.file.MimeTypes import java.io.ByteArrayOutputStream +import java.io.Closeable import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import java.io.IOException import java.io.OutputStream +import java.io.PipedInputStream +import java.io.PipedOutputStream import java.nio.ByteBuffer import java.nio.channels.Channels import java.util.LinkedList import java.util.Queue import java.util.UUID +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.Future import java.util.function.Supplier +import kotlin.system.measureTimeMillis +import timber.log.Timber abstract class CryptoImplDecorator( @@ -50,6 +72,59 @@ abstract class CryptoImplDecorator( @Volatile private var root: RootCryptoFolder? = null + private val sharedPreferencesHandler = SharedPreferencesHandler(context) + + private var diskLruCache: MutableMap = mutableMapOf() + + private val mimeTypes = MimeTypes(MimeTypeMap()) + + private val thumbnailExecutorService: ExecutorService by lazy { + val threadFactory = ThreadFactoryBuilder().setNameFormat("thumbnail-generation-thread-%d").build() + Executors.newCachedThreadPool(threadFactory) + } + + protected fun getLruCacheFor(type: CloudType): DiskLruCache? { + return getOrCreateLruCache(getCacheTypeFromCloudType(type), sharedPreferencesHandler.lruCacheSize()) + } + + private fun getOrCreateLruCache(cache: LruFileCacheUtil.Cache, cacheSize: Int): DiskLruCache? { + return diskLruCache.computeIfAbsent(cache) { + val cacheFile = LruFileCacheUtil(context).resolve(it) + try { + DiskLruCache.create(cacheFile, cacheSize.toLong()) + } catch (e: IOException) { + Timber.tag("CryptoImplDecorator").e(e, "Failed to setup LRU cache for $cacheFile.name") + null + } + } + } + + protected fun renameFileInCache(source: CryptoFile, target: CryptoFile) { + val oldCacheKey = generateCacheKey(source) + val newCacheKey = generateCacheKey(target) + source.cloudFile.cloud?.type()?.let { cloudType -> + getLruCacheFor(cloudType)?.let { diskCache -> + if (diskCache[oldCacheKey] != null) { + target.thumbnail = diskCache.put(newCacheKey, diskCache[oldCacheKey]) + diskCache.delete(oldCacheKey) + } + } + } + } + + private fun getCacheTypeFromCloudType(type: CloudType): LruFileCacheUtil.Cache { + return when (type) { + CloudType.DROPBOX -> LruFileCacheUtil.Cache.DROPBOX + CloudType.GOOGLE_DRIVE -> LruFileCacheUtil.Cache.GOOGLE_DRIVE + CloudType.ONEDRIVE -> LruFileCacheUtil.Cache.ONEDRIVE + CloudType.PCLOUD -> LruFileCacheUtil.Cache.PCLOUD + CloudType.WEBDAV -> LruFileCacheUtil.Cache.WEBDAV + CloudType.S3 -> LruFileCacheUtil.Cache.S3 + CloudType.LOCAL -> LruFileCacheUtil.Cache.LOCAL + else -> throw IllegalStateException("Unexpected CloudType: $type") + } + } + @Throws(BackendException::class) abstract fun folder(cryptoParent: CryptoFolder, cleartextName: String): CryptoFolder @@ -309,8 +384,22 @@ abstract class CryptoImplDecorator( @Throws(BackendException::class) fun read(cryptoFile: CryptoFile, data: OutputStream, progressAware: ProgressAware) { val ciphertextFile = cryptoFile.cloudFile + + val diskCache = cryptoFile.cloudFile.cloud?.type()?.let { getLruCacheFor(it) } + val cacheKey = generateCacheKey(cryptoFile) + val genThumbnail = isThumbnailGenerationAvailable(diskCache, cryptoFile.name) + var futureThumbnail: Future<*> = CompletableFuture.completedFuture(null) + + val thumbnailWriter = PipedOutputStream() + val thumbnailReader = PipedInputStream(thumbnailWriter) + try { val encryptedTmpFile = readToTmpFile(cryptoFile, ciphertextFile, progressAware) + + if (genThumbnail) { + futureThumbnail = startThumbnailGeneratorThread(cryptoFile, diskCache!!, cacheKey, thumbnailReader) + } + progressAware.onProgress(Progress.started(DownloadState.decryption(cryptoFile))) try { Channels.newChannel(FileInputStream(encryptedTmpFile)).use { readableByteChannel -> @@ -322,7 +411,12 @@ abstract class CryptoImplDecorator( while (decryptingReadableByteChannel.read(buff).also { read = it } > 0) { buff.flip() data.write(buff.array(), 0, buff.remaining()) + if (genThumbnail) { + thumbnailWriter.write(buff.array(), 0, buff.remaining()) + } + decrypted += read.toLong() + progressAware .onProgress( Progress.progress(DownloadState.decryption(cryptoFile)) // @@ -332,16 +426,120 @@ abstract class CryptoImplDecorator( ) } } + thumbnailWriter.flush() + closeQuietly(thumbnailWriter) } } finally { encryptedTmpFile.delete() + if (genThumbnail) { + futureThumbnail.get() + } progressAware.onProgress(Progress.completed(DownloadState.decryption(cryptoFile))) } + + closeQuietly(thumbnailReader) } catch (e: IOException) { throw FatalBackendException(e) } } + private fun closeQuietly(closeable: Closeable) { + try { + closeable.close(); + } catch (e: IOException) { + // ignore + } + } + + private fun startThumbnailGeneratorThread(cryptoFile: CryptoFile, diskCache: DiskLruCache, cacheKey: String, thumbnailReader: PipedInputStream): Future<*> { + return thumbnailExecutorService.submit { + try { + val options = BitmapFactory.Options() + val thumbnailBitmap: Bitmap? + options.inSampleSize = 4 // pixel number reduced by a factor of 1/16 + val bitmap = BitmapFactory.decodeStream(thumbnailReader, null, options) + if (bitmap == null) { + closeQuietly(thumbnailReader) + return@submit + } + + val thumbnailWidth = 100 + val thumbnailHeight = 100 + thumbnailBitmap = ThumbnailUtils.extractThumbnail(bitmap, thumbnailWidth, thumbnailHeight) + if (thumbnailBitmap != null) { + storeThumbnail(diskCache, cacheKey, thumbnailBitmap) + } + closeQuietly(thumbnailReader) + + cryptoFile.thumbnail = diskCache[cacheKey] + } catch (e: Exception) { + Timber.e(e, "Bitmap generation crashed") + } + } + } + + protected fun generateCacheKey(cryptoFile: CryptoFile): String { + return String.format("%s-%d", cryptoFile.cloudFile.cloud?.id() ?: "common", cryptoFile.path.hashCode()) + } + + private fun isThumbnailGenerationAvailable(cache: DiskLruCache?, fileName: String): Boolean { + return isGenerateThumbnailsEnabled() && sharedPreferencesHandler.generateThumbnails() != ThumbnailsOption.READONLY && cache != null && isImageMediaType(fileName) + } + + fun associateThumbnails(list: List, progressAware: ProgressAware) { + if (!isGenerateThumbnailsEnabled()) { + return + } + val cryptoFileList = list.filterIsInstance() + if (cryptoFileList.isEmpty()) { + return + } + val firstCryptoFile = cryptoFileList[0] + val cloudType = (firstCryptoFile).cloudFile.cloud?.type() ?: return + val diskCache = getLruCacheFor(cloudType) ?: return + val toProcess = cryptoFileList.filter { cryptoFile -> + (isImageMediaType(cryptoFile.name) && cryptoFile.thumbnail == null) + } + var associated = 0 + val elapsed = measureTimeMillis { + toProcess.forEach { cryptoFile -> + val cacheKey = generateCacheKey(cryptoFile) + val cacheFile = diskCache[cacheKey] + if (cacheFile != null && cryptoFile.thumbnail == null) { + cryptoFile.thumbnail = cacheFile + associated++ + val state = FileTransferState { cryptoFile } + val progress = Progress.progress(state).thatIsCompleted() + progressAware.onProgress(progress) + } + } + } + Timber.tag("THUMBNAIL").i("[AssociateThumbnails] associated:${associated} files, elapsed:${elapsed}ms") + } + + private fun isGenerateThumbnailsEnabled(): Boolean { + return sharedPreferencesHandler.useLruCache() && sharedPreferencesHandler.generateThumbnails() != ThumbnailsOption.NEVER + } + + private fun storeThumbnail(cache: DiskLruCache?, cacheKey: String, thumbnailBitmap: Bitmap) { + val thumbnailFile: File = File.createTempFile(UUID.randomUUID().toString(), ".thumbnail", internalCache) + thumbnailBitmap.compress(Bitmap.CompressFormat.JPEG, 100, thumbnailFile.outputStream()) + + try { + cache?.let { + LruFileCacheUtil.storeToLruCache(it, cacheKey, thumbnailFile) + } ?: Timber.tag("CryptoImplDecorator").e("Failed to store item in LRU cache") + } catch (e: IOException) { + Timber.tag("CryptoImplDecorator").e(e, "Failed to write the thumbnail in DiskLruCache") + } + + thumbnailFile.delete() + } + + private fun isImageMediaType(filename: String): Boolean { + return (mimeTypes.fromFilename(filename) ?: MimeType.WILDCARD_MIME_TYPE).mediatype == "image" + } + @Throws(BackendException::class, IOException::class) private fun readToTmpFile(cryptoFile: CryptoFile, file: CloudFile, progressAware: ProgressAware): File { val encryptedTmpFile = File.createTempFile(UUID.randomUUID().toString(), ".crypto", internalCache) diff --git a/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoImplVaultFormat7.kt b/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoImplVaultFormat7.kt index 4128ccc7a..e22932d1d 100644 --- a/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoImplVaultFormat7.kt +++ b/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoImplVaultFormat7.kt @@ -87,7 +87,6 @@ open class CryptoImplVaultFormat7 : CryptoImplDecorator { val shortFileName = BaseEncoding.base64Url().encode(hash) + LONG_NODE_FILE_EXT var dirFolder = cloudContentRepository.folder(getOrCreateCachingAwareDirIdInfo(cryptoParent).cloudFolder, shortFileName) - // if folder already exists in case of renaming if (!cloudContentRepository.exists(dirFolder)) { dirFolder = cloudContentRepository.create(dirFolder) } @@ -380,6 +379,7 @@ open class CryptoImplVaultFormat7 : CryptoImplDecorator { @Throws(BackendException::class) override fun move(source: CryptoFile, target: CryptoFile): CryptoFile { + renameFileInCache(source, target) return if (source.cloudFile.parent.name.endsWith(LONG_NODE_FILE_EXT)) { val targetDirFolder = cloudContentRepository.folder(target.cloudFile.parent, target.cloudFile.name) val cryptoFile: CryptoFile = if (target.cloudFile.name.endsWith(LONG_NODE_FILE_EXT)) { @@ -449,6 +449,15 @@ open class CryptoImplVaultFormat7 : CryptoImplDecorator { } else { cloudContentRepository.delete(node.cloudFile) } + + val cacheKey = generateCacheKey(node) + node.cloudFile.cloud?.type()?.let { cloudType -> + getLruCacheFor(cloudType)?.let { diskCache -> + if (diskCache[cacheKey] != null) { + diskCache.delete(cacheKey) + } + } + } } } @@ -493,7 +502,7 @@ open class CryptoImplVaultFormat7 : CryptoImplDecorator { cryptoFile, // cloudContentRepository.write( // targetFile, // - data.decorate(from(encryptedTmpFile)), + data.decorate(from(encryptedTmpFile)), // UploadFileReplacingProgressAware(cryptoFile, progressAware), // replace, // encryptedTmpFile.length() diff --git a/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoImplVaultFormatPre7.kt b/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoImplVaultFormatPre7.kt index a750bf6e1..cc5f22c7b 100644 --- a/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoImplVaultFormatPre7.kt +++ b/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoImplVaultFormatPre7.kt @@ -128,9 +128,7 @@ internal class CryptoImplVaultFormatPre7( .filterIsInstance() .map { node -> ciphertextToCleartextNode(cryptoFolder, dirId, node) - } - .toList() - .filterNotNull() + }.toList().filterNotNull() } @Throws(BackendException::class) @@ -228,6 +226,7 @@ internal class CryptoImplVaultFormatPre7( @Throws(BackendException::class) override fun move(source: CryptoFile, target: CryptoFile): CryptoFile { assertCryptoFileAlreadyExists(target) + renameFileInCache(source, target) return file(target, cloudContentRepository.move(source.cloudFile, target.cloudFile), source.size) } @@ -248,6 +247,15 @@ internal class CryptoImplVaultFormatPre7( evictFromCache(node) } else if (node is CryptoFile) { cloudContentRepository.delete(node.cloudFile) + + val cacheKey = generateCacheKey(node) + node.cloudFile.cloud?.type()?.let { cloudType -> + getLruCacheFor(cloudType)?.let { diskCache -> + if (diskCache[cacheKey] != null) { + diskCache.delete(cacheKey) + } + } + } } } diff --git a/data/src/main/java/org/cryptomator/data/repository/DispatchingCloudContentRepository.kt b/data/src/main/java/org/cryptomator/data/repository/DispatchingCloudContentRepository.kt index 05b61418c..db4c330a4 100644 --- a/data/src/main/java/org/cryptomator/data/repository/DispatchingCloudContentRepository.kt +++ b/data/src/main/java/org/cryptomator/data/repository/DispatchingCloudContentRepository.kt @@ -14,6 +14,7 @@ import org.cryptomator.domain.repository.CloudContentRepository import org.cryptomator.domain.usecases.ProgressAware import org.cryptomator.domain.usecases.cloud.DataSource import org.cryptomator.domain.usecases.cloud.DownloadState +import org.cryptomator.domain.usecases.cloud.FileTransferState import org.cryptomator.domain.usecases.cloud.UploadState import java.io.File import java.io.OutputStream @@ -164,6 +165,20 @@ class DispatchingCloudContentRepository @Inject constructor( } } + @Throws(BackendException::class) + override fun associateThumbnails(list: List, progressAware: ProgressAware) { + if (list.isEmpty()) { + return + } + try { + list[0].cloud?.let { networkConnectionCheck.assertConnectionIsPresent(it) } ?: throw IllegalStateException("Parent's cloud shouldn't be null") + delegateFor(list[0]).associateThumbnails(list, progressAware) + } catch (e: AuthenticationException) { + delegates.remove(list[0].cloud) + throw e + } + } + @Throws(BackendException::class) override fun delete(node: CloudNode) { try { diff --git a/domain/src/main/java/org/cryptomator/domain/repository/CloudContentRepository.kt b/domain/src/main/java/org/cryptomator/domain/repository/CloudContentRepository.kt index b7ecb0420..31bafe066 100644 --- a/domain/src/main/java/org/cryptomator/domain/repository/CloudContentRepository.kt +++ b/domain/src/main/java/org/cryptomator/domain/repository/CloudContentRepository.kt @@ -8,6 +8,7 @@ import org.cryptomator.domain.exception.BackendException import org.cryptomator.domain.usecases.ProgressAware import org.cryptomator.domain.usecases.cloud.DataSource import org.cryptomator.domain.usecases.cloud.DownloadState +import org.cryptomator.domain.usecases.cloud.FileTransferState import org.cryptomator.domain.usecases.cloud.UploadState import java.io.File import java.io.OutputStream @@ -94,6 +95,11 @@ interface CloudContentRepository) + @Throws(BackendException::class) + fun associateThumbnails(list: List, progressAware: ProgressAware) { + // default implementation + } + @Throws(BackendException::class) fun delete(node: NodeType) diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/AssociateThumbnails.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/AssociateThumbnails.java new file mode 100644 index 000000000..84eec1c8e --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/AssociateThumbnails.java @@ -0,0 +1,27 @@ +package org.cryptomator.domain.usecases.cloud; + +import org.cryptomator.domain.CloudNode; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.CloudContentRepository; +import org.cryptomator.domain.usecases.ProgressAware; +import org.cryptomator.generator.Parameter; +import org.cryptomator.generator.UseCase; + +import java.util.List; + +@UseCase +public class AssociateThumbnails { + + private final CloudContentRepository cloudContentRepository; + private final List list; + + public AssociateThumbnails(CloudContentRepository cloudContentRepository, // + @Parameter List list) { + this.cloudContentRepository = cloudContentRepository; + this.list = list; + } + + public void execute(ProgressAware progressAware) throws BackendException { + cloudContentRepository.associateThumbnails(list, progressAware); + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/CloudFileModel.kt b/presentation/src/main/java/org/cryptomator/presentation/model/CloudFileModel.kt index e2fa8a71b..a2b1a15ef 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/model/CloudFileModel.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/model/CloudFileModel.kt @@ -1,14 +1,17 @@ package org.cryptomator.presentation.model +import org.cryptomator.data.cloud.crypto.CryptoFile import org.cryptomator.domain.CloudFile import org.cryptomator.domain.usecases.ResultRenamed import org.cryptomator.presentation.util.FileIcon +import java.io.File import java.util.Date class CloudFileModel(cloudFile: CloudFile, val icon: FileIcon) : CloudNodeModel(cloudFile) { val modified: Date? = cloudFile.modified val size: Long? = cloudFile.size + var thumbnail : File? = if (cloudFile is CryptoFile) cloudFile.thumbnail else null constructor(cloudFileRenamed: ResultRenamed, icon: FileIcon) : this(cloudFileRenamed.value(), icon) { oldName = cloudFileRenamed.oldName diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt index fa9e235f6..bd7673b04 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt @@ -25,10 +25,12 @@ import org.cryptomator.domain.usecases.CopyDataUseCase import org.cryptomator.domain.usecases.DownloadFile import org.cryptomator.domain.usecases.GetDecryptedCloudForVaultUseCase import org.cryptomator.domain.usecases.ResultRenamed +import org.cryptomator.domain.usecases.cloud.AssociateThumbnailsUseCase import org.cryptomator.domain.usecases.cloud.CreateFolderUseCase import org.cryptomator.domain.usecases.cloud.DeleteNodesUseCase import org.cryptomator.domain.usecases.cloud.DownloadFilesUseCase import org.cryptomator.domain.usecases.cloud.DownloadState +import org.cryptomator.domain.usecases.cloud.FileTransferState import org.cryptomator.domain.usecases.cloud.GetCloudListRecursiveUseCase import org.cryptomator.domain.usecases.cloud.GetCloudListUseCase import org.cryptomator.domain.usecases.cloud.MoveFilesUseCase @@ -80,6 +82,7 @@ import org.cryptomator.presentation.workflow.CreateNewVaultWorkflow import org.cryptomator.presentation.workflow.Workflow import org.cryptomator.util.ExceptionUtil import org.cryptomator.util.SharedPreferencesHandler +import org.cryptomator.util.ThumbnailsOption import org.cryptomator.util.file.FileCacheUtils import org.cryptomator.util.file.MimeType import org.cryptomator.util.file.MimeTypes @@ -96,6 +99,7 @@ import timber.log.Timber @PerView class BrowseFilesPresenter @Inject constructor( // private val getCloudListUseCase: GetCloudListUseCase, // + private val associateThumbnailsUseCase: AssociateThumbnailsUseCase, // private val createFolderUseCase: CreateFolderUseCase, // private val downloadFilesUseCase: DownloadFilesUseCase, // private val deleteNodesUseCase: DeleteNodesUseCase, // @@ -157,6 +161,59 @@ class BrowseFilesPresenter @Inject constructor( // @JvmField var openWritableFileNotification: OpenWritableFileNotification? = null + fun thumbnailsForVisibleNodes(visibleCloudNodes: List>) { + if (!sharedPreferencesHandler.useLruCache() || (sharedPreferencesHandler.generateThumbnails() != ThumbnailsOption.PER_FOLDER)) { + return + } + val toDownload = ArrayList() + visibleCloudNodes.forEach { node -> + if (node is CloudFileModel && isImageMediaType(node.name) && node.thumbnail == null) { + toDownload.add(node) + } + } + if (toDownload.isEmpty()) { + return + } + downloadAndGenerateThumbnails(toDownload) + } + + private fun downloadAndGenerateThumbnails(visibleCloudFiles: List) { + view?.showProgress( + visibleCloudFiles, // + ProgressModel( + progressStateModelMapper.toModel( // + DownloadState.download(visibleCloudFiles[0].toCloudNode()) + ), 0 + ) + ) + downloadFilesUseCase // + .withDownloadFiles(downloadFileUtil.createDownloadFilesFor(this, visibleCloudFiles)) // + .run(object : DefaultProgressAwareResultHandler, DownloadState>() { + override fun onFinished() { + view?.hideProgress(visibleCloudFiles) + } + + override fun onProgress(progress: Progress) { + if (!progress.isOverallComplete) { + view?.showProgress( + cloudFileModelMapper.toModel(progress.state().file()), // + progressModelMapper.toModel(progress) + ) + } + if (progress.isCompleteAndHasState) { + val cloudFile = progress.state().file() + val cloudFileModel = cloudFileModelMapper.toModel(cloudFile) + view?.addOrUpdateCloudNode(cloudFileModel) + } + } + + override fun onError(e: Throwable) { + view?.hideProgress(visibleCloudFiles) + super.onError(e) + } + }) + } + override fun workflows(): Iterable> { return listOf(addExistingVaultWorkflow, createNewVaultWorkflow) } @@ -201,6 +258,7 @@ class BrowseFilesPresenter @Inject constructor( // clearCloudList() } else { showCloudNodesCollectionInView(cloudNodes) + associateThumbnails(cloudNodes) } view?.showLoading(false) } @@ -229,6 +287,30 @@ class BrowseFilesPresenter @Inject constructor( // }) } + private fun associateThumbnails(cloudNodes: List) { + if (!sharedPreferencesHandler.useLruCache() || sharedPreferencesHandler.generateThumbnails() == ThumbnailsOption.NEVER) { + return + } + associateThumbnailsUseCase.withList(cloudNodes) + .run(object : DefaultProgressAwareResultHandler() { + override fun onProgress(progress: Progress) { + val state = progress.state() + state?.let { state -> + view?.addOrUpdateCloudNode(cloudFileModelMapper.toModel(state.file())) + } + } + + override fun onFinished() { + val images = view?.renderedCloudNodes()?.filterIsInstance()?.filter { file -> isImageMediaType(file.name) } ?: return + images.take(10).filter { img -> img.thumbnail == null }.let { firstImagesWithoutThumbnails -> + if (firstImagesWithoutThumbnails.isNotEmpty()) { + thumbnailsForVisibleNodes(firstImagesWithoutThumbnails) + } + } + } + }) + } + @Callback(dispatchResultOkOnly = false) fun getCloudListAfterAuthentication(result: ActivityResult, cloudFolderModel: CloudFolderModel) { if (result.isResultOk) { @@ -1322,7 +1404,8 @@ class BrowseFilesPresenter @Inject constructor( // copyDataUseCase, // moveFilesUseCase, // moveFoldersUseCase, // - getDecryptedCloudForVaultUseCase + getDecryptedCloudForVaultUseCase, // + associateThumbnailsUseCase ) this.authenticationExceptionHandler = authenticationExceptionHandler } 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 7b05f62fc..32f1fe1dc 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 @@ -1,5 +1,6 @@ package org.cryptomator.presentation.ui.adapter +import android.graphics.BitmapFactory import android.os.PatternMatcher import android.view.LayoutInflater import android.view.View @@ -30,6 +31,8 @@ import org.cryptomator.presentation.util.FileSizeHelper import org.cryptomator.presentation.util.FileUtil import org.cryptomator.presentation.util.ResourceHelper.Companion.getDrawable import org.cryptomator.util.SharedPreferencesHandler +import org.cryptomator.util.file.MimeType +import org.cryptomator.util.file.MimeTypes import javax.inject.Inject class BrowseFilesAdapter @Inject @@ -37,7 +40,8 @@ constructor( private val dateHelper: DateHelper, // private val fileSizeHelper: FileSizeHelper, // private val fileUtil: FileUtil, // - private val sharedPreferencesHandler: SharedPreferencesHandler + private val sharedPreferencesHandler: SharedPreferencesHandler, // + private val mimeTypes: MimeTypes // ) : RecyclerViewBaseAdapter, BrowseFilesAdapter.ItemClickListener, VaultContentViewHolder, ItemBrowseFilesNodeBinding>(CloudNodeModelNameAZComparator()), FastScrollRecyclerView.SectionedAdapter { private var chooseCloudNodeSettings: ChooseCloudNodeSettings? = null @@ -135,7 +139,16 @@ constructor( } private fun bindNodeImage(node: CloudNodeModel<*>) { - binding.cloudNodeImage.setImageResource(bindCloudNodeImage(node)) + if (node is CloudFileModel && isImageMediaType(node.name) && node.thumbnail != null) { + val bitmap = BitmapFactory.decodeFile(node.thumbnail!!.absolutePath) + binding.cloudNodeImage.setImageBitmap(bitmap) + } else { + binding.cloudNodeImage.setImageResource(bindCloudNodeImage(node)) + } + } + + private fun isImageMediaType(filename: String): Boolean { + return (mimeTypes.fromFilename(filename) ?: MimeType.WILDCARD_MIME_TYPE).mediatype == "image" } private fun bindCloudNodeImage(cloudNodeModel: CloudNodeModel<*>): Int { diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/FileSettingsBottomSheet.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/FileSettingsBottomSheet.kt index 62c1aa78a..e2a0a87c0 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/FileSettingsBottomSheet.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/FileSettingsBottomSheet.kt @@ -1,5 +1,6 @@ package org.cryptomator.presentation.ui.bottomsheet +import android.graphics.BitmapFactory import android.os.Bundle import android.view.View import org.cryptomator.generator.BottomSheet @@ -25,9 +26,15 @@ class FileSettingsBottomSheet : BaseBottomSheet(FragmentBro } } + private val onFastScrollStateChangeListener = object : OnFastScrollStateChangeListener { + @Override + override fun onFastScrollStop() { + thumbnailsForVisibleNodes() + } + + @Override + override fun onFastScrollStart() { + } + } + + private val onScrollListener = object : RecyclerView.OnScrollListener() { + @Override + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + super.onScrollStateChanged(recyclerView, newState) + if (newState == SCROLL_STATE_IDLE) { + thumbnailsForVisibleNodes() + } + } + } + val selectedCloudNodes: List> get() = cloudNodesAdapter.selectedCloudNodes() @@ -103,6 +128,8 @@ class BrowseFilesFragment : BaseFragment(FragmentBro binding.recyclerViewLayout.recyclerView.setHasFixedSize(true) binding.recyclerViewLayout.recyclerView.setPadding(0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 88f, resources.displayMetrics).toInt()) binding.recyclerViewLayout.recyclerView.clipToPadding = false + binding.recyclerViewLayout.recyclerView.setOnFastScrollStateChangeListener(onFastScrollStateChangeListener) + binding.recyclerViewLayout.recyclerView.addOnScrollListener(onScrollListener) browseFilesPresenter.onFolderRedisplayed(folder) @@ -114,6 +141,19 @@ class BrowseFilesFragment : BaseFragment(FragmentBro } } + private fun thumbnailsForVisibleNodes() { + val layoutManager = binding.recyclerViewLayout.recyclerView.layoutManager as LinearLayoutManager + val first = layoutManager.findFirstVisibleItemPosition() + val last = layoutManager.findLastVisibleItemPosition() + if (first == NO_POSITION || last == NO_POSITION) { + return + } + val visibleCloudNodes = cloudNodesAdapter.renderedCloudNodes().subList(first, last + 1) + if (!binding.swipeRefreshLayout.isRefreshing) { + browseFilesPresenter.thumbnailsForVisibleNodes(visibleCloudNodes) + } + } + private fun isNavigationMode(navigationMode: ChooseCloudNodeSettings.NavigationMode): Boolean = this.navigationMode == navigationMode private fun setupNavigationMode() { diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/SettingsFragment.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/SettingsFragment.kt index 50dc8c545..4fe3375fd 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/SettingsFragment.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/SettingsFragment.kt @@ -9,6 +9,7 @@ import android.widget.Toast import androidx.appcompat.app.AppCompatDelegate import androidx.biometric.BiometricManager import androidx.core.content.ContextCompat +import androidx.preference.ListPreference import androidx.preference.Preference import androidx.preference.PreferenceCategory import androidx.preference.PreferenceFragmentCompat @@ -80,6 +81,17 @@ class SettingsFragment : PreferenceFragmentCompat() { if (FALSE == newValue) { LruFileCacheUtil(requireContext()).clear() setupLruCacheSize() + + findPreference(THUMBNAIL_GENERATION)?.let { preference -> + preference.isSelectable = false + } + Toast.makeText(context, context?.getString(R.string.thumbnail_generation__deactivation_toast), Toast.LENGTH_LONG).show() + } + + if (TRUE == newValue) { + findPreference(THUMBNAIL_GENERATION)?.let { preference -> + preference.isSelectable = true + } } Toast.makeText(context, context?.getString(R.string.screen_settings_lru_cache_changed__restart_toast), Toast.LENGTH_SHORT).show() @@ -142,7 +154,6 @@ class SettingsFragment : PreferenceFragmentCompat() { private fun setupLruCacheSize() { val preference = findPreference(DISPLAY_LRU_CACHE_SIZE_ITEM_KEY) as Preference? - val size = LruFileCacheUtil(requireContext()).totalSize() val readableSize: String = if (size > 0) { @@ -327,6 +338,7 @@ class SettingsFragment : PreferenceFragmentCompat() { private const val UPDATE_INTERVAL_ITEM_KEY = "updateInterval" private const val DISPLAY_LRU_CACHE_SIZE_ITEM_KEY = "displayLruCacheSize" private const val LRU_CACHE_CLEAR_ITEM_KEY = "lruCacheClear" + private const val THUMBNAIL_GENERATION = "thumbnailGeneration" } } diff --git a/presentation/src/main/res/values/arrays.xml b/presentation/src/main/res/values/arrays.xml index e2f96320b..d93229e38 100644 --- a/presentation/src/main/res/values/arrays.xml +++ b/presentation/src/main/res/values/arrays.xml @@ -42,6 +42,19 @@ 1000 5000 + + @string/thumbnail_generation_never + @string/thumbnail_generation_readonly + @string/thumbnail_generation_file + @string/thumbnail_generation_folder + + + NEVER + READONLY + PER_FILE + PER_FOLDER + + @string/update_interval_1d @string/update_interval_never diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index d3e34cdce..d0804db84 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -666,6 +666,12 @@ 1 GB 5 GB + Never + Read Only + Generate Per File + Generate Per Folder + LRU cache disabled therefore also the thumbnails + Style Automatic (follow system) @@ -675,5 +681,6 @@ Once a day @string/lock_timeout_never + Thumbnail generation diff --git a/presentation/src/main/res/xml/preferences.xml b/presentation/src/main/res/xml/preferences.xml index 3a4fdaaa6..5f4ba441c 100644 --- a/presentation/src/main/res/xml/preferences.xml +++ b/presentation/src/main/res/xml/preferences.xml @@ -141,6 +141,15 @@ android:key="displayLruCacheSize" android:title="@string/screen_settings_lru_cache_size" /> + + diff --git a/util/src/main/java/org/cryptomator/util/SharedPreferencesHandler.kt b/util/src/main/java/org/cryptomator/util/SharedPreferencesHandler.kt index 0847e17c8..4a7c5d7d3 100644 --- a/util/src/main/java/org/cryptomator/util/SharedPreferencesHandler.kt +++ b/util/src/main/java/org/cryptomator/util/SharedPreferencesHandler.kt @@ -161,6 +161,16 @@ constructor(context: Context) : SharedPreferences.OnSharedPreferenceChangeListen return defaultSharedPreferences.getValue(PHOTO_UPLOAD_INCLUDING_VIDEOS, false) } + fun generateThumbnails(): ThumbnailsOption { + return when (defaultSharedPreferences.getValue(THUMBNAIL_GENERATION, "NEVER")) { + "NEVER" -> ThumbnailsOption.NEVER + "READONLY" -> ThumbnailsOption.READONLY + "PER_FILE" -> ThumbnailsOption.PER_FILE + "PER_FOLDER" -> ThumbnailsOption.PER_FOLDER + else -> ThumbnailsOption.NEVER + } + } + fun useLruCache(): Boolean { return defaultSharedPreferences.getValue(USE_LRU_CACHE, false) } @@ -318,6 +328,7 @@ constructor(context: Context) : SharedPreferences.OnSharedPreferenceChangeListen const val BIOMETRIC_AUTHENTICATION = "biometricAuthentication" const val CRYPTOMATOR_VARIANTS = "cryptomatorVariants" const val LICENSES_ACTIVITY = "licensesActivity" + const val THUMBNAIL_GENERATION = "thumbnailGeneration" } private inline fun SharedPreferences.edit(operation: (SharedPreferences.Editor) -> Unit) { diff --git a/util/src/main/java/org/cryptomator/util/ThumbnailsOption.kt b/util/src/main/java/org/cryptomator/util/ThumbnailsOption.kt new file mode 100644 index 000000000..e82c2500f --- /dev/null +++ b/util/src/main/java/org/cryptomator/util/ThumbnailsOption.kt @@ -0,0 +1,8 @@ +package org.cryptomator.util + +enum class ThumbnailsOption { + NEVER, + READONLY, + PER_FILE, + PER_FOLDER +} \ No newline at end of file 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..301a82264 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, LOCAL } 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.LOCAL -> File(parent, "LruCacheLocal") else -> throw IllegalStateException() } }