From 3a5d7cd29147d459a92974ce7e8914fbbe1cbcd7 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Fri, 21 Nov 2025 15:13:58 +0100 Subject: [PATCH 1/4] Feature: Thumbnail Cache Improvements & Critical Fixes Comprehensive update addressing thumbnail caching, avatar display, and login state management: - Dynamic credential retrieval for thumbnail loading - Account-specific ImageLoaders with shared caches - Removed deprecated AvatarManager - Fixed invisible avatars - Moved avatar loading off main thread - Fixed startup crashes - Removed duplicate methods - Code cleanup and refactoring --- .../main/java/eu/opencloud/android/MainApp.kt | 5 +- .../datamodel/ThumbnailsCacheManager.java | 473 ------------------ .../dependecyinjection/CommonModule.kt | 4 +- .../operations/SyncProfileOperation.kt | 12 +- .../accounts/ManageAccountsAdapter.kt | 23 +- .../presentation/avatar/AvatarManager.kt | 146 ------ .../presentation/avatar/AvatarUtils.kt | 57 +-- .../files/details/FileDetailsFragment.kt | 27 +- .../files/filelist/FileListAdapter.kt | 135 +++-- .../files/filelist/MainFileListFragment.kt | 49 +- .../removefile/RemoveFilesDialogFragment.kt | 16 +- .../presentation/sharing/ShareFileFragment.kt | 11 +- .../presentation/spaces/SpacesListAdapter.kt | 3 +- .../thumbnails/ThumbnailsRequester.kt | 151 +++--- .../android/ui/activity/DrawerActivity.kt | 22 +- .../android/ui/activity/ToolbarActivity.kt | 24 +- .../android/ui/adapter/DiskLruImageCache.java | 147 ------ .../adapter/ReceiveExternalFilesAdapter.java | 45 +- .../src/main/res/layout/opencloud_toolbar.xml | 1 - .../opencloud/android/data/ClientManager.kt | 2 + .../data/files/repository/OCFileRepository.kt | 2 +- 21 files changed, 305 insertions(+), 1050 deletions(-) delete mode 100644 opencloudApp/src/main/java/eu/opencloud/android/datamodel/ThumbnailsCacheManager.java delete mode 100644 opencloudApp/src/main/java/eu/opencloud/android/presentation/avatar/AvatarManager.kt delete mode 100644 opencloudApp/src/main/java/eu/opencloud/android/ui/adapter/DiskLruImageCache.java diff --git a/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt b/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt index 4ac0b4d0f..9e8c9bf5e 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt @@ -39,7 +39,7 @@ import android.widget.CheckBox import androidx.appcompat.app.AlertDialog import androidx.core.content.pm.PackageInfoCompat import eu.opencloud.android.data.providers.implementation.OCSharedPreferencesProvider -import eu.opencloud.android.datamodel.ThumbnailsCacheManager + import eu.opencloud.android.db.PreferenceManager import eu.opencloud.android.dependecyinjection.commonModule import eu.opencloud.android.dependecyinjection.localDataSourceModule @@ -107,8 +107,7 @@ class MainApp : Application() { SingleSessionManager.setUserAgent(userAgent) - // initialise thumbnails cache on background thread - ThumbnailsCacheManager.InitDiskCacheTask().execute() + initDependencyInjection() diff --git a/opencloudApp/src/main/java/eu/opencloud/android/datamodel/ThumbnailsCacheManager.java b/opencloudApp/src/main/java/eu/opencloud/android/datamodel/ThumbnailsCacheManager.java deleted file mode 100644 index d4147ce3e..000000000 --- a/opencloudApp/src/main/java/eu/opencloud/android/datamodel/ThumbnailsCacheManager.java +++ /dev/null @@ -1,473 +0,0 @@ -/** - * openCloud Android client application - * - * @author Tobias Kaminsky - * @author David A. Velasco - * @author Christian Schabesberger - * Copyright (C) 2020 ownCloud GmbH. - *

- * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License version 2, - * as published by the Free Software Foundation. - *

- * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - *

- * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package eu.opencloud.android.datamodel; - -import android.accounts.Account; -import android.content.res.Resources; -import android.graphics.Bitmap; -import android.graphics.Bitmap.CompressFormat; -import android.graphics.BitmapFactory; -import android.graphics.Canvas; -import android.graphics.drawable.BitmapDrawable; -import android.graphics.drawable.Drawable; -import android.media.ThumbnailUtils; -import android.net.Uri; -import android.os.AsyncTask; -import android.widget.ImageView; - -import androidx.core.content.ContextCompat; -import eu.opencloud.android.MainApp; -import eu.opencloud.android.R; -import eu.opencloud.android.domain.files.model.OCFile; -import eu.opencloud.android.domain.files.usecases.DisableThumbnailsForFileUseCase; -import eu.opencloud.android.domain.files.usecases.GetWebDavUrlForSpaceUseCase; -import eu.opencloud.android.domain.spaces.model.SpaceSpecial; -import eu.opencloud.android.lib.common.OpenCloudAccount; -import eu.opencloud.android.lib.common.OpenCloudClient; -import eu.opencloud.android.lib.common.SingleSessionManager; -import eu.opencloud.android.lib.common.accounts.AccountUtils; -import eu.opencloud.android.lib.common.http.HttpConstants; -import eu.opencloud.android.lib.common.http.methods.nonwebdav.GetMethod; -import eu.opencloud.android.ui.adapter.DiskLruImageCache; -import eu.opencloud.android.utils.BitmapUtils; -import kotlin.Lazy; -import org.jetbrains.annotations.NotNull; -import timber.log.Timber; - -import java.io.File; -import java.io.InputStream; -import java.lang.ref.WeakReference; -import java.net.URL; -import java.util.Locale; - -import static org.koin.java.KoinJavaComponent.inject; - -/** - * Manager for concurrent access to thumbnails cache. - */ -public class ThumbnailsCacheManager { - - private static final String CACHE_FOLDER = "thumbnailCache"; - - private static final Object mThumbnailsDiskCacheLock = new Object(); - private static DiskLruImageCache mThumbnailCache = null; - private static boolean mThumbnailCacheStarting = true; - - private static final int DISK_CACHE_SIZE = 1024 * 1024 * 10; // 10MB - private static final CompressFormat mCompressFormat = CompressFormat.JPEG; - private static final int mCompressQuality = 70; - private static OpenCloudClient mClient = null; - - private static final String PREVIEW_URI = "%s%s?x=%d&y=%d&c=%s&preview=1"; - private static final String SPACE_SPECIAL_URI = "%s?scalingup=0&a=1&x=%d&y=%d&c=%s&preview=1"; - - public static Bitmap mDefaultImg = - BitmapFactory.decodeResource( - MainApp.Companion.getAppContext().getResources(), - R.drawable.file_image - ); - - public static class InitDiskCacheTask extends AsyncTask { - - @Override - protected Void doInBackground(File... params) { - synchronized (mThumbnailsDiskCacheLock) { - mThumbnailCacheStarting = true; - - if (mThumbnailCache == null) { - try { - // Check if media is mounted or storage is built-in, if so, - // try and use external cache dir; otherwise use internal cache dir - final String cachePath = - MainApp.Companion.getAppContext().getExternalCacheDir().getPath() + - File.separator + CACHE_FOLDER; - Timber.d("create dir: %s", cachePath); - final File diskCacheDir = new File(cachePath); - mThumbnailCache = new DiskLruImageCache( - diskCacheDir, - DISK_CACHE_SIZE, - mCompressFormat, - mCompressQuality - ); - } catch (Exception e) { - Timber.e(e, "Thumbnail cache could not be opened "); - mThumbnailCache = null; - } - } - mThumbnailCacheStarting = false; // Finished initialization - mThumbnailsDiskCacheLock.notifyAll(); // Wake any waiting threads - } - return null; - } - } - - public static void addBitmapToCache(String key, Bitmap bitmap) { - synchronized (mThumbnailsDiskCacheLock) { - if (mThumbnailCache != null) { - mThumbnailCache.put(key, bitmap); - } - } - } - - public static void removeBitmapFromCache(String key) { - synchronized (mThumbnailsDiskCacheLock) { - if (mThumbnailCache != null) { - mThumbnailCache.removeKey(key); - } - } - } - - public static Bitmap getBitmapFromDiskCache(String key) { - synchronized (mThumbnailsDiskCacheLock) { - // Wait while disk cache is started from background thread - while (mThumbnailCacheStarting) { - try { - mThumbnailsDiskCacheLock.wait(); - } catch (InterruptedException e) { - Timber.e(e, "Wait in mThumbnailsDiskCacheLock was interrupted"); - } - } - if (mThumbnailCache != null) { - return mThumbnailCache.getBitmap(key); - } - } - return null; - } - - public static class ThumbnailGenerationTask extends AsyncTask { - private final WeakReference mImageViewReference; - private static Account mAccount; - private Object mFile; - private FileDataStorageManager mStorageManager; - - public ThumbnailGenerationTask(ImageView imageView, Account account) { - // Use a WeakReference to ensure the ImageView can be garbage collected - mImageViewReference = new WeakReference<>(imageView); - mAccount = account; - } - - public ThumbnailGenerationTask(ImageView imageView) { - // Use a WeakReference to ensure the ImageView can be garbage collected - mImageViewReference = new WeakReference<>(imageView); - } - - @Override - protected Bitmap doInBackground(Object... params) { - Bitmap thumbnail = null; - - try { - if (mAccount != null) { - OpenCloudAccount ocAccount = new OpenCloudAccount( - mAccount, - MainApp.Companion.getAppContext() - ); - mClient = SingleSessionManager.getDefaultSingleton(). - getClientFor(ocAccount, MainApp.Companion.getAppContext()); - } - - mFile = params[0]; - - if (mFile instanceof OCFile) { - thumbnail = doOCFileInBackground(); - } else if (mFile instanceof File) { - thumbnail = doFileInBackground(); - } else if (mFile instanceof SpaceSpecial) { - thumbnail = doSpaceImageInBackground(); - //} else { do nothing - } - - } catch (Throwable t) { - // the app should never break due to a problem with thumbnails - Timber.e(t, "Generation of thumbnail for " + mFile + " failed"); - if (t instanceof OutOfMemoryError) { - System.gc(); - } - } - - return thumbnail; - } - - protected void onPostExecute(Bitmap bitmap) { - if (bitmap != null) { - final ImageView imageView = mImageViewReference.get(); - final ThumbnailGenerationTask bitmapWorkerTask = getBitmapWorkerTask(imageView); - if (this == bitmapWorkerTask) { - String tagId = ""; - if (mFile instanceof OCFile) { - tagId = String.valueOf(((OCFile) mFile).getId()); - } else if (mFile instanceof File) { - tagId = String.valueOf(mFile.hashCode()); - } else if (mFile instanceof SpaceSpecial) { - tagId = ((SpaceSpecial) mFile).getId(); - } - if (String.valueOf(imageView.getTag()).equals(tagId)) { - imageView.setImageBitmap(bitmap); - } - } - } - } - - /** - * Add thumbnail to cache - * - * @param imageKey: thumb key - * @param bitmap: image for extracting thumbnail - * @param path: image path - * @param px: thumbnail dp - * @return Bitmap - */ - private Bitmap addThumbnailToCache(String imageKey, Bitmap bitmap, String path, int px) { - - Bitmap thumbnail = ThumbnailUtils.extractThumbnail(bitmap, px, px); - - // Rotate image, obeying exif tag - thumbnail = BitmapUtils.rotateImage(thumbnail, path); - - // Add thumbnail to cache - addBitmapToCache(imageKey, thumbnail); - - return thumbnail; - } - - /** - * Converts size of file icon from dp to pixel - * - * @return int - */ - private int getThumbnailDimension() { - // Converts dp to pixel - Resources r = MainApp.Companion.getAppContext().getResources(); - return Math.round(r.getDimension(R.dimen.file_icon_size_grid)); - } - - private String getPreviewUrl(OCFile ocFile, Account account) { - String baseUrl = mClient.getBaseUri() + "/remote.php/dav/files/" + AccountUtils.getUserId(account, MainApp.Companion.getAppContext()); - - if (ocFile.getSpaceId() != null) { - Lazy getWebDavUrlForSpaceUseCaseLazy = inject(GetWebDavUrlForSpaceUseCase.class); - baseUrl = getWebDavUrlForSpaceUseCaseLazy.getValue().invoke( - new GetWebDavUrlForSpaceUseCase.Params(ocFile.getOwner(), ocFile.getSpaceId()) - ); - - } - return String.format(Locale.ROOT, - PREVIEW_URI, - baseUrl, - Uri.encode(ocFile.getRemotePath(), "/"), - getThumbnailDimension(), - getThumbnailDimension(), - ocFile.getEtag()); - } - - private Bitmap doOCFileInBackground() { - OCFile file = (OCFile) mFile; - - final String imageKey = String.valueOf(file.getRemoteId()); - - // Check disk cache in background thread - Bitmap thumbnail = getBitmapFromDiskCache(imageKey); - - // Not found in disk cache - if (thumbnail == null || file.getNeedsToUpdateThumbnail()) { - - int px = getThumbnailDimension(); - - // Download thumbnail from server - if (mClient != null) { - GetMethod get; - try { - String uri = getPreviewUrl(file, mAccount); - Timber.d("URI: %s", uri); - get = new GetMethod(new URL(uri)); - int status = mClient.executeHttpMethod(get); - if (status == HttpConstants.HTTP_OK) { - InputStream inputStream = get.getResponseBodyAsStream(); - Bitmap bitmap = BitmapFactory.decodeStream(inputStream); - thumbnail = ThumbnailUtils.extractThumbnail(bitmap, px, px); - - // Handle PNG - if (file.getMimeType().equalsIgnoreCase("image/png")) { - thumbnail = handlePNG(thumbnail, px); - } - - // Add thumbnail to cache - if (thumbnail != null) { - addBitmapToCache(imageKey, thumbnail); - } - } else { - mClient.exhaustResponse(get.getResponseBodyAsStream()); - } - if (status == HttpConstants.HTTP_OK || status == HttpConstants.HTTP_NOT_FOUND) { - @NotNull Lazy disableThumbnailsForFileUseCaseLazy = inject(DisableThumbnailsForFileUseCase.class); - disableThumbnailsForFileUseCaseLazy.getValue().invoke(new DisableThumbnailsForFileUseCase.Params(file.getId())); - } - } catch (Exception e) { - Timber.e(e); - } - } - } - - return thumbnail; - - } - - private Bitmap handlePNG(Bitmap bitmap, int px) { - Bitmap resultBitmap = Bitmap.createBitmap(px, - px, - Bitmap.Config.ARGB_8888); - Canvas c = new Canvas(resultBitmap); - - c.drawColor(ContextCompat.getColor(MainApp.Companion.getAppContext(), R.color.background_color)); - c.drawBitmap(bitmap, 0, 0, null); - - return resultBitmap; - } - - private Bitmap doFileInBackground() { - File file = (File) mFile; - - final String imageKey = String.valueOf(file.hashCode()); - - // Check disk cache in background thread - Bitmap thumbnail = getBitmapFromDiskCache(imageKey); - - // Not found in disk cache - if (thumbnail == null) { - - int px = getThumbnailDimension(); - - Bitmap bitmap = BitmapUtils.decodeSampledBitmapFromFile( - file.getAbsolutePath(), px, px); - - if (bitmap != null) { - thumbnail = addThumbnailToCache(imageKey, bitmap, file.getPath(), px); - } - } - return thumbnail; - } - - private String getSpaceSpecialUri(SpaceSpecial spaceSpecial) { - // Converts dp to pixel - Resources r = MainApp.Companion.getAppContext().getResources(); - Integer spacesThumbnailSize = Math.round(r.getDimension(R.dimen.spaces_thumbnail_height)) * 2; - return String.format(Locale.ROOT, - SPACE_SPECIAL_URI, - spaceSpecial.getWebDavUrl(), - spacesThumbnailSize, - spacesThumbnailSize, - spaceSpecial.getETag()); - } - - private Bitmap doSpaceImageInBackground() { - SpaceSpecial spaceSpecial = (SpaceSpecial) mFile; - - final String imageKey = spaceSpecial.getId(); - - // Check disk cache in background thread - Bitmap thumbnail = getBitmapFromDiskCache(imageKey); - - // Not found in disk cache - if (thumbnail == null) { - int px = getThumbnailDimension(); - - // Download thumbnail from server - if (mClient != null) { - GetMethod get; - try { - String uri = getSpaceSpecialUri(spaceSpecial); - Timber.d("URI: %s", uri); - get = new GetMethod(new URL(uri)); - int status = mClient.executeHttpMethod(get); - if (status == HttpConstants.HTTP_OK) { - InputStream inputStream = get.getResponseBodyAsStream(); - Bitmap bitmap = BitmapFactory.decodeStream(inputStream); - thumbnail = ThumbnailUtils.extractThumbnail(bitmap, px, px); - - // Handle PNG - if (spaceSpecial.getFile().getMimeType().equalsIgnoreCase("image/png")) { - thumbnail = handlePNG(thumbnail, px); - } - - // Add thumbnail to cache - if (thumbnail != null) { - addBitmapToCache(imageKey, thumbnail); - } - } else { - mClient.exhaustResponse(get.getResponseBodyAsStream()); - } - } catch (Exception e) { - Timber.e(e); - } - } - } - - return thumbnail; - - } - } - - public static boolean cancelPotentialThumbnailWork(Object file, ImageView imageView) { - final ThumbnailGenerationTask bitmapWorkerTask = getBitmapWorkerTask(imageView); - - if (bitmapWorkerTask != null) { - final Object bitmapData = bitmapWorkerTask.mFile; - // If bitmapData is not yet set or it differs from the new data - if (bitmapData == null || bitmapData != file) { - // Cancel previous task - bitmapWorkerTask.cancel(true); - Timber.v("Cancelled generation of thumbnail for a reused imageView"); - } else { - // The same work is already in progress - return false; - } - } - // No task associated with the ImageView, or an existing task was cancelled - return true; - } - - private static ThumbnailGenerationTask getBitmapWorkerTask(ImageView imageView) { - if (imageView != null) { - final Drawable drawable = imageView.getDrawable(); - if (drawable instanceof AsyncThumbnailDrawable) { - final AsyncThumbnailDrawable asyncDrawable = (AsyncThumbnailDrawable) drawable; - return asyncDrawable.getBitmapWorkerTask(); - } - } - return null; - } - - public static class AsyncThumbnailDrawable extends BitmapDrawable { - private final WeakReference bitmapWorkerTaskReference; - - public AsyncThumbnailDrawable( - Resources res, Bitmap bitmap, ThumbnailGenerationTask bitmapWorkerTask - ) { - - super(res, bitmap); - bitmapWorkerTaskReference = new WeakReference<>(bitmapWorkerTask); - } - - ThumbnailGenerationTask getBitmapWorkerTask() { - return bitmapWorkerTaskReference.get(); - } - } -} diff --git a/opencloudApp/src/main/java/eu/opencloud/android/dependecyinjection/CommonModule.kt b/opencloudApp/src/main/java/eu/opencloud/android/dependecyinjection/CommonModule.kt index 7cbe1898b..04c978ea3 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/dependecyinjection/CommonModule.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/dependecyinjection/CommonModule.kt @@ -21,7 +21,7 @@ package eu.opencloud.android.dependecyinjection import androidx.work.WorkManager -import eu.opencloud.android.presentation.avatar.AvatarManager + import eu.opencloud.android.providers.AccountProvider import eu.opencloud.android.providers.ContextProvider import eu.opencloud.android.providers.CoroutinesDispatcherProvider @@ -35,7 +35,7 @@ import org.koin.dsl.module val commonModule = module { - single { AvatarManager() } + single { CoroutinesDispatcherProvider() } factory { OCContextProvider(androidContext()) } single { LogsProvider(get(), get()) } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/operations/SyncProfileOperation.kt b/opencloudApp/src/main/java/eu/opencloud/android/operations/SyncProfileOperation.kt index 26779d01c..ab41ef989 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/operations/SyncProfileOperation.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/operations/SyncProfileOperation.kt @@ -23,11 +23,11 @@ import android.accounts.Account import android.accounts.AccountManager import eu.opencloud.android.MainApp.Companion.appContext import eu.opencloud.android.domain.capabilities.usecases.GetStoredCapabilitiesUseCase -import eu.opencloud.android.domain.user.usecases.GetUserAvatarAsyncUseCase + import eu.opencloud.android.domain.user.usecases.GetUserInfoAsyncUseCase import eu.opencloud.android.domain.user.usecases.RefreshUserQuotaFromServerAsyncUseCase import eu.opencloud.android.lib.common.accounts.AccountUtils -import eu.opencloud.android.presentation.avatar.AvatarManager + import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -79,12 +79,8 @@ class SyncProfileOperation( } val shouldFetchAvatar = storedCapabilities?.isFetchingAvatarAllowed() ?: true if (shouldFetchAvatar) { - val getUserAvatarAsyncUseCase: GetUserAvatarAsyncUseCase by inject() - val userAvatarResult = getUserAvatarAsyncUseCase(GetUserAvatarAsyncUseCase.Params(account.name)) - AvatarManager().handleAvatarUseCaseResult(account, userAvatarResult) - if (userAvatarResult.isSuccess) { - Timber.d("Avatar synchronized for account ${account.name}") - } + // Avatar fetching is now handled by Coil on demand + Timber.d("Avatar sync handled by Coil for account ${account.name}") } else { Timber.d("Avatar for this account: ${account.name} won't be synced due to capabilities ") } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/accounts/ManageAccountsAdapter.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/accounts/ManageAccountsAdapter.kt index a20e971b3..9614d8567 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/accounts/ManageAccountsAdapter.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/accounts/ManageAccountsAdapter.kt @@ -44,6 +44,11 @@ import eu.opencloud.android.presentation.avatar.AvatarUtils import eu.opencloud.android.utils.DisplayUtils import eu.opencloud.android.utils.PreferenceUtils import timber.log.Timber +import androidx.lifecycle.findViewTreeLifecycleOwner +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class ManageAccountsAdapter( private val accountListener: AccountAdapterListener, @@ -102,12 +107,18 @@ class ManageAccountsAdapter( try { val avatarUtils = AvatarUtils() - avatarUtils.loadAvatarForAccount( - holder.binding.icon, - account, - true, - accountAvatarRadiusDimension - ) + holder.itemView.findViewTreeLifecycleOwner()?.lifecycleScope?.launch(Dispatchers.IO) { + val loader = eu.opencloud.android.presentation.thumbnails.ThumbnailsRequester.getCoilImageLoader(account) + withContext(Dispatchers.Main) { + avatarUtils.loadAvatarForAccount( + holder.binding.icon, + account, + true, + accountAvatarRadiusDimension, + loader + ) + } + } } catch (e: java.lang.Exception) { Timber.e(e, "Error calculating RGB value for account list item.") // use user icon as a fallback diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/avatar/AvatarManager.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/avatar/AvatarManager.kt deleted file mode 100644 index 5ed928e38..000000000 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/avatar/AvatarManager.kt +++ /dev/null @@ -1,146 +0,0 @@ -/** - * openCloud Android client application - * - * @author Abel GarcĂ­a de Prada - * Copyright (C) 2020 ownCloud GmbH. - *

- * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License version 2, - * as published by the Free Software Foundation. - *

- * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - *

- * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package eu.opencloud.android.presentation.avatar - -import android.accounts.Account -import android.graphics.BitmapFactory -import android.graphics.drawable.Drawable -import android.media.ThumbnailUtils -import eu.opencloud.android.MainApp.Companion.appContext -import eu.opencloud.android.R -import eu.opencloud.android.datamodel.ThumbnailsCacheManager -import eu.opencloud.android.domain.UseCaseResult -import eu.opencloud.android.domain.capabilities.usecases.GetStoredCapabilitiesUseCase -import eu.opencloud.android.domain.exceptions.FileNotFoundException -import eu.opencloud.android.domain.user.model.UserAvatar -import eu.opencloud.android.domain.user.usecases.GetUserAvatarAsyncUseCase -import eu.opencloud.android.ui.DefaultAvatarTextDrawable -import eu.opencloud.android.utils.BitmapUtils -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -import org.koin.core.error.InstanceCreationException -import timber.log.Timber -import kotlin.math.roundToInt - -/** - * The avatar is loaded if available in the cache and bound to the received UI element. The avatar is not - * fetched from the server if not available, unless the parameter 'fetchFromServer' is set to 'true'. - * - * If there is no avatar stored and cannot be fetched, a colored icon is generated with the first - * letter of the account username. - * - * If this is not possible either, a predefined user icon is bound instead. - */ -class AvatarManager : KoinComponent { - - fun getAvatarForAccount( - account: Account, - fetchIfNotCached: Boolean, - displayRadius: Float - ): Drawable? { - val imageKey = getImageKeyForAccount(account) - - // Check disk cache in background thread - val avatarBitmap = ThumbnailsCacheManager.getBitmapFromDiskCache(imageKey) - avatarBitmap?.let { - Timber.i("Avatar retrieved from cache with imageKey: $imageKey") - return BitmapUtils.bitmapToCircularBitmapDrawable(appContext.resources, it) - } - - val shouldFetchAvatar = try { - val getStoredCapabilitiesUseCase: GetStoredCapabilitiesUseCase by inject() - val storedCapabilities = getStoredCapabilitiesUseCase(GetStoredCapabilitiesUseCase.Params(account.name)) - storedCapabilities?.isFetchingAvatarAllowed() ?: true - } catch (instanceCreationException: InstanceCreationException) { - Timber.e(instanceCreationException, "Koin may not be initialized at this point") - true - } - - // Avatar not found in disk cache, fetch from server. - if (fetchIfNotCached && shouldFetchAvatar) { - Timber.i("Avatar with imageKey $imageKey is not available in cache. Fetching from server...") - val getUserAvatarAsyncUseCase: GetUserAvatarAsyncUseCase by inject() - val useCaseResult = - getUserAvatarAsyncUseCase(GetUserAvatarAsyncUseCase.Params(accountName = account.name)) - handleAvatarUseCaseResult(account, useCaseResult)?.let { return it } - } - - // generate placeholder from user name - try { - Timber.i("Avatar with imageKey $imageKey is not available in cache. Generating one...") - return DefaultAvatarTextDrawable.createAvatar(account.name, displayRadius) - - } catch (e: Exception) { - // nothing to do, return null to apply default icon - Timber.e(e, "Error calculating RGB value for active account icon.") - } - return null - } - - /** - * Converts size of file icon from dp to pixel - * - * @return int - */ - private fun getAvatarDimension(): Int = appContext.resources.getDimension(R.dimen.file_avatar_size).roundToInt() - - private fun getImageKeyForAccount(account: Account) = "a_${account.name}" - - /** - * If [GetUserAvatarAsyncUseCase] is success, add avatar to cache and return a circular drawable. - * If there is no avatar available in server, remove it from cache. - */ - fun handleAvatarUseCaseResult( - account: Account, - useCaseResult: UseCaseResult - ): Drawable? { - Timber.d("Fetch avatar use case is success: ${useCaseResult.isSuccess}") - val imageKey = getImageKeyForAccount(account) - - if (useCaseResult.isSuccess) { - val userAvatar = useCaseResult.getDataOrNull() - userAvatar?.let { - try { - var bitmap = BitmapFactory.decodeByteArray(it.avatarData, 0, it.avatarData.size) - bitmap = ThumbnailUtils.extractThumbnail(bitmap, getAvatarDimension(), getAvatarDimension()) - // Add avatar to cache - bitmap?.let { - ThumbnailsCacheManager.addBitmapToCache(imageKey, bitmap) - Timber.d("User avatar saved into cache -> %s", imageKey) - return BitmapUtils.bitmapToCircularBitmapDrawable(appContext.resources, bitmap) - } - } catch (t: OutOfMemoryError) { - // the app should never break due to a problem with avatars - Timber.e(t, "Generation of avatar for $imageKey failed") - System.gc() - null - } catch (t: Throwable) { - Timber.e(t, "Generation of avatar for $imageKey failed") - null - } - } - - } else if (useCaseResult.getThrowableOrNull() is FileNotFoundException) { - Timber.i("No avatar available, removing cached copy") - ThumbnailsCacheManager.removeBitmapFromCache(imageKey) - } - return null - } -} diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/avatar/AvatarUtils.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/avatar/AvatarUtils.kt index 47ca63760..8be550591 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/avatar/AvatarUtils.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/avatar/AvatarUtils.kt @@ -23,17 +23,13 @@ import android.accounts.Account import android.view.MenuItem import android.widget.ImageView import eu.opencloud.android.R -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import coil.load +import eu.opencloud.android.MainApp.Companion.appContext +import eu.opencloud.android.presentation.thumbnails.ThumbnailsRequester import org.koin.core.component.KoinComponent -import org.koin.core.component.inject class AvatarUtils : KoinComponent { - private val avatarManager: AvatarManager by inject() - /** * Show the avatar corresponding to the received account in an {@ImageView}. *

@@ -54,22 +50,15 @@ class AvatarUtils : KoinComponent { imageView: ImageView, account: Account, @Suppress("UnusedParameter") fetchIfNotCached: Boolean = false, - @Suppress("UnusedParameter") displayRadius: Float + @Suppress("UnusedParameter") displayRadius: Float, + imageLoader: coil.ImageLoader? = null ) { - // Tech debt: Move this to a viewModel and use its viewModelScope instead - CoroutineScope(Dispatchers.IO).launch { - val drawable = avatarManager.getAvatarForAccount( - account = account, - fetchIfNotCached = fetchIfNotCached, - displayRadius = displayRadius - ) - withContext(Dispatchers.Main) { - if (drawable != null) { - imageView.setImageDrawable(drawable) - } else { - imageView.setImageResource(R.drawable.ic_account_circle) - } - } + val uri = ThumbnailsRequester.getAvatarUri(account) + val loader = imageLoader ?: ThumbnailsRequester.getCoilImageLoader(account) + imageView.load(uri, loader) { + placeholder(R.drawable.ic_account_circle) + error(R.drawable.ic_account_circle) + transformations(coil.transform.CircleCropTransformation()) } } @@ -79,19 +68,17 @@ class AvatarUtils : KoinComponent { @Suppress("UnusedParameter") fetchIfNotCached: Boolean = false, @Suppress("UnusedParameter") displayRadius: Float ) { - CoroutineScope(Dispatchers.IO).launch { - val drawable = avatarManager.getAvatarForAccount( - account = account, - fetchIfNotCached = fetchIfNotCached, - displayRadius = displayRadius + val uri = ThumbnailsRequester.getAvatarUri(account) + val imageLoader = ThumbnailsRequester.getCoilImageLoader(account) + val request = coil.request.ImageRequest.Builder(appContext) + .data(uri) + .target( + onStart = { menuItem.setIcon(R.drawable.ic_account_circle) }, + onSuccess = { result -> menuItem.icon = result }, + onError = { menuItem.setIcon(R.drawable.ic_account_circle) } ) - withContext(Dispatchers.Main) { - if (drawable != null) { - menuItem.icon = drawable - } else { - menuItem.setIcon(R.drawable.ic_account_circle) - } - } - } + .transformations(coil.transform.CircleCropTransformation()) + .build() + imageLoader.enqueue(request) } } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/details/FileDetailsFragment.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/details/FileDetailsFragment.kt index 6aaed24c1..fb7977694 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/details/FileDetailsFragment.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/details/FileDetailsFragment.kt @@ -40,8 +40,10 @@ import androidx.work.WorkInfo import com.google.android.material.snackbar.Snackbar import eu.opencloud.android.MainApp import eu.opencloud.android.R +import coil.load import eu.opencloud.android.databinding.FileDetailsFragmentBinding -import eu.opencloud.android.datamodel.ThumbnailsCacheManager + +import eu.opencloud.android.presentation.thumbnails.ThumbnailsRequester import eu.opencloud.android.domain.exceptions.AccountNotFoundException import eu.opencloud.android.domain.exceptions.InstanceNotConfiguredException import eu.opencloud.android.domain.exceptions.TooEarlyException @@ -428,25 +430,10 @@ class FileDetailsFragment : FileFragment() { } } if (ocFile.isImage) { - val tagId = ocFile.remoteId.toString() - var thumbnail: Bitmap? = ThumbnailsCacheManager.getBitmapFromDiskCache(tagId) - if (thumbnail != null && !ocFile.needsToUpdateThumbnail) { - imageView.setImageBitmap(thumbnail) - } else { - // generate new Thumbnail - if (ThumbnailsCacheManager.cancelPotentialThumbnailWork(ocFile, imageView)) { - val task = ThumbnailsCacheManager.ThumbnailGenerationTask(imageView, fileDetailsViewModel.getAccount()) - if (thumbnail == null) { - thumbnail = ThumbnailsCacheManager.mDefaultImg - } - val asyncDrawable = ThumbnailsCacheManager.AsyncThumbnailDrawable( - MainApp.appContext.resources, - thumbnail, - task - ) - imageView.setImageDrawable(asyncDrawable) - task.execute(ocFile) - } + imageView.load(ThumbnailsRequester.getPreviewUriForFile(OCFileWithSyncInfo(ocFile, null), fileDetailsViewModel.getAccount()), ThumbnailsRequester.getCoilImageLoader(fileDetailsViewModel.getAccount())) { + placeholder(MimetypeIconUtil.getFileTypeIconId(ocFile.mimeType, ocFile.fileName)) + error(MimetypeIconUtil.getFileTypeIconId(ocFile.mimeType, ocFile.fileName)) + crossfade(true) } } else { // Name of the file, to deduce the icon to use in case the MIME type is not precise enough diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/FileListAdapter.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/FileListAdapter.kt index 911bae6f6..969d6bda1 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/FileListAdapter.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/FileListAdapter.kt @@ -41,7 +41,9 @@ import eu.opencloud.android.R import eu.opencloud.android.databinding.GridItemBinding import eu.opencloud.android.databinding.ItemFileListBinding import eu.opencloud.android.databinding.ListFooterBinding -import eu.opencloud.android.datamodel.ThumbnailsCacheManager +import coil.load +import coil.dispose +import eu.opencloud.android.presentation.thumbnails.ThumbnailsRequester import eu.opencloud.android.domain.files.model.FileListOption import eu.opencloud.android.domain.files.model.OCFileWithSyncInfo import eu.opencloud.android.domain.files.model.OCFooterFile @@ -60,13 +62,19 @@ class FileListAdapter( var files = mutableListOf() private var account: Account? = AccountUtils.getCurrentOpenCloudAccount(context) private var fileListOption: FileListOption = FileListOption.ALL_FILES + private val disallowTouchesWithOtherWindows = + PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(context) + + init { + setHasStableIds(true) + } fun updateFileList(filesToAdd: List, fileListOption: FileListOption) { val listWithFooter = mutableListOf() listWithFooter.addAll(filesToAdd) - if (listWithFooter.isNotEmpty()) { + if (listWithFooter.isNotEmpty() && !isPickerMode) { listWithFooter.add(OCFooterFile(manageListOfFilesAndGenerateText(filesToAdd))) } @@ -85,13 +93,22 @@ class FileListAdapter( diffResult.dispatchUpdatesTo(this) } + override fun getItemId(position: Int): Long { + val item = files.getOrNull(position) + return when (item) { + is OCFileWithSyncInfo -> item.file.id ?: item.file.remotePath.hashCode().toLong() + is OCFooterFile -> Long.MIN_VALUE + position + else -> position.toLong() + } + } + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder = when (viewType) { ViewType.LIST_ITEM.ordinal -> { val binding = ItemFileListBinding.inflate(LayoutInflater.from(parent.context), parent, false) binding.root.apply { tag = ViewType.LIST_ITEM - filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(context) + filterTouchesWhenObscured = disallowTouchesWithOtherWindows } ListViewHolder(binding) } @@ -100,7 +117,7 @@ class FileListAdapter( val binding = GridItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) binding.root.apply { tag = ViewType.GRID_IMAGE - filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(context) + filterTouchesWhenObscured = disallowTouchesWithOtherWindows } GridImageViewHolder(binding) } @@ -109,7 +126,7 @@ class FileListAdapter( val binding = GridItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) binding.root.apply { tag = ViewType.GRID_ITEM - filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(context) + filterTouchesWhenObscured = disallowTouchesWithOtherWindows } GridViewHolder(binding) } @@ -118,7 +135,7 @@ class FileListAdapter( val binding = ListFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false) binding.root.apply { tag = ViewType.FOOTER - filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(context) + filterTouchesWhenObscured = disallowTouchesWithOtherWindows } FooterViewHolder(binding) } @@ -126,9 +143,11 @@ class FileListAdapter( override fun getItemCount(): Int = files.size - override fun getItemId(position: Int): Long = position.toLong() + private fun hasFooter(): Boolean = files.lastOrNull() is OCFooterFile - private fun isFooter(position: Int) = position == files.size.minus(1) + private fun isFooter(position: Int) = files.getOrNull(position) is OCFooterFile + + private fun selectableItemCount(): Int = files.size - if (hasFooter()) 1 else 0 override fun getItemViewType(position: Int): Int = @@ -166,33 +185,43 @@ class FileListAdapter( fun selectAll() { // Last item on list is the footer, so that element must be excluded from selection - selectAll(totalItems = files.size - 1) + selectAll(totalItems = selectableItemCount()) } fun selectInverse() { // Last item on list is the footer, so that element must be excluded from selection - toggleSelectionInBulk(totalItems = files.size - 1) + toggleSelectionInBulk(totalItems = selectableItemCount()) } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { val viewType = getItemViewType(position) + AccountUtils.getCurrentOpenCloudAccount(context)?.let { currentAccount -> + if (currentAccount != account) { + account = currentAccount + } + } ?: run { + if (account != null) { + account = null + } + } + if (viewType != ViewType.FOOTER.ordinal) { // Is Item + val hasActiveSelection = selectedItemCount > 0 val fileWithSyncInfo = files[position] as OCFileWithSyncInfo val file = fileWithSyncInfo.file val name = file.fileName val fileIcon = holder.itemView.findViewById(R.id.thumbnail).apply { tag = file.id } - val thumbnail: Bitmap? = file.remoteId?.let { ThumbnailsCacheManager.getBitmapFromDiskCache(file.remoteId) } holder.itemView.findViewById(R.id.ListItemLayout)?.apply { contentDescription = "LinearLayout-$name" // Allow or disallow touches with other visible windows - filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(context) + filterTouchesWhenObscured = disallowTouchesWithOtherWindows } holder.itemView.findViewById(R.id.share_icons_layout).isVisible = @@ -201,26 +230,35 @@ class FileListAdapter( holder.itemView.findViewById(R.id.shared_via_users_icon).isVisible = file.sharedWithSharee == true || file.isSharedWithMe - setSpecificViewHolder(viewType, holder, fileWithSyncInfo, thumbnail) + setSpecificViewHolder(viewType, holder, fileWithSyncInfo, hasActiveSelection) setIconPinAccordingToFilesLocalState(holder.itemView.findViewById(R.id.localFileIndicator), fileWithSyncInfo) holder.itemView.setOnClickListener { + val adapterPosition = holder.bindingAdapterPosition + if (adapterPosition == RecyclerView.NO_POSITION) { + return@setOnClickListener + } + val currentItem = files.getOrNull(adapterPosition) as? OCFileWithSyncInfo ?: return@setOnClickListener listener.onItemClick( - ocFileWithSyncInfo = fileWithSyncInfo, - position = position + ocFileWithSyncInfo = currentItem, + position = adapterPosition ) } holder.itemView.setOnLongClickListener { + val adapterPosition = holder.bindingAdapterPosition + if (adapterPosition == RecyclerView.NO_POSITION) { + return@setOnLongClickListener false + } listener.onLongItemClick( - position = position + position = adapterPosition ) } holder.itemView.setBackgroundColor(Color.WHITE) val checkBoxV = holder.itemView.findViewById(R.id.custom_checkbox).apply { - isVisible = getCheckedItems().isNotEmpty() + isVisible = hasActiveSelection } if (isSelected(position)) { @@ -233,28 +271,29 @@ class FileListAdapter( if (file.isFolder) { // Folder + fileIcon.dispose() fileIcon.setImageResource(R.drawable.ic_menu_archive) + fileIcon.setBackgroundColor(Color.TRANSPARENT) } else { // Set file icon depending on its mimetype. Ask for thumbnail later. fileIcon.setImageResource(MimetypeIconUtil.getFileTypeIconId(file.mimeType, file.fileName)) - if (thumbnail != null) { - fileIcon.setImageBitmap(thumbnail) - } - if (file.needsToUpdateThumbnail && ThumbnailsCacheManager.cancelPotentialThumbnailWork(file, fileIcon)) { - // generate new Thumbnail - val task = ThumbnailsCacheManager.ThumbnailGenerationTask(fileIcon, account) - val asyncDrawable = ThumbnailsCacheManager.AsyncThumbnailDrawable(context.resources, thumbnail, task) - - // If drawable is not visible, do not update it. - if (asyncDrawable.minimumHeight > 0 && asyncDrawable.minimumWidth > 0) { - fileIcon.setImageDrawable(asyncDrawable) + if (file.isImage) { + account?.let { acc -> + fileIcon.load(ThumbnailsRequester.getPreviewUriForFile(fileWithSyncInfo, acc), ThumbnailsRequester.getCoilImageLoader(acc)) { + placeholder(MimetypeIconUtil.getFileTypeIconId(file.mimeType, file.fileName)) + error(MimetypeIconUtil.getFileTypeIconId(file.mimeType, file.fileName)) + crossfade(true) + } } - task.execute(file) + } else { + fileIcon.dispose() } - if (file.mimeType == "image/png") { + if (file.mimeType.equals("image/png", ignoreCase = true)) { fileIcon.setBackgroundColor(ContextCompat.getColor(context, R.color.background_color)) + } else { + fileIcon.setBackgroundColor(Color.TRANSPARENT) } } @@ -270,18 +309,23 @@ class FileListAdapter( } } - private fun setSpecificViewHolder(viewType: Int, holder: RecyclerView.ViewHolder, fileWithSyncInfo: OCFileWithSyncInfo, thumbnail: Bitmap?) { + private fun setSpecificViewHolder( + viewType: Int, + holder: RecyclerView.ViewHolder, + fileWithSyncInfo: OCFileWithSyncInfo, + hasActiveSelection: Boolean, + ) { val file = fileWithSyncInfo.file when (viewType) { ViewType.LIST_ITEM.ordinal -> { val view = holder as ListViewHolder view.binding.let { - it.fileListConstraintLayout.filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(context) + it.fileListConstraintLayout.filterTouchesWhenObscured = disallowTouchesWithOtherWindows it.Filename.text = file.fileName it.fileListSize.text = DisplayUtils.bytesToHumanReadable(file.length, context, true) it.fileListLastMod.text = DisplayUtils.getRelativeTimestamp(context, file.modificationTimestamp) - it.threeDotMenu.isVisible = getCheckedItems().isEmpty() + it.threeDotMenu.isVisible = !hasActiveSelection it.threeDotMenu.contentDescription = context.getString(R.string.content_description_file_operations, file.fileName) if (fileListOption.isAvailableOffline() || (fileListOption.isSharedByLink() && fileWithSyncInfo.space == null)) { it.spacePathLine.path.apply { @@ -320,23 +364,16 @@ class FileListAdapter( val fileIcon = holder.itemView.findViewById(R.id.thumbnail) val layoutParams = fileIcon.layoutParams as ViewGroup.MarginLayoutParams - if (thumbnail == null) { - view.binding.Filename.text = file.fileName - // Reset layout params values default - manageGridLayoutParams( - layoutParams = layoutParams, - marginVertical = 0, - height = context.resources.getDimensionPixelSize(R.dimen.item_file_grid_height), - width = context.resources.getDimensionPixelSize(R.dimen.item_file_grid_width), - ) - } else { - manageGridLayoutParams( - layoutParams = layoutParams, - marginVertical = context.resources.getDimensionPixelSize(R.dimen.item_file_image_grid_margin), - height = ViewGroup.LayoutParams.MATCH_PARENT, - width = ViewGroup.LayoutParams.MATCH_PARENT, - ) + view.binding.Filename.apply { + text = "" + isVisible = false } + manageGridLayoutParams( + layoutParams = layoutParams, + marginVertical = context.resources.getDimensionPixelSize(R.dimen.item_file_image_grid_margin), + height = ViewGroup.LayoutParams.MATCH_PARENT, + width = ViewGroup.LayoutParams.MATCH_PARENT, + ) } } } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/MainFileListFragment.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/MainFileListFragment.kt index e82ce5319..760c2ab51 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/MainFileListFragment.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/MainFileListFragment.kt @@ -66,7 +66,7 @@ import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputLayout import eu.opencloud.android.R import eu.opencloud.android.databinding.MainFileListFragmentBinding -import eu.opencloud.android.datamodel.ThumbnailsCacheManager + import eu.opencloud.android.domain.appregistry.model.AppRegistryMimeType import eu.opencloud.android.domain.exceptions.InstanceNotConfiguredException import eu.opencloud.android.domain.exceptions.TooEarlyException @@ -607,50 +607,6 @@ class MainFileListFragment : Fragment(), } val thumbnailBottomSheet = fileOptionsBottomSheetSingleFile.findViewById(R.id.thumbnail_bottom_sheet) - if (file.isFolder) { - // Folder - thumbnailBottomSheet.setImageResource(R.drawable.ic_menu_archive) - } else { - // Set file icon depending on its mimetype. Ask for thumbnail later. - thumbnailBottomSheet.setImageResource( - MimetypeIconUtil.getFileTypeIconId(file.mimeType, file.fileName) - ) - if (file.remoteId != null) { - val thumbnail = ThumbnailsCacheManager.getBitmapFromDiskCache(file.remoteId) - if (thumbnail != null) { - thumbnailBottomSheet.setImageBitmap(thumbnail) - } - if (file.needsToUpdateThumbnail && - ThumbnailsCacheManager.cancelPotentialThumbnailWork(file, thumbnailBottomSheet) - ) { - // generate new Thumbnail - val task = ThumbnailsCacheManager.ThumbnailGenerationTask( - thumbnailBottomSheet, - AccountUtils.getCurrentOpenCloudAccount(requireContext()) - ) - val asyncDrawable = ThumbnailsCacheManager.AsyncThumbnailDrawable( - resources, - thumbnail, - task - ) - - // If drawable is not visible, do not update it. - if (asyncDrawable.minimumHeight > 0 && asyncDrawable.minimumWidth > 0) { - thumbnailBottomSheet.setImageDrawable(asyncDrawable) - } - task.execute(file) - } - - if (file.mimeType == "image/png") { - thumbnailBottomSheet.setBackgroundColor( - ContextCompat.getColor(requireContext(), R.color.background_color) - ) - } - } - } - - val fileNameBottomSheet = fileOptionsBottomSheetSingleFile.findViewById(R.id.file_name_bottom_sheet) - fileNameBottomSheet.text = file.fileName val fileSizeBottomSheet = fileOptionsBottomSheetSingleFile.findViewById(R.id.file_size_bottom_sheet) fileSizeBottomSheet.text = DisplayUtils.bytesToHumanReadable(file.length, requireContext(), true) @@ -836,9 +792,10 @@ class MainFileListFragment : Fragment(), val spaceSpecialImage = fileListUiState.space?.getSpaceSpecialImage() if (spaceSpecialImage != null) { + val account = AccountUtils.getCurrentOpenCloudAccount(requireContext()) binding.spaceHeader.spaceHeaderImage.load( ThumbnailsRequester.getPreviewUriForSpaceSpecial(spaceSpecialImage), - ThumbnailsRequester.getCoilImageLoader() + if (account != null) ThumbnailsRequester.getCoilImageLoader(account) else ThumbnailsRequester.getCoilImageLoader() ) { placeholder(R.drawable.ic_spaces) error(R.drawable.ic_spaces) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/removefile/RemoveFilesDialogFragment.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/removefile/RemoveFilesDialogFragment.kt index af3baf9dd..a1008dc0a 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/removefile/RemoveFilesDialogFragment.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/removefile/RemoveFilesDialogFragment.kt @@ -30,7 +30,9 @@ import android.widget.ImageView import androidx.fragment.app.DialogFragment import eu.opencloud.android.R import eu.opencloud.android.databinding.RemoveFilesDialogBinding -import eu.opencloud.android.datamodel.ThumbnailsCacheManager +import coil.load +import eu.opencloud.android.presentation.thumbnails.ThumbnailsRequester +import eu.opencloud.android.presentation.authentication.AccountUtils import eu.opencloud.android.domain.files.model.OCFile import eu.opencloud.android.presentation.files.operations.FileOperation import eu.opencloud.android.presentation.files.operations.FileOperationsViewModel @@ -121,13 +123,11 @@ class RemoveFilesDialogFragment : DialogFragment() { if (files.size == 1) { val file = files[0] // Show the thumbnail when the file has one - val thumbnail = ThumbnailsCacheManager.getBitmapFromDiskCache(file.remoteId) - if (thumbnail != null) { - thumbnailImageView.setImageBitmap(thumbnail) - } else { - thumbnailImageView.setImageResource( - MimetypeIconUtil.getFileTypeIconId(file.mimeType, file.fileName) - ) + val account = AccountUtils.getCurrentOpenCloudAccount(requireContext()) + thumbnailImageView.load(ThumbnailsRequester.getPreviewUriForFile(file, account), ThumbnailsRequester.getCoilImageLoader(account)) { + placeholder(MimetypeIconUtil.getFileTypeIconId(file.mimeType, file.fileName)) + error(MimetypeIconUtil.getFileTypeIconId(file.mimeType, file.fileName)) + crossfade(true) } } else { thumbnailImageView.visibility = View.GONE diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/sharing/ShareFileFragment.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/sharing/ShareFileFragment.kt index 0163f24bc..32da6bf50 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/sharing/ShareFileFragment.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/sharing/ShareFileFragment.kt @@ -37,7 +37,8 @@ import androidx.core.view.isVisible import androidx.fragment.app.Fragment import eu.opencloud.android.R import eu.opencloud.android.databinding.ShareFileLayoutBinding -import eu.opencloud.android.datamodel.ThumbnailsCacheManager +import coil.load +import eu.opencloud.android.presentation.thumbnails.ThumbnailsRequester import eu.opencloud.android.domain.capabilities.model.CapabilityBooleanType import eu.opencloud.android.domain.capabilities.model.OCCapability import eu.opencloud.android.domain.files.model.OCFile @@ -239,10 +240,10 @@ class ShareFileFragment : Fragment(), ShareUserListAdapter.ShareUserAdapterListe ) ) if (file!!.isImage) { - val remoteId = file?.remoteId.toString() - val thumbnail = ThumbnailsCacheManager.getBitmapFromDiskCache(remoteId) - if (thumbnail != null) { - binding.shareFileIcon.setImageBitmap(thumbnail) + binding.shareFileIcon.load(ThumbnailsRequester.getPreviewUriForFile(file!!, account!!), ThumbnailsRequester.getCoilImageLoader(account!!)) { + placeholder(MimetypeIconUtil.getFileTypeIconId(file!!.mimeType, file!!.fileName)) + error(MimetypeIconUtil.getFileTypeIconId(file!!.mimeType, file!!.fileName)) + crossfade(true) } } // Name diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/spaces/SpacesListAdapter.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/spaces/SpacesListAdapter.kt index 94ce04488..c3076245a 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/spaces/SpacesListAdapter.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/spaces/SpacesListAdapter.kt @@ -73,9 +73,10 @@ class SpacesListAdapter( val spaceSpecialImage = space.getSpaceSpecialImage() if (spaceSpecialImage != null) { + val account = eu.opencloud.android.presentation.authentication.AccountUtils.getCurrentOpenCloudAccount(holder.itemView.context) spacesListItemImage.load( ThumbnailsRequester.getPreviewUriForSpaceSpecial(spaceSpecialImage), - ThumbnailsRequester.getCoilImageLoader() + if (account != null) ThumbnailsRequester.getCoilImageLoader(account) else ThumbnailsRequester.getCoilImageLoader() ) { placeholder(R.drawable.ic_spaces_placeholder) error(R.drawable.ic_spaces_placeholder) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/thumbnails/ThumbnailsRequester.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/thumbnails/ThumbnailsRequester.kt index 124786849..176af864f 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/thumbnails/ThumbnailsRequester.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/thumbnails/ThumbnailsRequester.kt @@ -21,6 +21,7 @@ package eu.opencloud.android.presentation.thumbnails import android.accounts.Account +import android.accounts.AccountManager import android.net.Uri import coil.ImageLoader import coil.disk.DiskCache @@ -29,6 +30,8 @@ import coil.util.DebugLogger import eu.opencloud.android.MainApp.Companion.appContext import eu.opencloud.android.R import eu.opencloud.android.data.ClientManager +import java.util.concurrent.ConcurrentHashMap +import eu.opencloud.android.domain.files.model.OCFile import eu.opencloud.android.domain.files.model.OCFileWithSyncInfo import eu.opencloud.android.domain.spaces.model.SpaceSpecial import eu.opencloud.android.lib.common.SingleSessionManager @@ -52,85 +55,109 @@ object ThumbnailsRequester : KoinComponent { private val clientManager: ClientManager by inject() private const val SPACE_SPECIAL_PREVIEW_URI = "%s?scalingup=0&a=1&x=%d&y=%d&c=%s&preview=1" - private const val FILE_PREVIEW_URI = "%s%s?x=%d&y=%d&c=%s&preview=1&id=%s" + private const val FILE_PREVIEW_URI = "%s/remote.php/webdav%s?x=%d&y=%d&c=%s&preview=1" - private const val DISK_CACHE_SIZE: Long = 1024 * 1024 * 10 // 10MB + private const val DISK_CACHE_SIZE: Long = 1024 * 1024 * 100 // 100MB - fun getCoilImageLoader(): ImageLoader { - val openCloudClient = getOpenCloudClient() + private val imageLoaders = ConcurrentHashMap() + private var sharedDiskCache: DiskCache? = null + private var sharedMemoryCache: MemoryCache? = null - val coilRequestHeaderInterceptor = CoilRequestHeaderInterceptor( - requestHeaders = hashMapOf( - AUTHORIZATION_HEADER to openCloudClient.credentials.headerAuth, - ACCEPT_ENCODING_HEADER to ACCEPT_ENCODING_IDENTITY, - USER_AGENT_HEADER to SingleSessionManager.getUserAgent(), - OC_X_REQUEST_ID to RandomUtils.generateRandomUUID(), - ) - ) - - return ImageLoader(appContext).newBuilder().okHttpClient( - okHttpClient = openCloudClient.okHttpClient.newBuilder().addNetworkInterceptor(coilRequestHeaderInterceptor).build() - ).logger(DebugLogger()) - .memoryCache { - MemoryCache.Builder(appContext) - .maxSizePercent(0.1) - .build() - } - .diskCache { - DiskCache.Builder() - .directory(appContext.cacheDir.resolve("thumbnails_coil_cache")) - .maxSizeBytes(DISK_CACHE_SIZE) - .build() - } - .build() + private fun getSharedDiskCache(): DiskCache { + if (sharedDiskCache == null) { + sharedDiskCache = DiskCache.Builder() + .directory(appContext.cacheDir.resolve("thumbnails_coil_cache")) + .maxSizeBytes(DISK_CACHE_SIZE) + .build() + } + return sharedDiskCache!! } - fun getPreviewUriForSpaceSpecial(spaceSpecial: SpaceSpecial): String = - String.format( - Locale.ROOT, - SPACE_SPECIAL_PREVIEW_URI, - spaceSpecial.webDavUrl, - appContext.resources.getDimension(R.dimen.spaces_thumbnail_height).roundToInt(), - appContext.resources.getDimension(R.dimen.spaces_thumbnail_height).roundToInt(), - spaceSpecial.eTag - ) - - @Suppress("ExpressionBodySyntax") - fun getPreviewUriForFile(ocFile: OCFileWithSyncInfo, account: Account): String { - var baseUrl = getOpenCloudClient().baseUri.toString() + "/remote.php/dav/files/" + account.name.split("@".toRegex()) - .dropLastWhile { it.isEmpty() } - .toTypedArray()[0] - ocFile.space?.getSpaceSpecialImage()?.let { - baseUrl = it.webDavUrl + private fun getSharedMemoryCache(): MemoryCache { + if (sharedMemoryCache == null) { + sharedMemoryCache = MemoryCache.Builder(appContext) + .maxSizePercent(0.25) + .build() } + return sharedMemoryCache!! + } + + fun getAvatarUri(account: Account): String { + val accountManager = AccountManager.get(appContext) + val baseUrl = accountManager.getUserData(account, eu.opencloud.android.lib.common.accounts.AccountUtils.Constants.KEY_OC_BASE_URL) + val username = AccountUtils.getUsernameOfAccount(account.name) + return "$baseUrl/index.php/avatar/${android.net.Uri.encode(username)}/384" + } + + fun getPreviewUriForFile(file: OCFile, account: Account, etag: String? = null): String { + return getPreviewUri(file.remotePath, etag ?: file.etag, account) + } + + fun getPreviewUriForFile(fileWithSyncInfo: OCFileWithSyncInfo, account: Account): String { + return getPreviewUriForFile(fileWithSyncInfo.file, account) + } + + fun getPreviewUriForSpaceSpecial(spaceSpecial: SpaceSpecial): String { + return String.format(Locale.US, SPACE_SPECIAL_PREVIEW_URI, spaceSpecial.webDavUrl, 1024, 1024, spaceSpecial.eTag) + } + + private fun getPreviewUri(remotePath: String?, etag: String?, account: Account): String { + val accountManager = AccountManager.get(appContext) + val baseUrl = accountManager.getUserData(account, eu.opencloud.android.lib.common.accounts.AccountUtils.Constants.KEY_OC_BASE_URL) + + val path = if (remotePath?.startsWith("/") == true) remotePath else "/$remotePath" + val encodedPath = Uri.encode(path, "/") + + return String.format(Locale.US, FILE_PREVIEW_URI, baseUrl, encodedPath, 1024, 1024, etag) + } - // Converts dp to pixel - val fileThumbnailSize = appContext.resources.getDimension(R.dimen.file_icon_size_grid).roundToInt() - return String.format( - Locale.ROOT, - FILE_PREVIEW_URI, - baseUrl, - Uri.encode(ocFile.file.remotePath, "/"), - fileThumbnailSize, - fileThumbnailSize, - ocFile.file.etag, - "${ocFile.file.remoteId}${ocFile.file.modificationTimestamp}", - ) + fun getCoilImageLoader(): ImageLoader { + val account = AccountUtils.getCurrentOpenCloudAccount(appContext) + return getCoilImageLoader(account) } - private fun getOpenCloudClient() = clientManager.getClientForCoilThumbnails( - accountName = AccountUtils.getCurrentOpenCloudAccount(appContext).name - ) + fun getCoilImageLoader(account: Account): ImageLoader { + val accountName = account.name + return imageLoaders.getOrPut(accountName) { + val openCloudClient = clientManager.getClientForCoilThumbnails(accountName) + + val coilRequestHeaderInterceptor = CoilRequestHeaderInterceptor( + clientManager = clientManager, + accountName = accountName + ) + + ImageLoader(appContext).newBuilder().okHttpClient( + okHttpClient = openCloudClient.okHttpClient.newBuilder() + .addNetworkInterceptor(coilRequestHeaderInterceptor).build() + ).logger(DebugLogger()) + .memoryCache { + getSharedMemoryCache() + } + .diskCache { + getSharedDiskCache() + } + .build() + } + } private class CoilRequestHeaderInterceptor( - private val requestHeaders: HashMap + private val clientManager: ClientManager, + private val accountName: String ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { + val openCloudClient = clientManager.getClientForCoilThumbnails(accountName) + val requestHeaders = hashMapOf( + AUTHORIZATION_HEADER to openCloudClient.credentials.headerAuth, + ACCEPT_ENCODING_HEADER to ACCEPT_ENCODING_IDENTITY, + USER_AGENT_HEADER to SingleSessionManager.getUserAgent(), + OC_X_REQUEST_ID to RandomUtils.generateRandomUUID(), + ) + val request = chain.request().newBuilder() requestHeaders.toHeaders().forEach { request.addHeader(it.first, it.second) } return chain.proceed(request.build()).newBuilder().removeHeader("Cache-Control") - .addHeader("Cache-Control", "max-age=5000 , must-revalidate, value").build().also { Timber.d("Header :" + it.headers) } + .addHeader("Cache-Control", "max-age=5000, must-revalidate").build().also { Timber.d("Header :" + it.headers) } } } } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/DrawerActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/DrawerActivity.kt index eb4ccb9ce..8b55bce2f 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/DrawerActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/DrawerActivity.kt @@ -78,6 +78,12 @@ import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import timber.log.Timber +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import eu.opencloud.android.presentation.thumbnails.ThumbnailsRequester + /** * Base class to handle setup of the drawer implementation including avatar fetching and fallback * generation. @@ -451,11 +457,17 @@ abstract class DrawerActivity : ToolbarActivity() { } getDrawerCurrentAccount()?.let { - AvatarUtils().loadAvatarForAccount( - imageView = it, - account = account, - displayRadius = currentAccountAvatarRadiusDimension - ) + lifecycleScope.launch(Dispatchers.IO) { + val imageLoader = ThumbnailsRequester.getCoilImageLoader(account) + withContext(Dispatchers.Main) { + AvatarUtils().loadAvatarForAccount( + imageView = it, + account = account, + displayRadius = currentAccountAvatarRadiusDimension, + imageLoader = imageLoader + ) + } + } drawerViewModel.getUserQuota(account.name) updateQuota() } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/ToolbarActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/ToolbarActivity.kt index a2a7bd000..0728c6611 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/ToolbarActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/ToolbarActivity.kt @@ -42,6 +42,11 @@ import eu.opencloud.android.presentation.accounts.ManageAccountsDialogFragment import eu.opencloud.android.presentation.accounts.ManageAccountsDialogFragment.Companion.MANAGE_ACCOUNTS_DIALOG import eu.opencloud.android.presentation.authentication.AccountUtils import eu.opencloud.android.presentation.avatar.AvatarUtils +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import eu.opencloud.android.presentation.thumbnails.ThumbnailsRequester /** * Base class providing toolbar registration functionality, see [.setupToolbar]. @@ -112,12 +117,19 @@ abstract class ToolbarActivity : BaseActivity() { AccountUtils.getCurrentOpenCloudAccount(baseContext) ?: return if (isAvatarRequested) { - AvatarUtils().loadAvatarForAccount( - avatarView, - AccountUtils.getCurrentOpenCloudAccount(baseContext), - true, - baseContext.resources.getDimension(R.dimen.toolbar_avatar_radius) - ) + lifecycleScope.launch(Dispatchers.IO) { + val account = AccountUtils.getCurrentOpenCloudAccount(baseContext) + val imageLoader = ThumbnailsRequester.getCoilImageLoader(account) + withContext(Dispatchers.Main) { + AvatarUtils().loadAvatarForAccount( + avatarView, + account, + true, + baseContext.resources.getDimension(R.dimen.toolbar_avatar_radius), + imageLoader + ) + } + } } avatarView.setOnClickListener { val dialog = ManageAccountsDialogFragment.newInstance(AccountUtils.getCurrentOpenCloudAccount(applicationContext)) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/ui/adapter/DiskLruImageCache.java b/opencloudApp/src/main/java/eu/opencloud/android/ui/adapter/DiskLruImageCache.java deleted file mode 100644 index a823281d3..000000000 --- a/opencloudApp/src/main/java/eu/opencloud/android/ui/adapter/DiskLruImageCache.java +++ /dev/null @@ -1,147 +0,0 @@ -/* - * openCloud Android client application - * - * @author Christian Schabesberger - * Copyright (C) 2020 ownCloud GmbH. - *

- * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License version 2, - * as published by the Free Software Foundation. - *

- * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - *

- * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package eu.opencloud.android.ui.adapter; - -import java.io.BufferedInputStream; -import java.io.BufferedOutputStream; -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; - -import android.graphics.Bitmap; -import android.graphics.Bitmap.CompressFormat; -import android.graphics.BitmapFactory; - -import com.jakewharton.disklrucache.DiskLruCache; -import timber.log.Timber; - -public class DiskLruImageCache { - - private final DiskLruCache mDiskCache; - private final CompressFormat mCompressFormat; - private final int mCompressQuality; - private static final int CACHE_VERSION = 2; - private static final int VALUE_COUNT = 1; - private static final int IO_BUFFER_SIZE = 8 * 1024; - - //public DiskLruImageCache( Context context,String uniqueName, int diskCacheSize, - public DiskLruImageCache( - File diskCacheDir, int diskCacheSize, CompressFormat compressFormat, int quality - ) throws IOException { - - mDiskCache = DiskLruCache.open( - diskCacheDir, CACHE_VERSION, VALUE_COUNT, diskCacheSize - ); - mCompressFormat = compressFormat; - mCompressQuality = quality; - } - - private boolean writeBitmapToFile(Bitmap bitmap, DiskLruCache.Editor editor) - throws IOException { - OutputStream out = null; - try { - out = new BufferedOutputStream(editor.newOutputStream(0), IO_BUFFER_SIZE); - return bitmap.compress(mCompressFormat, mCompressQuality, out); - } finally { - if (out != null) { - out.close(); - } - } - } - - public void put(String key, Bitmap data) { - - DiskLruCache.Editor editor = null; - String validKey = convertToValidKey(key); - try { - editor = mDiskCache.edit(validKey); - if (editor == null) { - return; - } - - if (writeBitmapToFile(data, editor)) { - mDiskCache.flush(); - editor.commit(); - Timber.d("cache_test_DISK_ image put on disk cache %s", validKey); - } else { - editor.abort(); - Timber.d("cache_test_DISK_ ERROR on: image put on disk cache %s", validKey); - } - } catch (IOException e) { - Timber.w("cache_test_DISK_ ERROR on: image put on disk cache %s", validKey); - try { - if (editor != null) { - editor.abort(); - } - } catch (IOException ignored) { - } - } - } - - public Bitmap getBitmap(String key) { - - Bitmap bitmap = null; - DiskLruCache.Snapshot snapshot = null; - String validKey = convertToValidKey(key); - try { - - snapshot = mDiskCache.get(validKey); - if (snapshot == null) { - return null; - } - final InputStream in = snapshot.getInputStream(0); - if (in != null) { - final BufferedInputStream buffIn = new BufferedInputStream(in, IO_BUFFER_SIZE); - bitmap = BitmapFactory.decodeStream(buffIn); - } - } catch (IOException e) { - Timber.e(e); - } finally { - if (snapshot != null) { - snapshot.close(); - } - } - - Timber.d(bitmap == null ? "not found" : "image read from disk %s", validKey); - - return bitmap; - - } - - private String convertToValidKey(String key) { - return Integer.toString(key.hashCode()); - } - - /** - * Remove passed key from cache - * - * @param key - */ - public void removeKey(String key) { - String validKey = convertToValidKey(key); - try { - mDiskCache.remove(validKey); - Timber.d("removeKey from cache: %s", validKey); - } catch (IOException e) { - Timber.e(e); - } - } -} diff --git a/opencloudApp/src/main/java/eu/opencloud/android/ui/adapter/ReceiveExternalFilesAdapter.java b/opencloudApp/src/main/java/eu/opencloud/android/ui/adapter/ReceiveExternalFilesAdapter.java index ec7f97096..a295f3405 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/ui/adapter/ReceiveExternalFilesAdapter.java +++ b/opencloudApp/src/main/java/eu/opencloud/android/ui/adapter/ReceiveExternalFilesAdapter.java @@ -25,7 +25,7 @@ import android.accounts.Account; import android.content.Context; -import android.graphics.Bitmap; + import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -36,8 +36,8 @@ import eu.opencloud.android.R; import eu.opencloud.android.datamodel.FileDataStorageManager; -import eu.opencloud.android.datamodel.ThumbnailsCacheManager; -import eu.opencloud.android.datamodel.ThumbnailsCacheManager.AsyncThumbnailDrawable; +import eu.opencloud.android.presentation.thumbnails.ThumbnailsRequester; +import coil.ImageLoader; import eu.opencloud.android.db.PreferenceManager; import eu.opencloud.android.domain.files.model.OCFile; import eu.opencloud.android.extensions.VectorExtKt; @@ -147,30 +147,23 @@ public View getView(int position, View convertView, ViewGroup parent) { // get Thumbnail if file is image if (file.isImage() && file.getRemoteId() != null) { - // Thumbnail in Cache? - Bitmap thumbnail = ThumbnailsCacheManager.getBitmapFromDiskCache( - String.valueOf(file.getRemoteId()) - ); - if (thumbnail != null && !file.getNeedsToUpdateThumbnail()) { - fileIcon.setImageBitmap(thumbnail); - } else { - // generate new Thumbnail - if (ThumbnailsCacheManager.cancelPotentialThumbnailWork(file, fileIcon)) { - final ThumbnailsCacheManager.ThumbnailGenerationTask task = - new ThumbnailsCacheManager.ThumbnailGenerationTask(fileIcon, mAccount); - if (thumbnail == null) { - thumbnail = ThumbnailsCacheManager.mDefaultImg; - } - final AsyncThumbnailDrawable asyncDrawable = new AsyncThumbnailDrawable( - mContext.getResources(), - thumbnail, - task - ); - fileIcon.setImageDrawable(asyncDrawable); - task.execute(file); - } - } + String uri = ThumbnailsRequester.INSTANCE.getPreviewUriForFile(file, mAccount, null); + ImageLoader imageLoader = ThumbnailsRequester.INSTANCE.getCoilImageLoader(mAccount); + coil.request.ImageRequest request = new coil.request.ImageRequest.Builder(mContext) + .data(uri) + .target(fileIcon) + .placeholder(MimetypeIconUtil.getFileTypeIconId(file.getMimeType(), file.getFileName())) + .error(MimetypeIconUtil.getFileTypeIconId(file.getMimeType(), file.getFileName())) + .crossfade(true) + .build(); + imageLoader.enqueue(request); } else { + ImageLoader imageLoader = ThumbnailsRequester.INSTANCE.getCoilImageLoader(mAccount); + coil.request.ImageRequest request = new coil.request.ImageRequest.Builder(mContext) + .data(null) + .target(fileIcon) + .build(); + imageLoader.enqueue(request); fileIcon.setImageResource( MimetypeIconUtil.getFileTypeIconId(file.getMimeType(), file.getFileName()) ); diff --git a/opencloudApp/src/main/res/layout/opencloud_toolbar.xml b/opencloudApp/src/main/res/layout/opencloud_toolbar.xml index 886f0a234..29dfa2b96 100644 --- a/opencloudApp/src/main/res/layout/opencloud_toolbar.xml +++ b/opencloudApp/src/main/res/layout/opencloud_toolbar.xml @@ -80,7 +80,6 @@ android:layout_marginHorizontal="@dimen/standard_half_margin" android:padding="@dimen/standard_half_padding" android:src="@drawable/ic_account_circle" - android:tint="@color/primary" android:contentDescription="@string/content_description_manage_accounts" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/ClientManager.kt b/opencloudData/src/main/java/eu/opencloud/android/data/ClientManager.kt index e2356ff11..d9d65efc8 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/ClientManager.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/ClientManager.kt @@ -160,4 +160,6 @@ class ClientManager( val openCloudClient = getClientForAccount(accountName) return OCAppRegistryService(client = openCloudClient) } + + } diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/files/repository/OCFileRepository.kt b/opencloudData/src/main/java/eu/opencloud/android/data/files/repository/OCFileRepository.kt index 20452995b..bc43633e2 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/files/repository/OCFileRepository.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/files/repository/OCFileRepository.kt @@ -347,7 +347,7 @@ class OCFileRepository( it.copy(spaceId = spaceId) } val remoteFolder = fetchFolderResult.first() - val remoteFolderContent = fetchFolderResult.drop(1) + val remoteFolderContent = fetchFolderResult.drop(1).distinctBy { it.remotePath } // Final content for this folder, we will update the folder content all together val folderContentUpdated = mutableListOf() From 5d2368ff6c92986b35828e97a8aa3c05b438a1dd Mon Sep 17 00:00:00 2001 From: zerox80 Date: Sat, 22 Nov 2025 18:12:47 +0100 Subject: [PATCH 2/4] Fix Android avatar loading and cache control --- .../thumbnails/ThumbnailsRequester.kt | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/thumbnails/ThumbnailsRequester.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/thumbnails/ThumbnailsRequester.kt index 176af864f..fd9461b1e 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/thumbnails/ThumbnailsRequester.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/thumbnails/ThumbnailsRequester.kt @@ -156,8 +156,26 @@ object ThumbnailsRequester : KoinComponent { val request = chain.request().newBuilder() requestHeaders.toHeaders().forEach { request.addHeader(it.first, it.second) } - return chain.proceed(request.build()).newBuilder().removeHeader("Cache-Control") - .addHeader("Cache-Control", "max-age=5000, must-revalidate").build().also { Timber.d("Header :" + it.headers) } + val response = chain.proceed(request.build()) + var builder = response.newBuilder() + var changed = false + + val cacheControl = response.header("Cache-Control") + if (cacheControl.isNullOrEmpty() || cacheControl.contains("no-cache")) { + builder.removeHeader("Cache-Control") + builder.addHeader("Cache-Control", "max-age=5000, must-revalidate") + changed = true + } + + if (chain.request().url.toString().contains("/avatar/") && response.header("Content-Type").isNullOrEmpty()) { + builder.addHeader("Content-Type", "image/png") + changed = true + } + + if (changed) { + return builder.build().also { Timber.d("Header :" + it.headers) } + } + return response } } } From 89c25b5db042dfd6e6c04d0ba13504f87b40e1c5 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Wed, 26 Nov 2025 18:30:41 +0100 Subject: [PATCH 3/4] Fix Detekt issues --- .../files/details/FileDetailsFragment.kt | 12 ++++++++--- .../files/filelist/FileListAdapter.kt | 2 +- .../files/filelist/MainFileListFragment.kt | 2 +- .../presentation/sharing/ShareFileFragment.kt | 6 ++++-- .../thumbnails/ThumbnailsRequester.kt | 20 +++++++------------ 5 files changed, 22 insertions(+), 20 deletions(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/details/FileDetailsFragment.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/details/FileDetailsFragment.kt index fb7977694..49e58c90c 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/details/FileDetailsFragment.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/details/FileDetailsFragment.kt @@ -24,7 +24,7 @@ package eu.opencloud.android.presentation.files.details import android.accounts.Account import android.content.Intent -import android.graphics.Bitmap + import android.net.Uri import android.os.Build import android.os.Bundle @@ -38,7 +38,7 @@ import androidx.browser.customtabs.CustomTabsIntent import androidx.core.view.isVisible import androidx.work.WorkInfo import com.google.android.material.snackbar.Snackbar -import eu.opencloud.android.MainApp + import eu.opencloud.android.R import coil.load import eu.opencloud.android.databinding.FileDetailsFragmentBinding @@ -430,7 +430,13 @@ class FileDetailsFragment : FileFragment() { } } if (ocFile.isImage) { - imageView.load(ThumbnailsRequester.getPreviewUriForFile(OCFileWithSyncInfo(ocFile, null), fileDetailsViewModel.getAccount()), ThumbnailsRequester.getCoilImageLoader(fileDetailsViewModel.getAccount())) { + imageView.load( + ThumbnailsRequester.getPreviewUriForFile( + OCFileWithSyncInfo(ocFile, null), + fileDetailsViewModel.getAccount() + ), + ThumbnailsRequester.getCoilImageLoader(fileDetailsViewModel.getAccount()) + ) { placeholder(MimetypeIconUtil.getFileTypeIconId(ocFile.mimeType, ocFile.fileName)) error(MimetypeIconUtil.getFileTypeIconId(ocFile.mimeType, ocFile.fileName)) crossfade(true) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/FileListAdapter.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/FileListAdapter.kt index 969d6bda1..686552d9c 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/FileListAdapter.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/FileListAdapter.kt @@ -25,7 +25,7 @@ package eu.opencloud.android.presentation.files.filelist import android.accounts.Account import android.content.Context -import android.graphics.Bitmap + import android.graphics.Color import android.view.LayoutInflater import android.view.View diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/MainFileListFragment.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/MainFileListFragment.kt index 760c2ab51..7bda750cf 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/MainFileListFragment.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/MainFileListFragment.kt @@ -606,7 +606,7 @@ class MainFileListFragment : Fragment(), dialog.dismiss() } - val thumbnailBottomSheet = fileOptionsBottomSheetSingleFile.findViewById(R.id.thumbnail_bottom_sheet) + val fileSizeBottomSheet = fileOptionsBottomSheetSingleFile.findViewById(R.id.file_size_bottom_sheet) fileSizeBottomSheet.text = DisplayUtils.bytesToHumanReadable(file.length, requireContext(), true) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/sharing/ShareFileFragment.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/sharing/ShareFileFragment.kt index 32da6bf50..5782b58c3 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/sharing/ShareFileFragment.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/sharing/ShareFileFragment.kt @@ -240,13 +240,15 @@ class ShareFileFragment : Fragment(), ShareUserListAdapter.ShareUserAdapterListe ) ) if (file!!.isImage) { - binding.shareFileIcon.load(ThumbnailsRequester.getPreviewUriForFile(file!!, account!!), ThumbnailsRequester.getCoilImageLoader(account!!)) { + binding.shareFileIcon.load( + ThumbnailsRequester.getPreviewUriForFile(file!!, account!!), + ThumbnailsRequester.getCoilImageLoader(account!!) + ) { placeholder(MimetypeIconUtil.getFileTypeIconId(file!!.mimeType, file!!.fileName)) error(MimetypeIconUtil.getFileTypeIconId(file!!.mimeType, file!!.fileName)) crossfade(true) } } - // Name binding.shareFileName.text = file?.fileName // Size diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/thumbnails/ThumbnailsRequester.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/thumbnails/ThumbnailsRequester.kt index fd9461b1e..728f3efde 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/thumbnails/ThumbnailsRequester.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/thumbnails/ThumbnailsRequester.kt @@ -28,7 +28,6 @@ import coil.disk.DiskCache import coil.memory.MemoryCache import coil.util.DebugLogger import eu.opencloud.android.MainApp.Companion.appContext -import eu.opencloud.android.R import eu.opencloud.android.data.ClientManager import java.util.concurrent.ConcurrentHashMap import eu.opencloud.android.domain.files.model.OCFile @@ -49,7 +48,6 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject import timber.log.Timber import java.util.Locale -import kotlin.math.roundToInt object ThumbnailsRequester : KoinComponent { private val clientManager: ClientManager by inject() @@ -89,25 +87,21 @@ object ThumbnailsRequester : KoinComponent { return "$baseUrl/index.php/avatar/${android.net.Uri.encode(username)}/384" } - fun getPreviewUriForFile(file: OCFile, account: Account, etag: String? = null): String { - return getPreviewUri(file.remotePath, etag ?: file.etag, account) - } + fun getPreviewUriForFile(file: OCFile, account: Account, etag: String? = null): String = + getPreviewUri(file.remotePath, etag ?: file.etag, account) - fun getPreviewUriForFile(fileWithSyncInfo: OCFileWithSyncInfo, account: Account): String { - return getPreviewUriForFile(fileWithSyncInfo.file, account) - } + fun getPreviewUriForFile(fileWithSyncInfo: OCFileWithSyncInfo, account: Account): String = + getPreviewUriForFile(fileWithSyncInfo.file, account) - fun getPreviewUriForSpaceSpecial(spaceSpecial: SpaceSpecial): String { - return String.format(Locale.US, SPACE_SPECIAL_PREVIEW_URI, spaceSpecial.webDavUrl, 1024, 1024, spaceSpecial.eTag) - } + fun getPreviewUriForSpaceSpecial(spaceSpecial: SpaceSpecial): String = + String.format(Locale.US, SPACE_SPECIAL_PREVIEW_URI, spaceSpecial.webDavUrl, 1024, 1024, spaceSpecial.eTag) private fun getPreviewUri(remotePath: String?, etag: String?, account: Account): String { val accountManager = AccountManager.get(appContext) val baseUrl = accountManager.getUserData(account, eu.opencloud.android.lib.common.accounts.AccountUtils.Constants.KEY_OC_BASE_URL) - val path = if (remotePath?.startsWith("/") == true) remotePath else "/$remotePath" val encodedPath = Uri.encode(path, "/") - + return String.format(Locale.US, FILE_PREVIEW_URI, baseUrl, encodedPath, 1024, 1024, etag) } From cfe9ae59466522f171a702b4d564a1f73ed1d1d4 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Tue, 2 Dec 2025 17:34:55 +0100 Subject: [PATCH 4/4] remove dead code --- .../android/presentation/accounts/ManageAccountsAdapter.kt | 1 - .../eu/opencloud/android/presentation/avatar/AvatarUtils.kt | 2 -- .../java/eu/opencloud/android/ui/activity/ToolbarActivity.kt | 1 - 3 files changed, 4 deletions(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/accounts/ManageAccountsAdapter.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/accounts/ManageAccountsAdapter.kt index 9614d8567..9c0047c3b 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/accounts/ManageAccountsAdapter.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/accounts/ManageAccountsAdapter.kt @@ -113,7 +113,6 @@ class ManageAccountsAdapter( avatarUtils.loadAvatarForAccount( holder.binding.icon, account, - true, accountAvatarRadiusDimension, loader ) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/avatar/AvatarUtils.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/avatar/AvatarUtils.kt index 8be550591..1c9afd6da 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/avatar/AvatarUtils.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/avatar/AvatarUtils.kt @@ -49,7 +49,6 @@ class AvatarUtils : KoinComponent { fun loadAvatarForAccount( imageView: ImageView, account: Account, - @Suppress("UnusedParameter") fetchIfNotCached: Boolean = false, @Suppress("UnusedParameter") displayRadius: Float, imageLoader: coil.ImageLoader? = null ) { @@ -65,7 +64,6 @@ class AvatarUtils : KoinComponent { fun loadAvatarForAccount( menuItem: MenuItem, account: Account, - @Suppress("UnusedParameter") fetchIfNotCached: Boolean = false, @Suppress("UnusedParameter") displayRadius: Float ) { val uri = ThumbnailsRequester.getAvatarUri(account) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/ToolbarActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/ToolbarActivity.kt index 0728c6611..c32471a36 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/ToolbarActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/ToolbarActivity.kt @@ -124,7 +124,6 @@ abstract class ToolbarActivity : BaseActivity() { AvatarUtils().loadAvatarForAccount( avatarView, account, - true, baseContext.resources.getDimension(R.dimen.toolbar_avatar_radius), imageLoader )