-
-
Notifications
You must be signed in to change notification settings - Fork 165
Thumbnail support #533
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Thumbnail support #533
Changes from 21 commits
55a6929
f1ca280
ef39584
53779c4
e69bb98
ce1c989
b30afb1
e0fa1be
b31de8e
e20d516
ca57efb
041b2d8
abcc3e2
d02e811
6daefd7
4d0e715
fe0151d
00f766f
68b8744
7110a54
e05f206
64ba7cf
911c196
c25714d
82525c0
034a29b
80c013e
932d7d2
d85096b
a45edea
bdc28c2
340338a
24c9747
9ecfcda
f6c608e
861f0be
2271139
8ac93f4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
|
@@ -24,18 +30,31 @@ import org.cryptomator.domain.usecases.cloud.DownloadState | |
| import org.cryptomator.domain.usecases.cloud.FileBasedDataSource.Companion.from | ||
| 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.ExecutorService | ||
| import java.util.concurrent.Executors | ||
| import java.util.concurrent.Future | ||
| import java.util.function.Supplier | ||
| import timber.log.Timber | ||
|
|
||
|
|
||
| abstract class CryptoImplDecorator( | ||
|
|
@@ -50,6 +69,59 @@ abstract class CryptoImplDecorator( | |
| @Volatile | ||
| private var root: RootCryptoFolder? = null | ||
|
|
||
| private val sharedPreferencesHandler = SharedPreferencesHandler(context) | ||
|
|
||
| private var diskLruCache: MutableMap<LruFileCacheUtil.Cache, DiskLruCache?> = mutableMapOf() | ||
|
|
||
| private val mimeTypes = MimeTypes(MimeTypeMap()) | ||
|
|
||
| private val thumbnailExecutorService: ExecutorService by lazy { | ||
| val threadFactory = ThreadFactoryBuilder().setNameFormat("thumbnail-generation-thread-%d").build() | ||
| Executors.newFixedThreadPool(3, 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.cloudFile) | ||
| val newCacheKey = generateCacheKey(target.cloudFile) | ||
| 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() | ||
| } | ||
| } | ||
|
|
||
| @Throws(BackendException::class) | ||
| abstract fun folder(cryptoParent: CryptoFolder, cleartextName: String): CryptoFolder | ||
|
|
||
|
|
@@ -309,8 +381,21 @@ abstract class CryptoImplDecorator( | |
| @Throws(BackendException::class) | ||
| fun read(cryptoFile: CryptoFile, data: OutputStream, progressAware: ProgressAware<DownloadState>) { | ||
| val ciphertextFile = cryptoFile.cloudFile | ||
|
|
||
| val diskCache = cryptoFile.cloudFile.cloud?.type()?.let { getLruCacheFor(it) } | ||
| val cacheKey = generateCacheKey(ciphertextFile) | ||
| val genThumbnail = isThumbnailGenerationAvailable(diskCache, cryptoFile.name) | ||
|
|
||
| val thumbnailWriter = PipedOutputStream() | ||
| val thumbnailReader = PipedInputStream(thumbnailWriter) | ||
|
|
||
| try { | ||
| val encryptedTmpFile = readToTmpFile(cryptoFile, ciphertextFile, progressAware) | ||
|
|
||
| if (genThumbnail) { | ||
| startThumbnailGeneratorThread(diskCache, cacheKey, thumbnailReader) | ||
| } | ||
|
|
||
| progressAware.onProgress(Progress.started(DownloadState.decryption(cryptoFile))) | ||
| try { | ||
| Channels.newChannel(FileInputStream(encryptedTmpFile)).use { readableByteChannel -> | ||
|
|
@@ -322,7 +407,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 +422,106 @@ abstract class CryptoImplDecorator( | |
| ) | ||
| } | ||
| } | ||
| thumbnailWriter.flush() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Flush should be conditional to avoid NPE with nullable pipes. The flush is called unconditionally, but if pipes are made nullable (as suggested earlier), this will cause a NullPointerException when Apply this diff: - thumbnailWriter.flush()
+ if (genThumbnail) {
+ thumbnailWriter?.flush()
+ }Or better, move it into the finalization block at lines 441-453 where thumbnail operations are cleaned up. 🤖 Prompt for AI Agents |
||
| closeQuietly(thumbnailWriter) | ||
| } | ||
| } finally { | ||
| encryptedTmpFile.delete() | ||
| 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 | ||
| } | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| private fun startThumbnailGeneratorThread(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) | ||
| val thumbnailWidth = 100 | ||
| val thumbnailHeight = 100 | ||
| thumbnailBitmap = ThumbnailUtils.extractThumbnail(bitmap, thumbnailWidth, thumbnailHeight) | ||
|
|
||
| if (thumbnailBitmap != null) { | ||
| storeThumbnail(diskCache, cacheKey, thumbnailBitmap) | ||
| } | ||
|
|
||
| closeQuietly(thumbnailReader) | ||
| } catch (e: Exception) { | ||
| Timber.e("Bitmap generation crashed") | ||
| } | ||
| } | ||
coderabbitai[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| protected fun generateCacheKey(cloudFile: CloudFile): String { | ||
| return String.format("%s-%d", cloudFile.cloud?.id() ?: "common", cloudFile.path.hashCode()) | ||
| } | ||
|
||
|
|
||
| private fun isThumbnailGenerationAvailable(cache: DiskLruCache?, fileName: String): Boolean { | ||
| return isGenerateThumbnailsEnabled() && cache != null && isImageMediaType(fileName) | ||
| } | ||
|
|
||
| protected fun associateThumbnailIfInCache(list: List<CryptoNode?>): List<CryptoNode?> { | ||
| if (isGenerateThumbnailsEnabled()) { | ||
| val firstCryptoFile = list.find { it is CryptoFile } ?: return list | ||
| val cloudType = (firstCryptoFile as CryptoFile).cloudFile.cloud?.type() ?: return list | ||
| val diskCache = getLruCacheFor(cloudType) ?: return list | ||
| list.forEach { cryptoNode -> | ||
| if (cryptoNode is CryptoFile && isImageMediaType(cryptoNode.name)) { | ||
| val cacheKey = generateCacheKey(cryptoNode.cloudFile) | ||
| val cacheFile = diskCache[cacheKey] | ||
| if (cacheFile != null) { | ||
| cryptoNode.thumbnail = cacheFile | ||
| } else { | ||
| // TODO | ||
| // force thumbnail generation (~PER FOLDER) | ||
| val trash = File.createTempFile(cryptoNode.name, ".temp", internalCache) | ||
| read(cryptoNode, trash.outputStream(), ProgressAware.NO_OP_PROGRESS_AWARE_DOWNLOAD) | ||
| trash.delete() | ||
| } | ||
| } | ||
| } | ||
| } | ||
| return list | ||
coderabbitai[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| 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() | ||
| } | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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<DownloadState>): File { | ||
| val encryptedTmpFile = File.createTempFile(UUID.randomUUID().toString(), ".crypto", internalCache) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
| } | ||
|
|
@@ -166,6 +165,8 @@ open class CryptoImplVaultFormat7 : CryptoImplDecorator { | |
| } | ||
| }.map { node -> | ||
| ciphertextToCleartextNode(cryptoFolder, dirId, node) | ||
| }.also { | ||
| associateThumbnailIfInCache(it) | ||
|
||
| }.toList().filterNotNull() | ||
| } | ||
|
|
||
|
|
@@ -380,6 +381,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 +451,15 @@ open class CryptoImplVaultFormat7 : CryptoImplDecorator { | |
| } else { | ||
| cloudContentRepository.delete(node.cloudFile) | ||
| } | ||
|
|
||
| val cacheKey = generateCacheKey(node.cloudFile) | ||
|
||
| node.cloudFile.cloud?.type()?.let { cloudType -> | ||
| getLruCacheFor(cloudType)?.let { diskCache -> | ||
| if (diskCache[cacheKey] != null) { | ||
| diskCache.delete(cacheKey) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -493,7 +504,7 @@ open class CryptoImplVaultFormat7 : CryptoImplDecorator { | |
| cryptoFile, // | ||
| cloudContentRepository.write( // | ||
| targetFile, // | ||
| data.decorate(from(encryptedTmpFile)), | ||
| data.decorate(from(encryptedTmpFile)), // | ||
| UploadFileReplacingProgressAware(cryptoFile, progressAware), // | ||
| replace, // | ||
| encryptedTmpFile.length() | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Provide an exception message when throwing IllegalStateException
Throwing an
IllegalStateExceptionwithout a message can make debugging more difficult. Providing a descriptive message helps in identifying the issue quickly.Apply this diff to include an exception message:
else -> throw IllegalStateException() + else -> throw IllegalStateException("Unexpected CloudType: $type")📝 Committable suggestion
🧰 Tools
🪛 detekt