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..f8aeb5711 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 @@ -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,46 @@ 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.newFixedThreadPool(3, threadFactory) + } + + protected fun getLruCacheFor(type: CloudType): DiskLruCache? { + return getOrCreateLruCache(getCacheTypeFromCloudType(type), sharedPreferencesHandler.lruCacheSize()) + } + + private fun getOrCreateLruCache(key: LruFileCacheUtil.Cache, cacheSize: Int): DiskLruCache? { + return diskLruCache.computeIfAbsent(key) { + val where = LruFileCacheUtil(context).resolve(it) + try { + DiskLruCache.create(where, cacheSize.toLong()) + } catch (e: IOException) { + Timber.tag("CryptoImplDecorator").e(e, "Failed to setup LRU cache for $where.name") + null + } + } + } + + 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 +368,21 @@ 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(ciphertextFile) + val genThumbnail = isGenerateThumbnailsEnabled(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 +394,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 +409,88 @@ abstract class CryptoImplDecorator( ) } } + thumbnailWriter.flush() + 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 + } + } + + 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 = 175 + val thumbnailHeight = 175 + thumbnailBitmap = ThumbnailUtils.extractThumbnail(bitmap, thumbnailWidth, thumbnailHeight) + + if (thumbnailBitmap != null) { + storeThumbnail(diskCache, cacheKey, thumbnailBitmap) + } + + closeQuietly(thumbnailReader) + } catch (e: Exception) { + Timber.e("Bitmap generation crashed") + } + } + } + + protected fun generateCacheKey(cloudFile: CloudFile): String { + return buildString { + if (cloudFile.cloud?.id() != null) + this.append(cloudFile.cloud!!.id()) + else + this.append("c") // "common" + this.append("-") + this.append(cloudFile.path.hashCode()) + } + } + + private fun isGenerateThumbnailsEnabled(cache: DiskLruCache?, fileName: String): Boolean { + return sharedPreferencesHandler.useLruCache() && + sharedPreferencesHandler.generateThumbnails() != ThumbnailsOption.NEVER && + cache != null && + isImageMediaType(fileName) + } + + 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() + } + + protected 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..02fcb2aeb 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) } @@ -166,6 +165,18 @@ open class CryptoImplVaultFormat7 : CryptoImplDecorator { } }.map { node -> ciphertextToCleartextNode(cryptoFolder, dirId, node) + }.onEach { cryptoNode -> + if (cryptoNode is CryptoFile && isImageMediaType(cryptoNode.name)) { + val cacheKey = generateCacheKey(cryptoNode.cloudFile) + cryptoNode.cloudFile.cloud?.type()?.let { cloudType -> + getLruCacheFor(cloudType)?.let { diskCache -> + val cacheFile = diskCache[cacheKey] + if (cacheFile != null) { + cryptoNode.thumbnail = cacheFile + } + } + } + } }.toList().filterNotNull() } @@ -449,6 +460,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 +513,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..882507480 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,6 +128,18 @@ internal class CryptoImplVaultFormatPre7( .filterIsInstance() .map { node -> ciphertextToCleartextNode(cryptoFolder, dirId, node) + }.onEach { cryptoNode -> + if (cryptoNode is CryptoFile && isImageMediaType(cryptoNode.name)) { + val cacheKey = generateCacheKey(cryptoNode.cloudFile) + cryptoNode.cloudFile.cloud?.type()?.let { cloudType -> + getLruCacheFor(cloudType)?.let { diskCache -> + val cacheFile = diskCache[cacheKey] + if (cacheFile != null) { + cryptoNode.thumbnail = cacheFile + } + } + } + } } .toList() .filterNotNull() @@ -248,6 +260,15 @@ internal class CryptoImplVaultFormatPre7( evictFromCache(node) } else if (node is CryptoFile) { 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) + } + } + } } } diff --git a/presentation/src/main/java/org/cryptomator/presentation/di/component/ActivityComponent.java b/presentation/src/main/java/org/cryptomator/presentation/di/component/ActivityComponent.java index ed3206b03..3b55b9fda 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/di/component/ActivityComponent.java +++ b/presentation/src/main/java/org/cryptomator/presentation/di/component/ActivityComponent.java @@ -32,6 +32,7 @@ import org.cryptomator.presentation.ui.fragment.ChooseCloudServiceFragment; import org.cryptomator.presentation.ui.fragment.CloudConnectionListFragment; import org.cryptomator.presentation.ui.fragment.CloudSettingsFragment; +import org.cryptomator.presentation.ui.fragment.GalleryFragment; import org.cryptomator.presentation.ui.fragment.ImagePreviewFragment; import org.cryptomator.presentation.ui.fragment.S3AddOrChangeFragment; import org.cryptomator.presentation.ui.fragment.SetPasswordFragment; @@ -75,6 +76,8 @@ public interface ActivityComponent { void inject(BrowseFilesFragment browseFilesFragment); + void inject(GalleryFragment galleryFragment); + void inject(ChooseCloudServiceFragment chooseCloudServiceFragment); void inject(SharedFilesActivity sharedFilesActivity); diff --git a/presentation/src/main/java/org/cryptomator/presentation/intent/BrowseFilesIntent.java b/presentation/src/main/java/org/cryptomator/presentation/intent/BrowseFilesIntent.java index a38712995..075b16c51 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/intent/BrowseFilesIntent.java +++ b/presentation/src/main/java/org/cryptomator/presentation/intent/BrowseFilesIntent.java @@ -12,6 +12,8 @@ public interface BrowseFilesIntent { @Optional String title(); + @Optional + Long vaultId(); @Optional ChooseCloudNodeSettings chooseCloudNodeSettings(); 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/model/CloudNodeModel.kt b/presentation/src/main/java/org/cryptomator/presentation/model/CloudNodeModel.kt index 4e07dd4af..05957766c 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/model/CloudNodeModel.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/model/CloudNodeModel.kt @@ -1,5 +1,6 @@ package org.cryptomator.presentation.model +import android.graphics.Bitmap import org.cryptomator.domain.CloudNode import java.io.Serializable @@ -8,6 +9,7 @@ abstract class CloudNodeModel internal constructor(private val cl var oldName: String? = null var progress: ProgressModel? = null var isSelected = false + val name: String get() = cloudNode.name val simpleName: String diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/AutoUploadChooseVaultPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/AutoUploadChooseVaultPresenter.kt index 1f9adce4f..7ed7a7225 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/AutoUploadChooseVaultPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/AutoUploadChooseVaultPresenter.kt @@ -141,6 +141,7 @@ class AutoUploadChooseVaultPresenter @Inject constructor( // requestActivityResult( // ActivityResultCallbacks.onAutoUploadChooseLocation(vaultModel), // Intents.browseFilesIntent() // + .withVaultId(vaultModel.vaultId) // .withFolder(decryptedRoot) // .withTitle(vaultModel.name) // .withChooseCloudNodeSettings( // 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..b114d5660 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt @@ -2,6 +2,7 @@ package org.cryptomator.presentation.presenter import android.content.ActivityNotFoundException import android.content.Intent +import android.graphics.BitmapFactory import android.net.Uri import android.provider.DocumentsContract import android.widget.Toast @@ -513,6 +514,7 @@ class BrowseFilesPresenter @Inject constructor( // ) } else if (!lowerFileName.endsWith(".gif") && isImageMediaType(cloudFile.name)) { val cloudFileNodes = previewCloudFileNodes + val imagePreviewStore = ImagePreviewFilesStore( // cloudFileNodes, // cloudFileNodes.indexOf(cloudFile) @@ -1097,7 +1099,8 @@ class BrowseFilesPresenter @Inject constructor( // private fun moveIntentFor(parent: CloudFolderModel, sourceNodes: List>): IntentBuilder { val foldersToMove = nodesFor(sourceNodes, CloudFolderModel::class) as List - return Intents.browseFilesIntent() // + val vauldId = view?.folder?.vault()?.vaultId + val browseFilesIntentBuilder = Intents.browseFilesIntent() // .withTitle(effectiveMoveTitle()) // .withFolder(parent) // .withChooseCloudNodeSettings( // @@ -1110,6 +1113,8 @@ class BrowseFilesPresenter @Inject constructor( // .excludingFolder(if (foldersToMove.isEmpty()) null else foldersToMove) // .build() ) + vauldId?.let { browseFilesIntentBuilder.withVaultId(it) } + return browseFilesIntentBuilder } private fun effectiveMoveTitle(): String { diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/SharedFilesPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/SharedFilesPresenter.kt index b9c87bf3a..4d5bb204f 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/SharedFilesPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/SharedFilesPresenter.kt @@ -343,6 +343,7 @@ class SharedFilesPresenter @Inject constructor( // requestActivityResult( // ActivityResultCallbacks.onChooseLocation(vaultModel), // Intents.browseFilesIntent() // + .withVaultId(vaultModel.vaultId) // .withFolder(decryptedRoot) // .withTitle(vaultModel.name) // .withChooseCloudNodeSettings( // 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..ec6ebbc67 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 @@ -50,6 +50,8 @@ import org.cryptomator.presentation.ui.dialog.ReplaceDialog import org.cryptomator.presentation.ui.dialog.SymLinkDialog import org.cryptomator.presentation.ui.dialog.UploadCloudFileDialog import org.cryptomator.presentation.ui.fragment.BrowseFilesFragment +import org.cryptomator.presentation.ui.fragment.FilesFragmentInterface +import org.cryptomator.presentation.ui.fragment.GalleryFragment import java.util.regex.Pattern import javax.inject.Inject @@ -103,10 +105,7 @@ class BrowseFilesActivity : BaseActivity(ActivityLayoutBi get() = browseFilesFragment().folder override fun createFragment(): Fragment = - BrowseFilesFragment.newInstance( - browseFilesIntent.folder(), - browseFilesIntent.chooseCloudNodeSettings() - ) + createFragmentFor(browseFilesIntent.folder(), browseFilesIntent.chooseCloudNodeSettings()) override fun onDestroy() { super.onDestroy() @@ -422,14 +421,27 @@ class BrowseFilesActivity : BaseActivity(ActivityLayoutBi override fun navigateTo(folder: CloudFolderModel) { replaceFragment( - BrowseFilesFragment.newInstance( - folder, - browseFilesIntent.chooseCloudNodeSettings() - ), + createFragmentFor(folder), FragmentAnimation.NAVIGATE_IN_TO_FOLDER ) } + private fun createFragmentFor(folder: CloudFolderModel) : Fragment { + return createFragmentFor(folder, browseFilesIntent.chooseCloudNodeSettings()) + } + private fun createFragmentFor(folder: CloudFolderModel, chooseCloudNodeSettings : ChooseCloudNodeSettings?) : Fragment { + browseFilesIntent.vaultId()?.let { id -> + if(isAutoUploadFolder(id, folder.path)) { + return GalleryFragment.newInstance(folder, chooseCloudNodeSettings) + } + } + return BrowseFilesFragment.newInstance(folder, chooseCloudNodeSettings) + } + + private fun isAutoUploadFolder(vaultId : Long, folderPath : String) : Boolean { + return vaultId == sharedPreferencesHandler.photoUploadVault() && folderPath == sharedPreferencesHandler.photoUploadVaultFolder() + } + override fun showAddContentDialog() { VaultContentActionBottomSheet.newInstance(browseFilesFragment().folder) .show(supportFragmentManager, "AddContentDialog") @@ -512,10 +524,7 @@ class BrowseFilesActivity : BaseActivity(ActivityLayoutBi private fun createBackStackFor(sourceParent: CloudFolderModel) { replaceFragment( - BrowseFilesFragment.newInstance( - sourceParent, - browseFilesIntent.chooseCloudNodeSettings() - ), + createFragmentFor(sourceParent), FragmentAnimation.NAVIGATE_OUT_OF_FOLDER, false ) @@ -553,7 +562,7 @@ class BrowseFilesActivity : BaseActivity(ActivityLayoutBi browseFilesFragment().showLoading(loading) } - private fun browseFilesFragment(): BrowseFilesFragment = getCurrentFragment(R.id.fragment_container) as BrowseFilesFragment + private fun browseFilesFragment(): FilesFragmentInterface = getCurrentFragment(R.id.fragment_container) as FilesFragmentInterface override fun onCreateNewTextFileClicked(fileName: String) { browseFilesPresenter.onCreateNewTextFileClicked(browseFilesFragment().folder, fileName) diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/VaultListActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/VaultListActivity.kt index 409b867f2..e96c31174 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/VaultListActivity.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/VaultListActivity.kt @@ -162,7 +162,7 @@ class VaultListActivity : BaseActivity(Activi } override fun navigateToVaultContent(vault: VaultModel, decryptedRoot: CloudFolderModel) { - vaultListPresenter.startIntent(browseFilesIntent().withTitle(vault.name).withFolder(decryptedRoot)) + vaultListPresenter.startIntent(browseFilesIntent().withVaultId(vault.vaultId).withTitle(vault.name).withFolder(decryptedRoot)) } override fun renameVault(vaultModel: VaultModel) { 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/adapter/GalleryFilesAdapter.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/GalleryFilesAdapter.kt new file mode 100644 index 000000000..af148e216 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/GalleryFilesAdapter.kt @@ -0,0 +1,527 @@ +package org.cryptomator.presentation.ui.adapter + +import android.graphics.BitmapFactory +import android.os.PatternMatcher +import android.view.LayoutInflater +import android.view.View +import android.view.View.GONE +import android.view.View.VISIBLE +import android.view.ViewGroup +import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView +import org.cryptomator.domain.CloudNode +import org.cryptomator.presentation.R +import org.cryptomator.presentation.databinding.ItemGalleryFilesNodeBinding +import org.cryptomator.presentation.intent.ChooseCloudNodeSettings +import org.cryptomator.presentation.intent.ChooseCloudNodeSettings.NavigationMode.BROWSE_FILES +import org.cryptomator.presentation.intent.ChooseCloudNodeSettings.NavigationMode.SELECT_ITEMS +import org.cryptomator.presentation.model.CloudFileModel +import org.cryptomator.presentation.model.CloudFolderModel +import org.cryptomator.presentation.model.CloudNodeModel +import org.cryptomator.presentation.model.ProgressModel +import org.cryptomator.presentation.model.ProgressStateModel.Companion.COMPLETED +import org.cryptomator.presentation.model.comparator.CloudNodeModelDateNewestFirstComparator +import org.cryptomator.presentation.model.comparator.CloudNodeModelDateOldestFirstComparator +import org.cryptomator.presentation.model.comparator.CloudNodeModelSizeBiggestFirstComparator +import org.cryptomator.presentation.model.comparator.CloudNodeModelSizeSmallestFirstComparator +import org.cryptomator.presentation.ui.adapter.GalleryFilesAdapter.GalleryContentViewHolder +import org.cryptomator.presentation.util.DateHelper +import org.cryptomator.presentation.util.FileIcon +import org.cryptomator.presentation.util.FileSizeHelper +import org.cryptomator.presentation.util.FileUtil +import org.cryptomator.presentation.util.ResourceHelper.Companion.getDrawable +import org.cryptomator.util.SharedPreferencesHandler +import org.cryptomator.util.file.MimeType +import org.cryptomator.util.file.MimeTypes +import javax.inject.Inject + +class GalleryFilesAdapter @Inject +constructor( + private val dateHelper: DateHelper, // + private val fileSizeHelper: FileSizeHelper, // + private val fileUtil: FileUtil, // + private val sharedPreferencesHandler: SharedPreferencesHandler, // + private val mimeTypes: MimeTypes // +) : RecyclerViewBaseAdapter, GalleryFilesAdapter.ItemClickListener, GalleryContentViewHolder, ItemGalleryFilesNodeBinding>(CloudNodeModelDateNewestFirstComparator()), + FastScrollRecyclerView.SectionedAdapter { + + private var chooseCloudNodeSettings: ChooseCloudNodeSettings? = null + private var navigationMode: ChooseCloudNodeSettings.NavigationMode? = null + + private val isInSelectionMode: Boolean + get() = chooseCloudNodeSettings != null + + override fun createViewHolder(binding: ItemGalleryFilesNodeBinding, viewType: Int): GalleryContentViewHolder { + return GalleryContentViewHolder(binding) + } + + override fun getItemBinding(inflater: LayoutInflater, parent: ViewGroup?, viewType: Int): ItemGalleryFilesNodeBinding { + return ItemGalleryFilesNodeBinding.inflate(inflater, parent, false) + } + + fun addOrReplaceCloudNode(cloudNodeModel: CloudNodeModel<*>) { + if (contains(cloudNodeModel)) { + replaceItem(cloudNodeModel) + } else { + addItem(cloudNodeModel) + } + } + + fun triggerUpdateSelectedNodesNumberInfo() { + callback.onSelectedNodesChanged(selectedCloudNodes().size) + } + + fun replaceRenamedCloudFile(cloudNode: CloudNodeModel) { + itemCollection.forEach { nodes -> + if (nodes.javaClass == cloudNode.javaClass && nodes.name == cloudNode.oldName) { + val position = positionOf(nodes) + replaceItem(position, cloudNode) + return + } + } + } + + override fun setCallback(callback: ItemClickListener) { + this.callback = callback + } + + fun setChooseCloudNodeSettings(chooseCloudNodeSettings: ChooseCloudNodeSettings?) { + this.chooseCloudNodeSettings = chooseCloudNodeSettings + } + + fun updateNavigationMode(navigationMode: ChooseCloudNodeSettings.NavigationMode) { + this.navigationMode = navigationMode + if (isNavigationMode(BROWSE_FILES)) { + itemCollection.forEach { node -> + node.isSelected = false + } + } + notifyDataSetChanged() + } + + fun renderedCloudNodes(): List> { + return itemCollection + } + + fun selectedCloudNodes(): List> { + return all.filter { it.isSelected } + } + + fun hasUnSelectedNode(): Boolean { + return itemCount > selectedCloudNodes().size + } + + fun filterNodes(nodes: List>?, filterText: String): List>? { + return if (filterText.isNotEmpty()) { + if (sharedPreferencesHandler.useGlobSearch()) { + nodes?.filter { cloudNode -> PatternMatcher(filterText, PatternMatcher.PATTERN_SIMPLE_GLOB).match(cloudNode.name) } + } else { + nodes?.filter { cloudNode -> cloudNode.name.contains(filterText, true) } + } + } else { + nodes + } + } + + // descritto da R.layout.item_gallery_files_node + // sono state importate le sue componenti + // kotlinx.android.synthetic.main.item_gallery_files_node.view.galleryCloudNodeImage + inner class GalleryContentViewHolder internal constructor(private val binding: ItemGalleryFilesNodeBinding) : RecyclerViewBaseAdapter<*, *, *, *>.ItemViewHolder(binding.root) { + + private var uiState: UiStateTest? = null + + private var currentProgressIcon: Int = 0 + + private var bound: CloudNodeModel<*>? = null + + override fun bind(position: Int) { + bound = getItem(position) + bound?.let { internalBind(it) } + } + + private fun internalBind(node: CloudNodeModel<*>) { + clearPreviousHolderSelection() + bindNodeImage(node) + bindLongNodeClick(node) + bindFileOrFolder(node) + } + + private fun clearPreviousHolderSelection() { + // durante il rebind sta probabilmente riutilizzando lo stesso oggetto grafico (itemView) + // di un precente cloudNode che era stato selezionato + // e.g. se l'item 22 viene selezionato, cambia il foreground e quando viene + // ribindato con l'indice 0 rimane il foregound sbagliato! + binding.galleryItemContainer.foreground = null + } + + private fun bindNodeImage(node: CloudNodeModel<*>) { + if (node is CloudFileModel && isImageMediaType(node.name) && node.thumbnail != null) { + val bitmap = BitmapFactory.decodeFile(node.thumbnail!!.absolutePath) + binding.galleryCloudNodeImage.setImageBitmap(bitmap) + } else { + binding.galleryCloudNodeImage.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 { + if (cloudNodeModel is CloudFileModel) { + return FileIcon.fileIconFor(cloudNodeModel.name, fileUtil).iconResource + } else if (cloudNodeModel is CloudFolderModel) { + return R.drawable.node_folder + } + throw IllegalStateException("Could not identify the CloudNodeModel type") + } + + private fun bindLongNodeClick(node: CloudNodeModel<*>) { + enableNodeLongClick { + node.isSelected = true + callback.onNodeLongClicked() + true + } + } + + private fun bindFileOrFolder(node: CloudNodeModel<*>) { + if (node is CloudFileModel) { + internalBind(node) + } else { + internalBind(node as CloudFolderModel) + } + } + + private fun internalBind(file: CloudFileModel) { + switchTo(FileDetails()) + bindFile(file) + bindProgressIfPresent(file) + bindSelectItemsModeIfPresent(file) + bindFileSelectionModeIfPresent(file) + } + + private fun bindFile(file: CloudFileModel) { + enableNodeClick { callback.onFileClicked(file) } + } + + private fun bindFileSelectionModeIfPresent(file: CloudFileModel) { + if (isInSelectionMode) { + disableNodeLongClick() + if (!isSelectable(file)) { + binding.galleryItemContainer.isEnabled = false + } + } + } + + private fun internalBind(folder: CloudFolderModel) { + switchTo(FolderDetails()) + bindFolder(folder) + bindSelectItemsModeIfPresent(folder) + bindFolderSelectionModeIfPresent(folder) + bindProgressIfPresent(folder) + } + + private fun bindSelectItemsModeIfPresent(node: CloudNodeModel<*>) { + if (isNavigationMode(SELECT_ITEMS)) { + if (node is CloudFileModel) { + switchTo(FileSelection()) + } else { + switchTo(FolderSelection()) + } + disableNodeLongClick() + bindNodeSelection(node) + } + } + + private fun bindProgressIfPresent(node: CloudNodeModel<*>) { + node.progress?.let { showProgress(it) } + } + + private fun bindFolder(folder: CloudFolderModel) { +// itemView.cloudFolderText.text = folder.name + enableNodeClick { callback.onFolderClicked(folder) } + } + + private fun bindFolderSelectionModeIfPresent(folder: CloudFolderModel) { + if (isInSelectionMode) { + disableNodeLongClick() +// hideSettings() + if (!isSelectable(folder)) { + itemView.isEnabled = false + } + } + } + + private fun bindNodeSelection(cloudNodeModel: CloudNodeModel<*>) { + // this method is invoked for each item to be displayed! + +// itemView.galleryItemContainer.setOnLongClickListener { /* https://stackoverflow.com/a/12230526 +// As you may know, the View hierarchy in Android is represented by a tree. +// When you return true from the onItemLongClick() - it means that the View that +// currently received the event is the true event receiver and the event should +// not be propagated to the other Views in the tree; when you return false - +// you let the event be passed to the other Views that may consume it. +// */ +// toggleSelection(cloudNodeModel) +// true +// } + + enableNodeClick { + toggleSelection(cloudNodeModel) + } + + // first set + if (cloudNodeModel.isSelected) { + binding.galleryItemContainer.foreground = getDrawable(R.drawable.rectangle_selection_mode) + triggerUpdateSelectedNodesNumberInfo() + } + } + + private fun toggleSelection(cloudNodeModel: CloudNodeModel<*>) { + // toggle selection + cloudNodeModel.isSelected = !cloudNodeModel.isSelected + + // toggle rectangle + if (cloudNodeModel.isSelected) + binding.galleryItemContainer.foreground = getDrawable(R.drawable.rectangle_selection_mode) + else + binding.galleryItemContainer.foreground = null + + // update screen info + triggerUpdateSelectedNodesNumberInfo() + } + + fun showProgress(progress: ProgressModel?) { + bound?.progress = progress + when { + progress?.state() === COMPLETED -> hideProgress() + progress?.progress() == ProgressModel.UNKNOWN_PROGRESS_PERCENTAGE -> showIndeterminateProgress(progress) + progress?.state() !== COMPLETED -> progress?.let { showDeterminateProgress(it) } + } + } + + private fun showIndeterminateProgress(progress: ProgressModel) { + uiState?.let { switchTo(it.indeterminateProgress()) } + if (uiState?.isForFile == true) { +// itemView.cloudFileSubText.setText(progress.state().textResourceId()) + } else { +// itemView.cloudFolderActionText.setText(progress.state().textResourceId()) + } + + if (!progress.state().isSelectable) { + disableNodeActions() + } + } + + private fun disableNodeActions() { + itemView.isEnabled = false +// itemView.settings.visibility = GONE + } + + private fun enableNodeClick(clickListener: View.OnClickListener) { + itemView.setOnClickListener(clickListener) + } + + private fun enableNodeLongClick(longClickListener: View.OnLongClickListener) { + itemView.setOnLongClickListener(longClickListener) + } + + private fun disableNodeLongClick() { + itemView.setOnLongClickListener(null) + } + + private fun showDeterminateProgress(progress: ProgressModel) { + uiState?.let { switchTo(it.determinateProgress()) } + if (uiState?.isForFile == true) { + disableNodeActions() + binding.rlCloudFileProgress.cloudFile.progress = progress.progress() + if (currentProgressIcon != progress.state().imageResourceId()) { + currentProgressIcon = progress.state().imageResourceId() + binding.progressIcon.setImageDrawable(getDrawable(currentProgressIcon)) + } + } else { + // no determinate progress for folders +// itemView.cloudFolderActionText.setText(progress.state().textResourceId()) + } + } + + fun hideProgress() { + uiState?.let { switchTo(it.details()) } + bound?.progress = null + } + + private fun switchTo(state: UiStateTest) { + if (uiState !== state) { + uiState = state + uiState?.apply() + } + } + + fun selectNode(checked: Boolean) { + if (checked) + binding.galleryItemContainer.foreground = getDrawable(R.drawable.rectangle_selection_mode) + else + binding.galleryItemContainer.foreground = null + + bound?.let { it.isSelected = checked } + triggerUpdateSelectedNodesNumberInfo() + } + + abstract inner class UiStateTest(val isForFile: Boolean) { + + fun details(): UiStateTest { + return if (isForFile) { + FileDetails() + } else { + FolderDetails() + } + } + + fun determinateProgress(): UiStateTest { + return if (isForFile) { + FileDeterminateProgress() + } else { + FolderIndeterminateProgress() // no determinate progress for folders + } + } + + fun indeterminateProgress(): UiStateTest { + return if (isForFile) { + FileIndeterminateProgress() + } else { + FolderIndeterminateProgress() + } + } + + abstract fun apply() + } + + inner class FileDetails : UiStateTest(true) { + + override fun apply() { + itemView.isEnabled = true +// itemView.cloudFolderContent.visibility = GONE +// itemView.cloudFileContent.visibility = VISIBLE +// itemView.cloudFileText.visibility = VISIBLE +// itemView.cloudFileSubText.visibility = VISIBLE + binding.cloudFileProgress.visibility = GONE +// itemView.settings.visibility = VISIBLE +// itemView.itemCheckBox.visibility = GONE + } + } + + inner class FolderDetails : UiStateTest(false) { + + override fun apply() { + itemView.isEnabled = true +// itemView.cloudFileContent.visibility = GONE +// itemView.cloudFolderContent.visibility = VISIBLE +// itemView.cloudFolderText.visibility = VISIBLE +// itemView.cloudFolderActionText.visibility = GONE +// itemView.settings.visibility = VISIBLE +// itemView.itemCheckBox.visibility = GONE + } + } + + inner class FileDeterminateProgress : UiStateTest(true) { + + override fun apply() { +// itemView.cloudFolderContent.visibility = GONE +// itemView.cloudFileContent.visibility = VISIBLE +// itemView.cloudFileText.visibility = VISIBLE +// itemView.cloudFileSubText.visibility = GONE + binding.cloudFileProgress.visibility = VISIBLE +// itemView.itemCheckBox.visibility = GONE + } + } + + inner class FileIndeterminateProgress : UiStateTest(true) { + + override fun apply() { +// itemView.cloudFolderContent.visibility = GONE +// itemView.cloudFileContent.visibility = VISIBLE +// itemView.cloudFileText.visibility = VISIBLE +// itemView.cloudFileSubText.visibility = VISIBLE + binding.cloudFileProgress.visibility = GONE +// itemView.itemCheckBox.visibility = GONE + } + + } + + inner class FolderIndeterminateProgress : UiStateTest(false) { + + override fun apply() { +// itemView.cloudFileContent.visibility = GONE +// itemView.cloudFolderContent.visibility = VISIBLE +// itemView.cloudFolderText.visibility = VISIBLE +// itemView.cloudFolderActionText.visibility = VISIBLE +// itemView.itemCheckBox.visibility = GONE + } + } + + inner class FileSelection : UiStateTest(true) { + + override fun apply() { +// itemView.itemCheckBox.visibility = VISIBLE +// itemView.settings.visibility = GONE + } + } + + inner class FolderSelection : UiStateTest(false) { + + override fun apply() { +// itemView.itemCheckBox.visibility = VISIBLE +// itemView.settings.visibility = GONE + } + + } + } + + private fun isSelectable(folder: CloudFolderModel): Boolean { + return chooseCloudNodeSettings?.selectionMode()?.allowsFolders() == true // + && chooseCloudNodeSettings?.excludeFolder(folder) == false + } + + private fun isSelectable(file: CloudFileModel): Boolean { + return chooseCloudNodeSettings?.selectionMode()?.allowsFiles() == true // + && chooseCloudNodeSettings?.namePattern()?.matcher(file.name)?.matches() == true + } + + private fun isNavigationMode(navigationMode: ChooseCloudNodeSettings.NavigationMode): Boolean { + return this.navigationMode == navigationMode + } + + fun setSort(comparator: Comparator>) { + updateComparator(comparator) + } + + interface ItemClickListener { + + fun onFolderClicked(cloudFolderModel: CloudFolderModel) + + fun onFileClicked(cloudNodeModel: CloudFileModel) + + fun onNodeSettingsClicked(cloudNodeModel: CloudNodeModel<*>) + + fun onNodeLongClicked() + + fun onSelectedNodesChanged(selectedNodes: Int) + } + + override fun getSectionName(position: Int): String { + val node = all[position] + + if (node.isFolder) { + return node.name.first().toString() + } + + node as CloudFileModel + val formattedFileSize = fileSizeHelper.getFormattedFileSize((node).size) + val formattedModifiedDate = dateHelper.getModifiedDate((node).modified) + + return when (comparator) { + is CloudNodeModelDateNewestFirstComparator, is CloudNodeModelDateOldestFirstComparator -> formattedModifiedDate ?: node.name.first().toString() + is CloudNodeModelSizeBiggestFirstComparator, is CloudNodeModelSizeSmallestFirstComparator -> formattedFileSize ?: node.name.first().toString() + else -> all[position].name.first().toString() + } + } +} 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..33feac1fa 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,7 +26,13 @@ class FileSettingsBottomSheet : BaseBottomSheet(FragmentBrowseFilesBinding::inflate) { +class BrowseFilesFragment : BaseFragment(FragmentBrowseFilesBinding::inflate), FilesFragmentInterface { @Inject lateinit var cloudNodesAdapter: BrowseFilesAdapter @@ -43,7 +43,7 @@ class BrowseFilesFragment : BaseFragment(FragmentBro private var filterText: String = "" - var folder: CloudFolderModel + override var folder: CloudFolderModel get() = requireArguments().getSerializable(ARG_FOLDER) as CloudFolderModel set(updatedFolder) { arguments?.putSerializable(ARG_FOLDER, updatedFolder) @@ -82,7 +82,7 @@ class BrowseFilesFragment : BaseFragment(FragmentBro } } - val selectedCloudNodes: List> + override val selectedCloudNodes: List> get() = cloudNodesAdapter.selectedCloudNodes() override fun setupView() { @@ -99,6 +99,7 @@ class BrowseFilesFragment : BaseFragment(FragmentBro navigationMode?.let { cloudNodesAdapter.updateNavigationMode(it) } binding.recyclerViewLayout.recyclerView.layoutManager = LinearLayoutManager(context()) +// recyclerView.layoutManager = GridLayoutManager(context(), 2) binding.recyclerViewLayout.recyclerView.adapter = cloudNodesAdapter binding.recyclerViewLayout.recyclerView.setHasFixedSize(true) binding.recyclerViewLayout.recyclerView.setPadding(0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 88f, resources.displayMetrics).toInt()) @@ -176,19 +177,19 @@ class BrowseFilesFragment : BaseFragment(FragmentBro browseFilesPresenter.onFolderReloadContent(folder) } - fun show(nodes: List>?) { + override fun show(nodes: List>?) { cloudNodesAdapter.clear() cloudNodesAdapter.addAll(cloudNodesAdapter.filterNodes(nodes, filterText)) updateEmptyFolderHint() } - fun showProgress(nodes: List>?, progress: ProgressModel?) { + override fun showProgress(nodes: List>?, progress: ProgressModel?) { nodes?.forEach { node -> showProgress(node, progress) } } - fun showProgress(node: CloudNodeModel<*>?, progress: ProgressModel?) { + override fun showProgress(node: CloudNodeModel<*>?, progress: ProgressModel?) { val viewHolder = viewHolderFor(node) if (viewHolder.isPresent) { viewHolder.get().showProgress(progress) @@ -198,13 +199,13 @@ class BrowseFilesFragment : BaseFragment(FragmentBro } } - fun hideProgress(nodes: List>?) { + override fun hideProgress(nodes: List>?) { nodes?.forEach { node -> hideProgress(node) } } - fun hideProgress(cloudNode: CloudNodeModel<*>?) { + override fun hideProgress(cloudNode: CloudNodeModel<*>?) { val viewHolder = viewHolderFor(cloudNode) if (viewHolder.isPresent) { viewHolder.get().hideProgress() @@ -214,7 +215,7 @@ class BrowseFilesFragment : BaseFragment(FragmentBro } } - fun selectAllItems() { + override fun selectAllItems() { val hasUnSelectedNode = cloudNodesAdapter.hasUnSelectedNode() cloudNodesAdapter.renderedCloudNodes().forEach { node -> selectNode(node, hasUnSelectedNode) @@ -231,7 +232,7 @@ class BrowseFilesFragment : BaseFragment(FragmentBro } } - fun remove(cloudNode: List>?) { + override fun remove(cloudNode: List>?) { cloudNodesAdapter.deleteItems(cloudNode) updateEmptyFolderHint() } @@ -241,15 +242,15 @@ class BrowseFilesFragment : BaseFragment(FragmentBro return Optional.ofNullable(binding.recyclerViewLayout.recyclerView.findViewHolderForAdapterPosition(positionOf) as? BrowseFilesAdapter.VaultContentViewHolder) } - fun replaceRenamedCloudFile(cloudFile: CloudNodeModel) { + override fun replaceRenamedCloudFile(cloudFile: CloudNodeModel) { cloudNodesAdapter.replaceRenamedCloudFile(cloudFile) } - fun showLoading(loading: Boolean?) { + override fun showLoading(loading: Boolean?) { loading?.let { binding.swipeRefreshLayout.isRefreshing = it } } - fun addOrUpdate(cloudNode: CloudNodeModel<*>) { + override fun addOrUpdate(cloudNode: CloudNodeModel<*>) { cloudNodesAdapter.addOrReplaceCloudNode(cloudNode) updateEmptyFolderHint() } @@ -268,11 +269,11 @@ class BrowseFilesFragment : BaseFragment(FragmentBro private fun isSelectionMode(selectionMode: ChooseCloudNodeSettings.SelectionMode): Boolean = chooseCloudNodeSettings?.selectionMode() == selectionMode - fun renderedCloudNodes(): List> = cloudNodesAdapter.renderedCloudNodes() + override fun renderedCloudNodes(): List> = cloudNodesAdapter.renderedCloudNodes() - fun rootView(): View = binding.slidingCoordinatorLayout + override fun rootView(): View = binding.slidingCoordinatorLayout - fun navigationModeChanged(navigationMode: ChooseCloudNodeSettings.NavigationMode) { + override fun navigationModeChanged(navigationMode: ChooseCloudNodeSettings.NavigationMode) { updateNavigationMode(navigationMode) if (navigationMode == SELECT_ITEMS) { @@ -287,11 +288,11 @@ class BrowseFilesFragment : BaseFragment(FragmentBro cloudNodesAdapter.updateNavigationMode(navigationMode) } - fun setFilterText(query: String) { + override fun setFilterText(query: String) { filterText = query } - fun setSort(comparator: Comparator>) { + override fun setSort(comparator: Comparator>) { cloudNodesAdapter.setSort(comparator) } diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/FilesFragmentInterface.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/FilesFragmentInterface.kt new file mode 100644 index 000000000..d5cb811c9 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/FilesFragmentInterface.kt @@ -0,0 +1,29 @@ +package org.cryptomator.presentation.ui.fragment + +import org.cryptomator.domain.CloudNode +import org.cryptomator.presentation.intent.ChooseCloudNodeSettings +import org.cryptomator.presentation.model.CloudNodeModel +import org.cryptomator.presentation.model.CloudFolderModel +import org.cryptomator.presentation.model.ProgressModel +import android.view.View +interface FilesFragmentInterface { + + abstract val selectedCloudNodes: List> + abstract var folder: CloudFolderModel + + abstract fun rootView(): View + fun selectAllItems() + abstract fun setSort(comparator: Comparator>) + abstract fun renderedCloudNodes(): List> + abstract fun navigationModeChanged(navigationMode: ChooseCloudNodeSettings.NavigationMode) + abstract fun show(nodes: List>?) + abstract fun addOrUpdate(cloudNode: CloudNodeModel<*>) + abstract fun remove(cloudNode: List>?) + abstract fun replaceRenamedCloudFile(cloudFile: CloudNodeModel) + abstract fun showProgress(node: CloudNodeModel<*>?, progress: ProgressModel?) + abstract fun showProgress(nodes: List>?, progress: ProgressModel?) + abstract fun hideProgress(cloudNode : CloudNodeModel<*>?) + abstract fun hideProgress(nodes : List>?) + abstract fun showLoading(loading: Boolean?) + abstract fun setFilterText(query: String) +} \ No newline at end of file diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/GalleryFragment.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/GalleryFragment.kt new file mode 100644 index 000000000..ef9b49829 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/GalleryFragment.kt @@ -0,0 +1,329 @@ +package org.cryptomator.presentation.ui.fragment + +import android.annotation.SuppressLint +import android.graphics.Rect +import android.os.Bundle +import android.view.View +import android.view.View.GONE +import android.view.View.VISIBLE +import android.widget.RelativeLayout +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import org.cryptomator.domain.CloudNode +import org.cryptomator.generator.Fragment +import org.cryptomator.presentation.R +import org.cryptomator.presentation.R.dimen.global_padding +import org.cryptomator.presentation.databinding.FragmentGalleryViewBinding +import org.cryptomator.presentation.intent.ChooseCloudNodeSettings +import org.cryptomator.presentation.intent.ChooseCloudNodeSettings.NavigationMode.BROWSE_FILES +import org.cryptomator.presentation.intent.ChooseCloudNodeSettings.NavigationMode.SELECT_ITEMS +import org.cryptomator.presentation.intent.ChooseCloudNodeSettings.SelectionMode.FILES_ONLY +import org.cryptomator.presentation.intent.ChooseCloudNodeSettings.SelectionMode.FOLDERS_ONLY +import org.cryptomator.presentation.model.CloudFileModel +import org.cryptomator.presentation.model.CloudFolderModel +import org.cryptomator.presentation.model.CloudNodeModel +import org.cryptomator.presentation.model.ProgressModel +import org.cryptomator.presentation.presenter.BrowseFilesPresenter +import org.cryptomator.presentation.ui.adapter.GalleryFilesAdapter +import org.cryptomator.presentation.util.ResourceHelper.Companion.getPixelOffset +import java.util.Optional +import javax.inject.Inject + +@Fragment +class GalleryFragment : BaseFragment(FragmentGalleryViewBinding::inflate), FilesFragmentInterface { + + @Inject + lateinit var cloudNodesAdapter: GalleryFilesAdapter + + @Inject + lateinit var browseFilesPresenter: BrowseFilesPresenter + + private var navigationMode: ChooseCloudNodeSettings.NavigationMode? = null + + private var filterText: String = "" + + private val COLUMNS : Int = 3 + + override var folder: CloudFolderModel + get() = requireArguments().getSerializable(ARG_FOLDER) as CloudFolderModel + set(updatedFolder) { + arguments?.putSerializable(ARG_FOLDER, updatedFolder) + } + + private val chooseCloudNodeSettings: ChooseCloudNodeSettings? + get() = requireArguments().getSerializable(ARG_CHOOSE_CLOUD_NODE_SETTINGS) as ChooseCloudNodeSettings? + + private val refreshListener = SwipeRefreshLayout.OnRefreshListener { browseFilesPresenter.onRefreshTriggered(folder) } + + private val nodeClickListener = object : GalleryFilesAdapter.ItemClickListener { + override fun onFolderClicked(cloudFolderModel: CloudFolderModel) { + browseFilesPresenter.onFolderClicked(cloudFolderModel) + filterText = "" + browseFilesPresenter.invalidateOptionsMenu() + } + + override fun onFileClicked(cloudNodeModel: CloudFileModel) { + if (fileCanBeChosen(cloudNodeModel)) { + browseFilesPresenter.onFileChosen(cloudNodeModel) + } else { + browseFilesPresenter.onFileClicked(cloudNodeModel) + } + } + + override fun onNodeSettingsClicked(cloudNodeModel: CloudNodeModel<*>) { + browseFilesPresenter.onNodeSettingsClicked(cloudNodeModel) + } + + override fun onNodeLongClicked() { + browseFilesPresenter.onSelectionModeActivated() + } + + override fun onSelectedNodesChanged(selectedNodes: Int) { + browseFilesPresenter.onSelectedNodesChanged(selectedNodes) + } + } + + override val selectedCloudNodes: List> + get() = cloudNodesAdapter.selectedCloudNodes() + + override fun setupView() { + setupNavigationMode() + + binding.floatingActionButton.floatingActionButton.setOnClickListener { browseFilesPresenter.onAddContentClicked() } + binding.chooseLocationLayout.chooseLocationButton.setOnClickListener { browseFilesPresenter.onFolderChosen(folder) } + + binding.swipeRefreshLayout.setColorSchemeColors(ContextCompat.getColor(context(), R.color.colorPrimary)) + binding.swipeRefreshLayout.setOnRefreshListener(refreshListener) + + cloudNodesAdapter.setCallback(nodeClickListener) + cloudNodesAdapter.setChooseCloudNodeSettings(chooseCloudNodeSettings) + navigationMode?.let { cloudNodesAdapter.updateNavigationMode(it) } + + + binding.recyclerViewLayout.recyclerView.layoutManager = GridLayoutManager(context(), COLUMNS) + binding.recyclerViewLayout.recyclerView.adapter = cloudNodesAdapter + binding.recyclerViewLayout.recyclerView.setHasFixedSize(true) + + val spacing = resources.getDimensionPixelSize(global_padding) / 4 + + // bottom = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 88f, resources.displayMetrics).toInt() + binding.recyclerViewLayout.recyclerView.setPadding(spacing, spacing, spacing, spacing) + binding.recyclerViewLayout.recyclerView.clipToPadding = false + binding.recyclerViewLayout.recyclerView.clipChildren = false + + binding.recyclerViewLayout.recyclerView.addItemDecoration(object : RecyclerView.ItemDecoration() { + override fun getItemOffsets(outRect : Rect, view : View, parent : RecyclerView, state : RecyclerView.State) { + outRect.set(spacing, spacing, spacing, spacing) + } + }) + + browseFilesPresenter.onFolderRedisplayed(folder) + + when { + !hasCloudNodeSettings() -> setupViewForBrowseFilesMode() + isSelectionMode(FOLDERS_ONLY) -> setupViewForFolderSelection() + isSelectionMode(FILES_ONLY) -> setupViewForFilesSelection() + isNavigationMode(SELECT_ITEMS) -> setupViewForNodeSelectionMode() + } + } + + private fun isNavigationMode(navigationMode: ChooseCloudNodeSettings.NavigationMode): Boolean = this.navigationMode == navigationMode + + private fun setupNavigationMode() { + navigationMode = if (hasCloudNodeSettings()) { + chooseCloudNodeSettings?.navigationMode() + } else { + BROWSE_FILES + } + } + + private fun setupViewForBrowseFilesMode() { + showFloatingActionButton() + binding.swipeRefreshLayout.isEnabled = true + } + + private fun setupViewForNodeSelectionMode() { + hideFloatingActionButton() + disableSwipeRefresh() + } + + private fun disableSwipeRefresh() { + binding.swipeRefreshLayout.isRefreshing = false + binding.swipeRefreshLayout.isEnabled = false + } + + private fun setupViewForFilesSelection() { + binding.chooseLocationLayout.extraTextAndButtonLayout.visibility = VISIBLE + binding.chooseLocationLayout.chooseLocationButton.visibility = GONE + binding.chooseLocationLayout.extraText.text = chooseCloudNodeSettings?.extraText() + val layoutParams = binding.chooseLocationLayout.extraText.layoutParams as RelativeLayout.LayoutParams + layoutParams.addRule(RelativeLayout.CENTER_HORIZONTAL) + binding.chooseLocationLayout.extraText.layoutParams = layoutParams + disableSwipeRefresh() + } + + private fun setupViewForFolderSelection() { + binding.chooseLocationLayout.extraTextAndButtonLayout.visibility = VISIBLE + binding.chooseLocationLayout.chooseLocationButton.visibility = VISIBLE + binding.chooseLocationLayout.chooseLocationButton.text = chooseCloudNodeSettings?.buttonText() + binding.chooseLocationLayout.extraText.text = chooseCloudNodeSettings?.extraText() + binding.chooseLocationLayout.extraText.setPadding(getPixelOffset(global_padding), 0, 0, 0) + disableSwipeRefresh() + } + + @SuppressLint("RestrictedApi") // Due to bug https://stackoverflow.com/questions/50343634/android-p-visibilityawareimagebutton-setvisibility-can-only-be-called-from-the-s + private fun showFloatingActionButton() { + binding.floatingActionButton.floatingActionButton.visibility = VISIBLE + } + + @SuppressLint("RestrictedApi") // Due to bug https://stackoverflow.com/questions/50343634/android-p-visibilityawareimagebutton-setvisibility-can-only-be-called-from-the-s + private fun hideFloatingActionButton() { + binding.floatingActionButton.floatingActionButton.visibility = GONE + } + + override fun loadContent() { + browseFilesPresenter.onFolderDisplayed(folder) + } + + override fun loadContentSilent() { + browseFilesPresenter.onFolderReloadContent(folder) + } + + override fun show(nodes: List>?) { + cloudNodesAdapter.clear() + cloudNodesAdapter.addAll(cloudNodesAdapter.filterNodes(nodes, filterText)) + updateEmptyFolderHint() + } + + override fun showProgress(nodes: List>?, progress: ProgressModel?) { + nodes?.forEach { node -> + showProgress(node, progress) + } + } + + override fun showProgress(node: CloudNodeModel<*>?, progress: ProgressModel?) { + val viewHolder = viewHolderFor(node) + if (viewHolder.isPresent) { + viewHolder.get().showProgress(progress) + } else { + node?.progress = progress + node?.let { addOrUpdate(it) } + } + } + + override fun hideProgress(nodes: List>?) { + nodes?.forEach { node -> + hideProgress(node) + } + } + + override fun hideProgress(cloudNode: CloudNodeModel<*>?) { + val viewHolder = viewHolderFor(cloudNode) + if (viewHolder.isPresent) { + viewHolder.get().hideProgress() + } else { + cloudNode?.progress = ProgressModel.COMPLETED + cloudNode?.let { addOrUpdate(it) } + } + } + + override fun selectAllItems() { + val hasUnSelectedNode = cloudNodesAdapter.hasUnSelectedNode() + cloudNodesAdapter.renderedCloudNodes().forEach { node -> + selectNode(node, hasUnSelectedNode) + } + } + + private fun selectNode(node: CloudNodeModel<*>, selected: Boolean) { + val viewHolder = viewHolderFor(node) + if (viewHolder.isPresent) { + viewHolder.get().selectNode(selected) + } else { + node.isSelected = selected + addOrUpdate(node) + } + cloudNodesAdapter.triggerUpdateSelectedNodesNumberInfo() + } + + override fun remove(cloudNode: List>?) { + cloudNodesAdapter.deleteItems(cloudNode) + updateEmptyFolderHint() + } + + private fun viewHolderFor(nodeModel: CloudNodeModel<*>?): Optional { + val positionOf = cloudNodesAdapter.positionOf(nodeModel) + return Optional.ofNullable(binding.recyclerViewLayout.recyclerView.findViewHolderForAdapterPosition(positionOf) as? GalleryFilesAdapter.GalleryContentViewHolder) + } + + override fun replaceRenamedCloudFile(cloudFile: CloudNodeModel) { + cloudNodesAdapter.replaceRenamedCloudFile(cloudFile) + } + + override fun showLoading(loading: Boolean?) { + loading?.let { binding.swipeRefreshLayout.isRefreshing = it } + } + + override fun addOrUpdate(cloudNode: CloudNodeModel<*>) { + cloudNodesAdapter.addOrReplaceCloudNode(cloudNode) + updateEmptyFolderHint() + } + + private fun updateEmptyFolderHint() { + binding.rlViewEmptyFolder.emptyFolderHint.visibility = if (cloudNodesAdapter.isEmpty) VISIBLE else GONE + } + + private fun fileCanBeChosen(cloudFile: CloudFileModel): Boolean { + val settings = chooseCloudNodeSettings + return settings != null && settings.selectionMode().allowsFiles() && settings.namePattern().matcher(cloudFile.name).matches() + } + + private fun hasCloudNodeSettings(): Boolean = chooseCloudNodeSettings != null + + private fun isSelectionMode(selectionMode: ChooseCloudNodeSettings.SelectionMode): + Boolean = chooseCloudNodeSettings?.selectionMode() == selectionMode + + override fun renderedCloudNodes(): List> = cloudNodesAdapter.renderedCloudNodes() + + override fun rootView(): View = binding.slidingCoordinatorLayout + + override fun navigationModeChanged(navigationMode: ChooseCloudNodeSettings.NavigationMode) { + updateNavigationMode(navigationMode) + + if (navigationMode == SELECT_ITEMS) { + setupViewForNodeSelectionMode() + } else if (navigationMode == BROWSE_FILES) { + setupViewForBrowseFilesMode() + } + } + + private fun updateNavigationMode(navigationMode: ChooseCloudNodeSettings.NavigationMode) { + this.navigationMode = navigationMode + cloudNodesAdapter.updateNavigationMode(navigationMode) + } + + override fun setFilterText(query: String) { + filterText = query + } + + override fun setSort(comparator: Comparator>) { + cloudNodesAdapter.setSort(comparator) + } + + companion object { + + private const val ARG_FOLDER = "folder" + private const val ARG_CHOOSE_CLOUD_NODE_SETTINGS = "chooseCloudNodeSettings" + + fun newInstance(folder: CloudFolderModel, chooseCloudNodeSettings: ChooseCloudNodeSettings?): GalleryFragment { + val result = GalleryFragment() + val args = Bundle() + args.putSerializable(ARG_FOLDER, folder) + args.putSerializable(ARG_CHOOSE_CLOUD_NODE_SETTINGS, chooseCloudNodeSettings) + result.arguments = args + return result + } + } + +} 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..2a7b942a5 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 @@ -28,6 +28,7 @@ import org.cryptomator.presentation.ui.dialog.DisableSecureScreenDisclaimerDialo import org.cryptomator.presentation.ui.dialog.MicrosoftWorkaroundDisclaimerDialog import org.cryptomator.util.SharedPreferencesHandler import org.cryptomator.util.SharedPreferencesHandler.Companion.CRYPTOMATOR_VARIANTS +import org.cryptomator.util.SharedPreferencesHandler.Companion.THUMBNAIL_GENERATION import org.cryptomator.util.file.LruFileCacheUtil import java.lang.Boolean.FALSE import java.lang.Boolean.TRUE @@ -47,6 +48,7 @@ class SettingsFragment : PreferenceFragmentCompat() { setupAppVersion() setupLruCacheSize() setupLicense() + setupThumbnailGeneration() setupCryptomatorVariants() } @@ -109,6 +111,11 @@ class SettingsFragment : PreferenceFragmentCompat() { true } + private val thumbnailGenerationChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> + // TODO ... + true + } + private fun activity(): SettingsActivity = this.activity as SettingsActivity private fun isBiometricAuthenticationNotAvailableRemovePreference() { @@ -140,9 +147,13 @@ class SettingsFragment : PreferenceFragmentCompat() { } } + private fun setupThumbnailGeneration() { + val preference = findPreference(THUMBNAIL_GENERATION) as Preference? + // TODO ... + } + 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) { @@ -245,6 +256,7 @@ class SettingsFragment : PreferenceFragmentCompat() { } (findPreference(SharedPreferencesHandler.PHOTO_UPLOAD_VAULT) as Preference?)?.intent = Intent(context, AutoUploadChooseVaultActivity::class.java) (findPreference(SharedPreferencesHandler.LICENSES_ACTIVITY) as Preference?)?.intent = Intent(context, LicensesActivity::class.java) + (findPreference(SharedPreferencesHandler.THUMBNAIL_GENERATION) as Preference?)?.onPreferenceChangeListener = thumbnailGenerationChangeListener } fun deactivateDebugMode() { @@ -327,6 +339,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/java/org/cryptomator/presentation/util/DateHelper.kt b/presentation/src/main/java/org/cryptomator/presentation/util/DateHelper.kt index 575c57430..589005986 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/util/DateHelper.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/util/DateHelper.kt @@ -1,12 +1,18 @@ package org.cryptomator.presentation.util import org.cryptomator.presentation.R +import java.text.SimpleDateFormat +import java.time.format.DateTimeFormatter import java.util.Date import java.util.concurrent.TimeUnit import javax.inject.Inject class DateHelper @Inject constructor() { + private val dateFormatter by lazy { + SimpleDateFormat("yyyy/MM/dd - HH:mm") + } + fun getFormattedModifiedDate(modified: Date?): String? { return modified?.let { val modifiedAgo = currentDate().time - it.time @@ -14,6 +20,12 @@ class DateHelper @Inject constructor() { } } + fun getModifiedDate(modified: Date?): String? { + return modified?.let { + dateFormatter.format(it) + } + } + private fun convert(time: Long): String { return DurationHandler.values() .firstOrNull { it.isApplicable(time) } diff --git a/presentation/src/main/res/drawable/rectangle_selection_mode.xml b/presentation/src/main/res/drawable/rectangle_selection_mode.xml new file mode 100644 index 000000000..b8c5648c6 --- /dev/null +++ b/presentation/src/main/res/drawable/rectangle_selection_mode.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/layout/fragment_gallery_view.xml b/presentation/src/main/res/layout/fragment_gallery_view.xml new file mode 100644 index 000000000..1d483da24 --- /dev/null +++ b/presentation/src/main/res/layout/fragment_gallery_view.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/presentation/src/main/res/layout/item_gallery_files_node.xml b/presentation/src/main/res/layout/item_gallery_files_node.xml new file mode 100644 index 000000000..05a760b9b --- /dev/null +++ b/presentation/src/main/res/layout/item_gallery_files_node.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + diff --git a/presentation/src/main/res/values/arrays.xml b/presentation/src/main/res/values/arrays.xml index e2f96320b..e1fbbca51 100644 --- a/presentation/src/main/res/values/arrays.xml +++ b/presentation/src/main/res/values/arrays.xml @@ -42,6 +42,17 @@ 1000 5000 + + @string/thumbnail_generation_never + @string/thumbnail_generation_file + @string/thumbnail_generation_folder + + + "NEVER" + "PER_FILE" + "PER_FOLDER" + + @string/update_interval_1d @string/update_interval_never diff --git a/presentation/src/main/res/values/colors.xml b/presentation/src/main/res/values/colors.xml index 647f47c26..e98c5e153 100644 --- a/presentation/src/main/res/values/colors.xml +++ b/presentation/src/main/res/values/colors.xml @@ -1,6 +1,7 @@ #49B04A + #4D49B04A #66CC68 #407F41 diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index d3e34cdce..0c1b9390e 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -666,6 +666,10 @@ 1 GB 5 GB + Never + Per File + Per Folder + Style Automatic (follow system) @@ -675,5 +679,7 @@ Once a day @string/lock_timeout_never + Thumbnails + Thumbnail generation diff --git a/presentation/src/main/res/xml/preferences.xml b/presentation/src/main/res/xml/preferences.xml index 3a4fdaaa6..1ed780747 100644 --- a/presentation/src/main/res/xml/preferences.xml +++ b/presentation/src/main/res/xml/preferences.xml @@ -120,6 +120,18 @@ + + + + + ThumbnailsOption.NEVER + "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 +327,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..91fdc623b --- /dev/null +++ b/util/src/main/java/org/cryptomator/util/ThumbnailsOption.kt @@ -0,0 +1,7 @@ +package org.cryptomator.util + +enum class ThumbnailsOption { + NEVER, + 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() } }