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..b22eba925 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,16 @@ abstract class CryptoImplDecorator( while (decryptingReadableByteChannel.read(buff).also { read = it } > 0) { buff.flip() data.write(buff.array(), 0, buff.remaining()) + if (genThumbnail) { + try { + thumbnailWriter.write(buff.array(), 0, buff.remaining()) + } catch (e: IOException){ + Timber.w(e, "Failed to write thumbnail to output stream: ${cryptoFile.name} - skipping thumbnail generation") + } + } + decrypted += read.toLong() + progressAware .onProgress( Progress.progress(DownloadState.decryption(cryptoFile)) // @@ -332,16 +430,200 @@ abstract class CryptoImplDecorator( ) } } + thumbnailWriter.flush() } } finally { encryptedTmpFile.delete() progressAware.onProgress(Progress.completed(DownloadState.decryption(cryptoFile))) } + + // Finalize the thumbnail generation: close thumbnail writer first, then wait for thumbnail generation to complete + if (genThumbnail) { + closeQuietly(thumbnailWriter) + try { + futureThumbnail.get(5, java.util.concurrent.TimeUnit.SECONDS) + } catch (e: java.util.concurrent.TimeoutException) { + Timber.w("Thumbnail generation timed out for ${cryptoFile.name}") + futureThumbnail.cancel(true) + } catch (e: Exception) { + Timber.w(e, "Non-fatal error while waiting for thumbnail generation for ${cryptoFile.name}") + } finally { + closeQuietly(thumbnailReader) + } + } } catch (e: IOException) { throw FatalBackendException(e) } } + private fun closeQuietly(closeable: Closeable) { + try { + closeable.close(); + } catch (e: IOException) { + Timber.d(e, "IOException occurred while closing Closeable") + } + } + + private fun startThumbnailGeneratorThread(cryptoFile: CryptoFile, diskCache: DiskLruCache, cacheKey: String, thumbnailReader: PipedInputStream): Future<*> { + return thumbnailExecutorService.submit { + try { + val options = BitmapFactory.Options() + val thumbnailBitmap: Bitmap? + + // Use aggressive sampling for memory efficiency with large images + // Estimate file size and adjust sample size accordingly + val fileSize = cryptoFile.size ?: 0L + val fileSizeMB = fileSize / (1024 * 1024) + + // Calculate sample size based on file size to prevent OOM + val sampleSize = when { + fileSizeMB > 50 -> 16 // 1/256 of original size for very large files + fileSizeMB > 30 -> 12 // 1/144 of original size for large files + fileSizeMB > 20 -> 8 // 1/64 of original size for medium-large files + fileSizeMB > 10 -> 6 // 1/36 of original size for medium files + else -> 4 // 1/16 of original size for smaller files + } + + options.inSampleSize = sampleSize + options.inPreferredConfig = Bitmap.Config.RGB_565 + + Timber.d("Generating thumbnail for ${cryptoFile.name} (${fileSizeMB}MB) with sampleSize: $sampleSize") + + val bitmap = BitmapFactory.decodeStream(thumbnailReader, null, options) + if (bitmap == null) { + closeQuietly(thumbnailReader) + Timber.w("Failed to decode bitmap for thumbnail generation: ${cryptoFile.name}") + return@submit + } + + val thumbnailWidth = 100 + val thumbnailHeight = 100 + thumbnailBitmap = ThumbnailUtils.extractThumbnail(bitmap, thumbnailWidth, thumbnailHeight) + + // Clean up the original bitmap to free memory immediately + if (bitmap != thumbnailBitmap) { + bitmap.recycle() + } + + if (thumbnailBitmap != null) { + storeThumbnail(diskCache, cacheKey, thumbnailBitmap) + thumbnailBitmap.recycle() // Clean up thumbnail bitmap after storing + } + closeQuietly(thumbnailReader) + + cryptoFile.thumbnail = diskCache[cacheKey] + Timber.d("Successfully generated thumbnail for ${cryptoFile.name}") + } catch (e: OutOfMemoryError) { + closeQuietly(thumbnailReader) + Timber.e(e, "OutOfMemoryError during thumbnail generation for large image: ${cryptoFile.name} (${(cryptoFile.size ?: 0L) / (1024 * 1024)}MB)") + // Try to recover by forcing garbage collection + System.gc() + } catch (e: java.io.IOException) { + closeQuietly(thumbnailReader) + if (e.message?.contains("Pipe closed") == true) { + Timber.d("Thumbnail generation stream closed (expected for large files): ${cryptoFile.name}") + } else { + Timber.w(e, "IOException during thumbnail generation for file: ${cryptoFile.name}") + } + } catch (e: Exception) { + closeQuietly(thumbnailReader) + Timber.e(e, "Bitmap generation crashed for file: ${cryptoFile.name}") + } + } + } + + 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) && isBitmapImage(fileName) + } + + private fun isBitmapImage(fileName: String): Boolean { + val mimeType = mimeTypes.fromFilename(fileName) + if (mimeType == null) { + return false + } + if (mimeType.mediatype != "image") { + return false + } + + when (mimeType.subtype) { + "png" -> return true + "apng" -> return true + "jpg" -> return true + "jpeg" -> return true + "avif" -> return true + "jfif" -> return true + "gif" -> return true + "bmp" -> return true + "pjpeg" -> return true + "pjp" -> return true + "webp" -> return true + "ico" -> return true + "cur" -> return true + "tif" -> return true + "tiff" -> return true + "svg" -> return false + } + return false + } + + 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/AndroidManifest.xml b/presentation/src/main/AndroidManifest.xml index cb407cbe2..06c01f102 100644 --- a/presentation/src/main/AndroidManifest.xml +++ b/presentation/src/main/AndroidManifest.xml @@ -32,6 +32,7 @@ android:allowBackup="false" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" + android:largeHeap="true" android:requestLegacyExternalStorage="true" android:supportsRtl="true" android:theme="@style/AppTheme" 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..52ea91efc 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,18 @@ 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 + val thumbnail : File? + get() = if (toCloudNode() is CryptoFile) (toCloudNode() as CryptoFile).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 eb9aece02..f0b3115dc 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt @@ -26,10 +26,12 @@ import org.cryptomator.domain.usecases.DownloadFile import org.cryptomator.domain.usecases.GetDecryptedCloudForVaultUseCase import org.cryptomator.domain.usecases.PrepareDownloadFilesUseCase 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 @@ -94,6 +97,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, // @@ -133,6 +137,9 @@ class BrowseFilesPresenter @Inject constructor( // private lateinit var existingFilesForUpload: MutableMap private lateinit var downloadFiles: MutableList + private var availableThumbnailsThreads = MAX_CONCURRENT_THUMBNAILS_THREADS + private val filesBeingDownloaded: MutableSet = mutableSetOf() + private var resumedAfterAuthentication = false @InjectIntent @@ -157,6 +164,35 @@ class BrowseFilesPresenter @Inject constructor( // @JvmField var openWritableFileNotification: OpenWritableFileNotification? = null + private fun downloadAndGenerateThumbnails(visibleCloudFiles: List) { + filesBeingDownloaded.addAll(visibleCloudFiles) + view?.replaceImagesWithDownloadIcon( + visibleCloudFiles + ) + downloadFilesUseCase // + .withDownloadFiles(downloadFileUtil.createDownloadFilesFor(this, visibleCloudFiles)) // + .run(object : DefaultProgressAwareResultHandler, DownloadState>() { + override fun onFinished() { + availableThumbnailsThreads++ // releasing the passed baton + Timber.tag("THUMBNAILS").i("[RELEASE] downloadAndGen (${availableThumbnailsThreads}/${MAX_CONCURRENT_THUMBNAILS_THREADS})") + } + + override fun onProgress(progress: Progress) { + if (progress.isCompleteAndHasState) { + val cloudFile = progress.state().file() + val cloudFileModel = cloudFileModelMapper.toModel(cloudFile) + filesBeingDownloaded.remove(cloudFileModel) + view?.addOrUpdateCloudNode(cloudFileModel) + } + } + + override fun onError(e: Throwable) { + view?.hideProgress(visibleCloudFiles) + super.onError(e) + } + }) + } + override fun workflows(): Iterable> { return listOf(addExistingVaultWorkflow, createNewVaultWorkflow) } @@ -169,6 +205,14 @@ class BrowseFilesPresenter @Inject constructor( // .run(DefaultResultHandler()) } setRefreshOnBackPressEnabled(enableRefreshOnBackpressSupplier.setInAction(false)) + + // Re-associate thumbnails for visible images when returning from image preview + val images = view?.renderedCloudNodes()?.filterIsInstance()?.filter { file -> + isImageMediaType(file.name) + } ?: return + if (images.isNotEmpty()) { + associateThumbnails(images.take(20)) // Increased from 10 to 20 for better coverage + } } fun onWindowFocusChanged(hasFocus: Boolean) { @@ -179,6 +223,9 @@ class BrowseFilesPresenter @Inject constructor( // fun onBackPressed() { unsubscribeAll() + Timber.tag("THUMBNAILS").i("[RESET] unsubscribe to all") + availableThumbnailsThreads = MAX_CONCURRENT_THUMBNAILS_THREADS + filesBeingDownloaded.clear() } fun onFolderDisplayed(folder: CloudFolderModel) { @@ -201,6 +248,10 @@ class BrowseFilesPresenter @Inject constructor( // clearCloudList() } else { showCloudNodesCollectionInView(cloudNodes) + val images = view?.renderedCloudNodes()?.filterIsInstance()?.filter { file -> + isImageMediaType(file.name) + } ?: return + associateThumbnails(images.take(10)) } view?.showLoading(false) } @@ -229,6 +280,64 @@ class BrowseFilesPresenter @Inject constructor( // }) } + fun associateThumbnails(cloudNodes: List>) { + if (!sharedPreferencesHandler.useLruCache() || sharedPreferencesHandler.generateThumbnails() == ThumbnailsOption.NEVER) { + return + } + if (cloudNodes.isEmpty()) { + return + } + if (availableThumbnailsThreads == 0) { + Timber.tag("THUMBNAILS").i("[DROP] all threads are in use!") + return + } + + availableThumbnailsThreads-- + Timber.tag("THUMBNAILS").i("[ACQUIRE] associate (${availableThumbnailsThreads}/${MAX_CONCURRENT_THUMBNAILS_THREADS})") + + val associatedCloudNodes = ArrayList>() + associateThumbnailsUseCase.withList(cloudNodeModelMapper.fromModels(cloudNodes)) + .run(object : DefaultProgressAwareResultHandler() { + override fun onProgress(progress: Progress) { + val state = progress.state() + Timber.tag("THUMBNAILS").i("associateThumbnailsUseCase - onProgress") + + state?.let { state -> + val file = cloudFileModelMapper.toModel(state.file()) + view?.addOrUpdateCloudNode(file) + associatedCloudNodes.add(file) + } + } + + override fun onFinished() { + Timber.tag("THUMBNAILS").i("associateThumbnailsUseCase - onFinished") + + if (sharedPreferencesHandler.generateThumbnails() != ThumbnailsOption.PER_FOLDER) { + availableThumbnailsThreads++ + Timber.tag("THUMBNAILS").i("[RELEASE] associate (${availableThumbnailsThreads}/${MAX_CONCURRENT_THUMBNAILS_THREADS})") + return + } + val toDownload = ArrayList() + cloudNodes.filter { node -> !associatedCloudNodes.contains(node) }.forEach { node -> + if (node is CloudFileModel && isImageMediaType(node.name) && node.thumbnail == null) { + if (filesBeingDownloaded.contains(node)) { + Timber.tag("THUMBNAILS").i("[SKIP] No double download!") + } else { + toDownload.add(node) + } + } + } + if (toDownload.isEmpty()) { + availableThumbnailsThreads++ + Timber.tag("THUMBNAILS").i("[RELEASE] associate (${availableThumbnailsThreads}/${MAX_CONCURRENT_THUMBNAILS_THREADS})") + return + } + + downloadAndGenerateThumbnails(toDownload) // passing of the baton, do not increase the number of threads + } + }) + } + @Callback(dispatchResultOkOnly = false) fun getCloudListAfterAuthentication(result: ActivityResult, cloudFolderModel: CloudFolderModel) { if (result.isResultOk) { @@ -888,6 +997,9 @@ class BrowseFilesPresenter @Inject constructor( // fun onFolderClicked(cloudFolderModel: CloudFolderModel) { unsubscribeAll() + Timber.tag("THUMBNAILS").i("[RESET] unsubscribe to all") + availableThumbnailsThreads = MAX_CONCURRENT_THUMBNAILS_THREADS + filesBeingDownloaded.clear() view?.navigateTo(cloudFolderModel) } @@ -1260,6 +1372,7 @@ class BrowseFilesPresenter @Inject constructor( // companion object { const val OPEN_FILE_FINISHED = 12 + private const val MAX_CONCURRENT_THUMBNAILS_THREADS = 2 val EXPORT_AFTER_APP_CHOOSER: ExportOperation = object : ExportOperation { override fun export(presenter: BrowseFilesPresenter, downloadFiles: List) { @@ -1287,7 +1400,8 @@ class BrowseFilesPresenter @Inject constructor( // moveFoldersUseCase, // getDecryptedCloudForVaultUseCase, // calculateFileHashUseCase, // - prepareDownloadFilesUseCase + prepareDownloadFilesUseCase, // + associateThumbnailsUseCase ) this.authenticationExceptionHandler = authenticationExceptionHandler } diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BrowseFilesActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BrowseFilesActivity.kt index f3d42fa02..b86d1a985 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BrowseFilesActivity.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BrowseFilesActivity.kt @@ -541,6 +541,14 @@ class BrowseFilesActivity : BaseActivity(ActivityLayoutBi browseFilesFragment().showProgress(nodes, progress) } + override fun replaceImageWithDownloadIcon(nodes: CloudNodeModel<*>) { + browseFilesFragment().replaceImageWithDownloadIcon(nodes) + } + + override fun replaceImagesWithDownloadIcon(nodes: List>) { + browseFilesFragment().replaceImagesWithDownloadIcon(nodes) + } + override fun hideProgress(node: CloudNodeModel<*>) { browseFilesFragment().hideProgress(node) } diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/ImagePreviewActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/ImagePreviewActivity.kt index 7b7ea88b0..c5412d144 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/ImagePreviewActivity.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/ImagePreviewActivity.kt @@ -73,7 +73,17 @@ class ImagePreviewActivity : BaseActivity(ActivityI toggleFullScreen() attachSystemUiVisibilityChangeListener() } catch (e: FatalBackendException) { - showError(getString(R.string.error_generic)) + // Check if it's a memory issue with large images + if (e.message?.contains("memory", ignoreCase = true) == true || + e.cause is OutOfMemoryError || + e.message?.contains("large", ignoreCase = true) == true) { + showError(getString(R.string.error_image_too_large)) + } else { + showError(getString(R.string.error_generic)) + } + finish() + } catch (e: OutOfMemoryError) { + showError(getString(R.string.error_image_too_large)) finish() } } diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/BrowseFilesView.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/BrowseFilesView.kt index da9a83af5..4828a0fcc 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/BrowseFilesView.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/BrowseFilesView.kt @@ -36,5 +36,6 @@ interface BrowseFilesView : View { fun showSymLinkDialog() fun showNoDirFileOrEmptyDialog(cryptoFolderName: String, cloudFolderPath: String) fun updateActiveFolderDueToAuthenticationProblem(folder: CloudFolderModel) - + fun replaceImageWithDownloadIcon(nodes: CloudNodeModel<*>) + fun replaceImagesWithDownloadIcon(nodes: List>) } 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..92d8d2a08 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,20 @@ 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) + if (bitmap == null){ + binding.cloudNodeImage.setImageResource(bindCloudNodeImage(node)) + return + } + 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 { @@ -319,6 +336,10 @@ constructor( bound?.progress = null } + fun replaceImageWithDownloadIcon() { + binding.cloudNodeImage.setImageResource(R.drawable.ic_file_download) + } + private fun switchTo(state: UiStateTest) { if (uiState !== state) { uiState = state 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.associateThumbnails(visibleCloudNodes) + } + } + private fun isNavigationMode(navigationMode: ChooseCloudNodeSettings.NavigationMode): Boolean = this.navigationMode == navigationMode private fun setupNavigationMode() { @@ -198,6 +238,19 @@ class BrowseFilesFragment : BaseFragment(FragmentBro } } + fun replaceImagesWithDownloadIcon(nodes: List>?) { + nodes?.forEach { node -> + replaceImageWithDownloadIcon(node) + } + } + + fun replaceImageWithDownloadIcon(node: CloudNodeModel<*>?) { + val viewHolder = viewHolderFor(node) + if (viewHolder.isPresent) { + viewHolder.get().replaceImageWithDownloadIcon() + } + } + fun hideProgress(nodes: List>?) { nodes?.forEach { node -> hideProgress(node) diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/ImagePreviewFragment.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/ImagePreviewFragment.kt index 88d70fa1a..5f5bd7412 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/ImagePreviewFragment.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/ImagePreviewFragment.kt @@ -83,7 +83,21 @@ class ImagePreviewFragment : Fragment() { binding.imageView.let { imageView -> imagePreviewFile?.let { imagePreviewFile -> imageView.orientation = SubsamplingScaleImageView.ORIENTATION_USE_EXIF - imagePreviewFile.uri?.let { imageView.setImage(ImageSource.uri(it)) } + // Configure SubsamplingScaleImageView for better memory handling of large images + imageView.setMinimumTileDpi(160) + imageView.setDebug(false) + imageView.setDoubleTapZoomDpi(240) + imageView.setDoubleTapZoomDuration(500) + imageView.setPanLimit(SubsamplingScaleImageView.PAN_LIMIT_INSIDE) + // Remove the problematic line - SubsamplingScaleImageView will use default decoder + imagePreviewFile.uri?.let { + try { + imageView.setImage(ImageSource.uri(it)) + } catch (e: OutOfMemoryError) { + // Handle OOM gracefully + presenter.onImagePreviewClicked() // This will show an error through the presenter + } + } } } } 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 89a73c300..6ef4efb18 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-ja-rJP/strings.xml b/presentation/src/main/res/values-ja-rJP/strings.xml index bc3b4cb1b..c93ea98f1 100644 --- a/presentation/src/main/res/values-ja-rJP/strings.xml +++ b/presentation/src/main/res/values-ja-rJP/strings.xml @@ -4,6 +4,7 @@ 暗号化 エラーが発生しました + 画像が大きすぎて表示できません。より小さい画像をお試しください。 認証に失敗しました 認証に失敗しました。%1$s を使用してログインしてください ネットワーク接続がありません 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 5f6a89c58..860998c80 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -12,6 +12,7 @@ An error occurred + Image is too large to display. Please try with a smaller image. Authentication failed Authentication failed, please login using %1$s No network connection @@ -666,6 +667,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 +682,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 ac100058b..55e544929 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() } }