From 94871b8c2d788bca0666dd8d53bea0939d0a10a7 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Fri, 21 Nov 2025 15:13:58 +0100 Subject: [PATCH 01/50] 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 | 4 +- .../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, 304 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 4897b67c8..2d27cb6b5 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt @@ -40,7 +40,6 @@ 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 @@ -117,8 +116,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 9d8eb0256..8edbccfda 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/ClientManager.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/ClientManager.kt @@ -162,4 +162,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 ce850044cf74a926977d51057ae7ecede1cf7e8e Mon Sep 17 00:00:00 2001 From: zerox80 Date: Sat, 22 Nov 2025 18:12:47 +0100 Subject: [PATCH 02/50] 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 92a932d755b3c9989226abef83652512909d90fe Mon Sep 17 00:00:00 2001 From: zerox80 Date: Wed, 26 Nov 2025 18:30:41 +0100 Subject: [PATCH 03/50] 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 a70416d436310bf0927352ac9639278375c9a7c4 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Tue, 2 Dec 2025 17:34:55 +0100 Subject: [PATCH 04/50] 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 ) From fcc5c5c6e611ba970faea2dd561215c9a3d1eaf2 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Fri, 9 Jan 2026 13:57:26 +0100 Subject: [PATCH 05/50] Avatar fix --- .../thumbnails/ThumbnailsRequester.kt | 41 ++++++++++++++++--- 1 file changed, 35 insertions(+), 6 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 728f3efde..2d00b24e9 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 @@ -82,7 +82,10 @@ object ThumbnailsRequester : KoinComponent { 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 baseUrl = + accountManager.getUserData(account, eu.opencloud.android.lib.common.accounts.AccountUtils.Constants.KEY_OC_BASE_URL) + ?.trimEnd('/') + .orEmpty() val username = AccountUtils.getUsernameOfAccount(account.name) return "$baseUrl/index.php/avatar/${android.net.Uri.encode(username)}/384" } @@ -122,7 +125,7 @@ object ThumbnailsRequester : KoinComponent { ImageLoader(appContext).newBuilder().okHttpClient( okHttpClient = openCloudClient.okHttpClient.newBuilder() - .addNetworkInterceptor(coilRequestHeaderInterceptor).build() + .addInterceptor(coilRequestHeaderInterceptor).build() ).logger(DebugLogger()) .memoryCache { getSharedMemoryCache() @@ -148,9 +151,26 @@ object ThumbnailsRequester : KoinComponent { OC_X_REQUEST_ID to RandomUtils.generateRandomUUID(), ) - val request = chain.request().newBuilder() - requestHeaders.toHeaders().forEach { request.addHeader(it.first, it.second) } - val response = chain.proceed(request.build()) + val requestBuilder = chain.request().newBuilder() + requestHeaders.toHeaders().forEach { requestBuilder.addHeader(it.first, it.second) } + val requestWithHeaders = requestBuilder.build() + + var response = chain.proceed(requestWithHeaders) + + val originalUrl = requestWithHeaders.url.toString() + if ( + originalUrl.contains("/index.php/avatar/") && + (!response.isSuccessful || !isProbablyAnImage(response)) + ) { + response.close() + + val baseUrl = originalUrl.substringBefore("/index.php/avatar/").trimEnd('/') + val graphUrl = "$baseUrl/graph/v1.0/me/photo/\$value" + + val graphRequest = requestWithHeaders.newBuilder().url(graphUrl).build() + response = chain.proceed(graphRequest) + } + var builder = response.newBuilder() var changed = false @@ -161,7 +181,11 @@ object ThumbnailsRequester : KoinComponent { changed = true } - if (chain.request().url.toString().contains("/avatar/") && response.header("Content-Type").isNullOrEmpty()) { + val finalRequestUrl = response.request.url.toString() + if ( + (finalRequestUrl.contains("/avatar/") || finalRequestUrl.contains("/photo/\$value")) && + response.header("Content-Type").isNullOrEmpty() + ) { builder.addHeader("Content-Type", "image/png") changed = true } @@ -171,5 +195,10 @@ object ThumbnailsRequester : KoinComponent { } return response } + + private fun isProbablyAnImage(response: Response): Boolean { + val contentType = response.header("Content-Type") + return contentType.isNullOrEmpty() || contentType.startsWith("image") + } } } From d48dc95254950383d627192046a0e66271a6ad8b Mon Sep 17 00:00:00 2001 From: zerox80 Date: Fri, 21 Nov 2025 15:13:58 +0100 Subject: [PATCH 06/50] 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 | 4 +- .../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, 304 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 4897b67c8..2d27cb6b5 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt @@ -40,7 +40,6 @@ 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 @@ -117,8 +116,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 9d8eb0256..8edbccfda 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/ClientManager.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/ClientManager.kt @@ -162,4 +162,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 914127a789cb0f1157a8682318b0d4582a8610c2 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Sat, 22 Nov 2025 18:12:47 +0100 Subject: [PATCH 07/50] 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 adc948457fc8919751da7b73abe4d72651d55da6 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Wed, 26 Nov 2025 18:30:41 +0100 Subject: [PATCH 08/50] 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 ab4070abf64e8e9a8111ba0aac02370a9db14ac6 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Tue, 2 Dec 2025 17:34:55 +0100 Subject: [PATCH 09/50] 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 ) From abd2f84eee9c4fc7527ac63eecbf1ed10dd9d028 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Fri, 21 Nov 2025 09:42:41 +0100 Subject: [PATCH 10/50] Fix: Critical login state and authentication issues --- .../src/main/java/eu/opencloud/android/MainApp.kt | 2 -- .../presentation/authentication/LoginActivity.kt | 13 +++++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt b/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt index 2d27cb6b5..1e6c338e4 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt @@ -116,8 +116,6 @@ class MainApp : Application() { SingleSessionManager.setUserAgent(userAgent) - - initDependencyInjection() // register global protection with pass code, pattern lock and biometric lock diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt index 4e1819a0a..0a579ef54 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt @@ -121,6 +121,16 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted private var pendingAuthorizationIntent: Intent? = null override fun onCreate(savedInstanceState: Bundle?) { + if (intent.data != null && (intent.data?.getQueryParameter("code") != null || intent.data?.getQueryParameter("error") != null)) { + if (!isTaskRoot) { + val newIntent = Intent(this, LoginActivity::class.java) + newIntent.data = intent.data + newIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + startActivity(newIntent) + finish() + return + } + } super.onCreate(savedInstanceState) // Log OAuth redirect details for debugging (especially Firefox issues) @@ -919,9 +929,12 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted outState.putString(KEY_SERVER_BASE_URL, serverBaseUrl) } outState.putBoolean(KEY_OIDC_SUPPORTED, oidcSupported) +<<<<<<< HEAD outState.putString(KEY_CODE_VERIFIER, authenticationViewModel.codeVerifier) outState.putString(KEY_CODE_CHALLENGE, authenticationViewModel.codeChallenge) outState.putString(KEY_OIDC_STATE, authenticationViewModel.oidcState) +======= +>>>>>>> f5e9fdde5 (Fix: Critical login state and authentication issues) } override fun finish() { From 440f9da82ed9b3255fa43972220d9b9267fdaead Mon Sep 17 00:00:00 2001 From: zerox80 Date: Fri, 9 Jan 2026 13:57:26 +0100 Subject: [PATCH 11/50] Avatar fix --- .../thumbnails/ThumbnailsRequester.kt | 41 ++++++++++++++++--- 1 file changed, 35 insertions(+), 6 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 728f3efde..2d00b24e9 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 @@ -82,7 +82,10 @@ object ThumbnailsRequester : KoinComponent { 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 baseUrl = + accountManager.getUserData(account, eu.opencloud.android.lib.common.accounts.AccountUtils.Constants.KEY_OC_BASE_URL) + ?.trimEnd('/') + .orEmpty() val username = AccountUtils.getUsernameOfAccount(account.name) return "$baseUrl/index.php/avatar/${android.net.Uri.encode(username)}/384" } @@ -122,7 +125,7 @@ object ThumbnailsRequester : KoinComponent { ImageLoader(appContext).newBuilder().okHttpClient( okHttpClient = openCloudClient.okHttpClient.newBuilder() - .addNetworkInterceptor(coilRequestHeaderInterceptor).build() + .addInterceptor(coilRequestHeaderInterceptor).build() ).logger(DebugLogger()) .memoryCache { getSharedMemoryCache() @@ -148,9 +151,26 @@ object ThumbnailsRequester : KoinComponent { OC_X_REQUEST_ID to RandomUtils.generateRandomUUID(), ) - val request = chain.request().newBuilder() - requestHeaders.toHeaders().forEach { request.addHeader(it.first, it.second) } - val response = chain.proceed(request.build()) + val requestBuilder = chain.request().newBuilder() + requestHeaders.toHeaders().forEach { requestBuilder.addHeader(it.first, it.second) } + val requestWithHeaders = requestBuilder.build() + + var response = chain.proceed(requestWithHeaders) + + val originalUrl = requestWithHeaders.url.toString() + if ( + originalUrl.contains("/index.php/avatar/") && + (!response.isSuccessful || !isProbablyAnImage(response)) + ) { + response.close() + + val baseUrl = originalUrl.substringBefore("/index.php/avatar/").trimEnd('/') + val graphUrl = "$baseUrl/graph/v1.0/me/photo/\$value" + + val graphRequest = requestWithHeaders.newBuilder().url(graphUrl).build() + response = chain.proceed(graphRequest) + } + var builder = response.newBuilder() var changed = false @@ -161,7 +181,11 @@ object ThumbnailsRequester : KoinComponent { changed = true } - if (chain.request().url.toString().contains("/avatar/") && response.header("Content-Type").isNullOrEmpty()) { + val finalRequestUrl = response.request.url.toString() + if ( + (finalRequestUrl.contains("/avatar/") || finalRequestUrl.contains("/photo/\$value")) && + response.header("Content-Type").isNullOrEmpty() + ) { builder.addHeader("Content-Type", "image/png") changed = true } @@ -171,5 +195,10 @@ object ThumbnailsRequester : KoinComponent { } return response } + + private fun isProbablyAnImage(response: Response): Boolean { + val contentType = response.header("Content-Type") + return contentType.isNullOrEmpty() || contentType.startsWith("image") + } } } From 011f6263224d3d5fd3de17c32ea767a3d662f1a6 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Fri, 9 Jan 2026 14:11:56 +0100 Subject: [PATCH 12/50] Fix DocumentsProvider lock setting bug - value was always set to true - Fixed SettingsSecurityFragment.kt: setPrefLockAccessDocumentProvider now uses actual newValue instead of hardcoded true - Extended UI tests to verify ViewModel is called with correct boolean value --- .../android/settings/security/SettingsSecurityFragmentTest.kt | 2 ++ .../presentation/settings/security/SettingsSecurityFragment.kt | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/opencloudApp/src/androidTest/java/eu/opencloud/android/settings/security/SettingsSecurityFragmentTest.kt b/opencloudApp/src/androidTest/java/eu/opencloud/android/settings/security/SettingsSecurityFragmentTest.kt index 2d3fc4a55..abb3f7c5d 100644 --- a/opencloudApp/src/androidTest/java/eu/opencloud/android/settings/security/SettingsSecurityFragmentTest.kt +++ b/opencloudApp/src/androidTest/java/eu/opencloud/android/settings/security/SettingsSecurityFragmentTest.kt @@ -376,6 +376,7 @@ class SettingsSecurityFragmentTest { onView(withText(R.string.prefs_lock_access_from_document_provider)).perform(click()) assertTrue(prefLockAccessDocumentProvider.isChecked) + io.mockk.verify { securityViewModel.setPrefLockAccessDocumentProvider(true) } } @Test @@ -385,6 +386,7 @@ class SettingsSecurityFragmentTest { onView(withText(R.string.prefs_lock_access_from_document_provider)).perform(click()) onView(withText(R.string.prefs_lock_access_from_document_provider)).perform(click()) assertFalse(prefLockAccessDocumentProvider.isChecked) + io.mockk.verify { securityViewModel.setPrefLockAccessDocumentProvider(false) } } @Test diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityFragment.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityFragment.kt index 5f28b8f7b..235000bc5 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityFragment.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityFragment.kt @@ -196,7 +196,7 @@ class SettingsSecurityFragment : PreferenceFragmentCompat() { // Lock access from document provider prefLockAccessDocumentProvider?.setOnPreferenceChangeListener { _: Preference?, newValue: Any -> - securityViewModel.setPrefLockAccessDocumentProvider(true) + securityViewModel.setPrefLockAccessDocumentProvider(newValue as Boolean) notifyDocumentsProviderRoots(requireContext()) true } From 1531ffbaf1a7cc380e9a99bf9809f4a1cbdddf41 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Fri, 9 Jan 2026 19:27:38 +0100 Subject: [PATCH 13/50] feat: use external storage with human-readable folder names - Switch to External Storage Directory in ScopedStorageProvider. - Request MANAGE_EXTERNAL_STORAGE permission on Android R+. - Use Space names instead of IDs for folder structure. - Rename data folder to OpenCloud. --- opencloudApp/src/main/AndroidManifest.xml | 1 + .../ui/activity/FileDisplayActivity.kt | 19 +++++++++++++++++++ .../android/workers/DownloadFileWorker.kt | 12 +++++++++++- opencloudApp/src/main/res/values/setup.xml | 2 +- .../data/providers/LocalStorageProvider.kt | 13 +++++++++++-- .../data/providers/ScopedStorageProvider.kt | 8 +++++++- 6 files changed, 50 insertions(+), 5 deletions(-) diff --git a/opencloudApp/src/main/AndroidManifest.xml b/opencloudApp/src/main/AndroidManifest.xml index 0d6ba96aa..ca76bb9ca 100644 --- a/opencloudApp/src/main/AndroidManifest.xml +++ b/opencloudApp/src/main/AndroidManifest.xml @@ -23,6 +23,7 @@ API >= 23; the app needs to handle this --> + + Download all files + Download all files from your cloud for offline access (requires significant storage) + Download Everything + This will download ALL files from your cloud. This may use significant storage space and bandwidth. Continue? + + + Auto-sync local changes + Automatically upload changes to locally modified files + Auto-Sync + Local file changes will be automatically synced to the cloud. This requires a stable network connection. Continue? + diff --git a/opencloudApp/src/main/res/xml/settings_security.xml b/opencloudApp/src/main/res/xml/settings_security.xml index 91c72bb0d..b81b6a783 100644 --- a/opencloudApp/src/main/res/xml/settings_security.xml +++ b/opencloudApp/src/main/res/xml/settings_security.xml @@ -49,4 +49,18 @@ app:summary="@string/prefs_touches_with_other_visible_windows_summary" app:title="@string/prefs_touches_with_other_visible_windows" /> + + + + + + \ No newline at end of file From 48e0600739590009df034c30aecdf95cfe282191 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Fri, 9 Jan 2026 20:38:11 +0100 Subject: [PATCH 15/50] fix(workers): Fix DownloadEverythingWorker to properly download all files - Root cause: refreshFolder() only returned changed files, not all - Solution: Use getFolderContent(folderId) after refresh to get ALL files from DB - Add recursive folder traversal with proper refresh before each folder scan - Improve LocalFileSyncWorker with better statistics and notifications - Remove setForegroundAsync to fix Android 14+ foreground service crash --- .../workers/DownloadEverythingWorker.kt | 260 +++++++++++++++--- .../android/workers/LocalFileSyncWorker.kt | 75 ++++- 2 files changed, 286 insertions(+), 49 deletions(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/workers/DownloadEverythingWorker.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/DownloadEverythingWorker.kt index e93e4a7e1..20f79be9e 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/DownloadEverythingWorker.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/DownloadEverythingWorker.kt @@ -21,26 +21,39 @@ package eu.opencloud.android.workers import android.accounts.AccountManager +import android.app.NotificationChannel +import android.app.NotificationManager import android.content.Context +import android.os.Build +import androidx.core.app.NotificationCompat import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import eu.opencloud.android.MainApp +import eu.opencloud.android.R import eu.opencloud.android.domain.capabilities.usecases.GetStoredCapabilitiesUseCase +import eu.opencloud.android.domain.files.FileRepository import eu.opencloud.android.domain.files.model.OCFile import eu.opencloud.android.domain.files.model.OCFile.Companion.ROOT_PATH import eu.opencloud.android.domain.files.usecases.GetFileByRemotePathUseCase import eu.opencloud.android.domain.spaces.usecases.GetPersonalAndProjectSpacesForAccountUseCase import eu.opencloud.android.domain.spaces.usecases.RefreshSpacesFromServerAsyncUseCase import eu.opencloud.android.presentation.authentication.AccountUtils -import eu.opencloud.android.usecases.synchronization.SynchronizeFolderUseCase +import eu.opencloud.android.usecases.transfers.downloads.DownloadFileUseCase import org.koin.core.component.KoinComponent import org.koin.core.component.inject import timber.log.Timber import java.util.concurrent.TimeUnit /** - * Worker that downloads all files from all accounts for offline access. + * Worker that downloads ALL files from all accounts for offline access. * This is an opt-in feature that can be enabled in Security Settings. + * + * This worker: + * 1. Iterates through all connected accounts + * 2. Discovers all spaces (personal + project) for each account + * 3. Recursively scans all folders to find all files + * 4. Enqueues a download for each file that is not yet available locally + * 5. Shows a notification with progress information */ class DownloadEverythingWorker( private val appContext: Context, @@ -54,77 +67,234 @@ class DownloadEverythingWorker( private val refreshSpacesFromServerAsyncUseCase: RefreshSpacesFromServerAsyncUseCase by inject() private val getPersonalAndProjectSpacesForAccountUseCase: GetPersonalAndProjectSpacesForAccountUseCase by inject() private val getFileByRemotePathUseCase: GetFileByRemotePathUseCase by inject() - private val synchronizeFolderUseCase: SynchronizeFolderUseCase by inject() + private val fileRepository: FileRepository by inject() + private val downloadFileUseCase: DownloadFileUseCase by inject() + + private var totalFilesFound = 0 + private var filesDownloaded = 0 + private var filesAlreadyLocal = 0 + private var filesSkipped = 0 + private var foldersProcessed = 0 override suspend fun doWork(): Result { Timber.i("DownloadEverythingWorker started") + + // Create notification channel and show initial notification + createNotificationChannel() + updateNotification("Starting download of all files...") return try { val accountManager = AccountManager.get(appContext) val accounts = accountManager.getAccountsByType(MainApp.accountType) - Timber.i("Found ${accounts.size} accounts to sync") + Timber.i("Found ${accounts.size} accounts to process") + updateNotification("Found ${accounts.size} accounts") - accounts.forEach { account -> + accounts.forEachIndexed { accountIndex, account -> val accountName = account.name - Timber.i("Syncing all files for account: $accountName") - - // Get capabilities for account - val capabilities = getStoredCapabilitiesUseCase(GetStoredCapabilitiesUseCase.Params(accountName)) - val spacesAvailableForAccount = AccountUtils.isSpacesFeatureAllowedForAccount(appContext, account, capabilities) - - if (!spacesAvailableForAccount) { - // Account does not support spaces - sync legacy root - val rootLegacyFolder = getFileByRemotePathUseCase( - GetFileByRemotePathUseCase.Params(accountName, ROOT_PATH, null) - ).getDataOrNull() - rootLegacyFolder?.let { - syncFolderRecursively(it) - } - } else { - // Account supports spaces - sync all spaces - refreshSpacesFromServerAsyncUseCase(RefreshSpacesFromServerAsyncUseCase.Params(accountName)) - val spaces = getPersonalAndProjectSpacesForAccountUseCase( - GetPersonalAndProjectSpacesForAccountUseCase.Params(accountName) - ) - - Timber.i("Found ${spaces.size} spaces for account $accountName") - - spaces.forEach { space -> - val rootFolderForSpace = getFileByRemotePathUseCase( - GetFileByRemotePathUseCase.Params(accountName, ROOT_PATH, space.root.id) - ).getDataOrNull() - - rootFolderForSpace?.let { - Timber.i("Syncing space: ${space.name}") - syncFolderRecursively(it) + Timber.i("Processing account ${accountIndex + 1}/${accounts.size}: $accountName") + updateNotification("Account ${accountIndex + 1}/${accounts.size}: $accountName") + + try { + // Get capabilities for account + val capabilities = getStoredCapabilitiesUseCase(GetStoredCapabilitiesUseCase.Params(accountName)) + val spacesAvailableForAccount = AccountUtils.isSpacesFeatureAllowedForAccount(appContext, account, capabilities) + + if (!spacesAvailableForAccount) { + // Account does not support spaces - process legacy root + Timber.i("Account $accountName uses legacy mode (no spaces)") + processSpaceRoot(accountName, ROOT_PATH, null) + } else { + // Account supports spaces - process all spaces + refreshSpacesFromServerAsyncUseCase(RefreshSpacesFromServerAsyncUseCase.Params(accountName)) + val spaces = getPersonalAndProjectSpacesForAccountUseCase( + GetPersonalAndProjectSpacesForAccountUseCase.Params(accountName) + ) + + Timber.i("Account $accountName has ${spaces.size} spaces") + + spaces.forEachIndexed { spaceIndex, space -> + Timber.i("Processing space ${spaceIndex + 1}/${spaces.size}: ${space.name}") + updateNotification("Space ${spaceIndex + 1}/${spaces.size}: ${space.name}") + + processSpaceRoot(accountName, ROOT_PATH, space.root.id) } } + } catch (e: Exception) { + Timber.e(e, "Error processing account $accountName") } } - Timber.i("DownloadEverythingWorker completed successfully") + val summary = "Done! Files: $totalFilesFound, Downloaded: $filesDownloaded, Already local: $filesAlreadyLocal, Skipped: $filesSkipped, Folders: $foldersProcessed" + Timber.i("DownloadEverythingWorker completed: $summary") + updateNotification(summary) + Result.success() } catch (exception: Exception) { Timber.e(exception, "DownloadEverythingWorker failed") + updateNotification("Failed: ${exception.message}") Result.failure() } } - private fun syncFolderRecursively(folder: OCFile) { - synchronizeFolderUseCase( - SynchronizeFolderUseCase.Params( - accountName = folder.owner, - remotePath = folder.remotePath, - spaceId = folder.spaceId, - syncMode = SynchronizeFolderUseCase.SyncFolderMode.SYNC_FOLDER_RECURSIVELY + /** + * Processes the root of a space by refreshing it and then recursively processing all content. + */ + private fun processSpaceRoot(accountName: String, remotePath: String, spaceId: String?) { + try { + Timber.i("Processing space root: remotePath=$remotePath, spaceId=$spaceId") + + // First refresh the root folder from server to ensure DB has latest data + fileRepository.refreshFolder( + remotePath = remotePath, + accountName = accountName, + spaceId = spaceId, + isActionSetFolderAvailableOfflineOrSynchronize = false ) - ) + + // Now get the root folder from local database + val rootFolder = getFileByRemotePathUseCase( + GetFileByRemotePathUseCase.Params(accountName, remotePath, spaceId) + ).getDataOrNull() + + if (rootFolder == null) { + Timber.w("Root folder not found after refresh for spaceId=$spaceId") + return + } + + Timber.i("Got root folder with id=${rootFolder.id}, remotePath=${rootFolder.remotePath}") + + // Process the root folder recursively + processFolderRecursively(accountName, rootFolder, spaceId) + + } catch (e: Exception) { + Timber.e(e, "Error processing space root: spaceId=$spaceId") + } + } + + /** + * Recursively processes a folder: gets content from database, + * enqueues downloads for files, and recurses into subfolders. + */ + private fun processFolderRecursively(accountName: String, folder: OCFile, spaceId: String?) { + try { + val folderId = folder.id + if (folderId == null) { + Timber.w("Folder ${folder.remotePath} has no id, skipping") + return + } + + foldersProcessed++ + Timber.d("Processing folder: ${folder.remotePath} (id=$folderId)") + + // First refresh this folder from server + try { + fileRepository.refreshFolder( + remotePath = folder.remotePath, + accountName = accountName, + spaceId = spaceId, + isActionSetFolderAvailableOfflineOrSynchronize = false + ) + } catch (e: Exception) { + Timber.e(e, "Error refreshing folder ${folder.remotePath}") + } + + // Now get ALL content from local database (this returns everything, not just changes) + val folderContent = fileRepository.getFolderContent(folderId) + + Timber.d("Folder ${folder.remotePath} contains ${folderContent.size} items") + + folderContent.forEach { item -> + if (item.isFolder) { + // Recursively process subfolders + processFolderRecursively(accountName, item, spaceId) + } else { + // Process file + processFile(accountName, item) + } + } + + // Update notification periodically + if (foldersProcessed % 5 == 0) { + updateNotification("Scanning: $foldersProcessed folders, $totalFilesFound files found") + } + } catch (e: Exception) { + Timber.e(e, "Error processing folder ${folder.remotePath}") + } + } + + /** + * Processes a single file: checks if it's already local, + * and if not, enqueues a download. + */ + private fun processFile(accountName: String, file: OCFile) { + totalFilesFound++ + + try { + if (file.isAvailableLocally) { + // File is already downloaded + filesAlreadyLocal++ + Timber.d("File already local: ${file.fileName}") + } else { + // Enqueue download + val downloadId = downloadFileUseCase(DownloadFileUseCase.Params(accountName, file)) + if (downloadId != null) { + filesDownloaded++ + Timber.i("Enqueued download for: ${file.fileName}") + } else { + filesSkipped++ + Timber.d("Download already enqueued or skipped: ${file.fileName}") + } + } + + // Update notification periodically (every 20 files) + if (totalFilesFound % 20 == 0) { + updateNotification("Found: $totalFilesFound files, $filesDownloaded queued for download") + } + } catch (e: Exception) { + filesSkipped++ + Timber.e(e, "Error processing file ${file.fileName}") + } + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + NOTIFICATION_CHANNEL_ID, + "Download Everything", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Shows progress when downloading all files" + } + val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + } + + private fun updateNotification(contentText: String) { + try { + val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val notification = NotificationCompat.Builder(appContext, NOTIFICATION_CHANNEL_ID) + .setContentTitle("Download Everything") + .setContentText(contentText) + .setStyle(NotificationCompat.BigTextStyle().bigText(contentText)) + .setSmallIcon(R.drawable.notification_icon) + .setOngoing(true) + .setProgress(0, 0, true) + .build() + + notificationManager.notify(NOTIFICATION_ID, notification) + } catch (e: Exception) { + Timber.e(e, "Error updating notification") + } } companion object { const val DOWNLOAD_EVERYTHING_WORKER = "DOWNLOAD_EVERYTHING_WORKER" const val repeatInterval: Long = 6L val repeatIntervalTimeUnit: TimeUnit = TimeUnit.HOURS + + private const val NOTIFICATION_CHANNEL_ID = "download_everything_channel" + private const val NOTIFICATION_ID = 9001 } } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/workers/LocalFileSyncWorker.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/LocalFileSyncWorker.kt index c4246bb30..bb43c047f 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/LocalFileSyncWorker.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/LocalFileSyncWorker.kt @@ -21,10 +21,16 @@ package eu.opencloud.android.workers import android.accounts.AccountManager +import android.app.NotificationChannel +import android.app.NotificationManager import android.content.Context +import android.os.Build +import androidx.core.app.NotificationCompat import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo import androidx.work.WorkerParameters import eu.opencloud.android.MainApp +import eu.opencloud.android.R import eu.opencloud.android.domain.UseCaseResult import eu.opencloud.android.domain.files.FileRepository import eu.opencloud.android.usecases.synchronization.SynchronizeFileUseCase @@ -39,6 +45,8 @@ import java.util.concurrent.TimeUnit * * It monitors all downloaded files and checks if they have been modified locally. * If a file has been modified, it uploads the new version to the server. + * + * Shows a notification with sync progress and results. */ class LocalFileSyncWorker( private val appContext: Context, @@ -53,6 +61,8 @@ class LocalFileSyncWorker( override suspend fun doWork(): Result { Timber.i("LocalFileSyncWorker started") + + createNotificationChannel() return try { val accountManager = AccountManager.get(appContext) @@ -61,7 +71,12 @@ class LocalFileSyncWorker( Timber.i("Checking ${accounts.size} accounts for local file changes") var totalFilesChecked = 0 - var totalFilesUpdated = 0 + var filesUploaded = 0 + var filesDownloaded = 0 + var filesWithConflicts = 0 + var filesAlreadySynced = 0 + var filesNotFound = 0 + var errors = 0 accounts.forEach { account -> val accountName = account.name @@ -81,35 +96,54 @@ class LocalFileSyncWorker( when (val syncResult = useCaseResult.data) { is SynchronizeFileUseCase.SyncType.UploadEnqueued -> { Timber.i("File ${file.fileName} has local changes, upload enqueued") - totalFilesUpdated++ + filesUploaded++ } is SynchronizeFileUseCase.SyncType.DownloadEnqueued -> { Timber.i("File ${file.fileName} has remote changes, download enqueued") - totalFilesUpdated++ + filesDownloaded++ } is SynchronizeFileUseCase.SyncType.ConflictDetected -> { Timber.w("File ${file.fileName} has a conflict with etag: ${syncResult.etagInConflict}") + filesWithConflicts++ } is SynchronizeFileUseCase.SyncType.AlreadySynchronized -> { Timber.d("File ${file.fileName} is already synchronized") + filesAlreadySynced++ } is SynchronizeFileUseCase.SyncType.FileNotFound -> { Timber.w("File ${file.fileName} was not found on server") + filesNotFound++ } } } is UseCaseResult.Error -> { Timber.e(useCaseResult.throwable, "Error syncing file ${file.fileName}") + errors++ } } } catch (e: Exception) { Timber.e(e, "Error syncing file ${file.fileName}") + errors++ } } } } - Timber.i("LocalFileSyncWorker completed: checked $totalFilesChecked files, updated $totalFilesUpdated") + val summary = buildString { + append("Checked: $totalFilesChecked") + if (filesUploaded > 0) append(" | Uploaded: $filesUploaded") + if (filesDownloaded > 0) append(" | Downloaded: $filesDownloaded") + if (filesWithConflicts > 0) append(" | Conflicts: $filesWithConflicts") + if (errors > 0) append(" | Errors: $errors") + } + + Timber.i("LocalFileSyncWorker completed: $summary") + + // Only show notification if something changed + if (filesUploaded > 0 || filesDownloaded > 0 || filesWithConflicts > 0) { + showCompletionNotification(summary) + } + Result.success() } catch (exception: Exception) { Timber.e(exception, "LocalFileSyncWorker failed") @@ -117,9 +151,42 @@ class LocalFileSyncWorker( } } + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + NOTIFICATION_CHANNEL_ID, + "Auto-Sync", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Shows when local file changes are synced" + } + val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + } + + private fun showCompletionNotification(summary: String) { + try { + val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val notification = NotificationCompat.Builder(appContext, NOTIFICATION_CHANNEL_ID) + .setContentTitle("Auto-Sync Complete") + .setContentText(summary) + .setSmallIcon(R.drawable.notification_icon) + .setAutoCancel(true) + .build() + + notificationManager.notify(NOTIFICATION_ID, notification) + } catch (e: Exception) { + Timber.e(e, "Error showing notification") + } + } + companion object { const val LOCAL_FILE_SYNC_WORKER = "LOCAL_FILE_SYNC_WORKER" const val repeatInterval: Long = 5L val repeatIntervalTimeUnit: TimeUnit = TimeUnit.MINUTES + + private const val NOTIFICATION_CHANNEL_ID = "auto_sync_channel" + private const val NOTIFICATION_ID = 9002 } } From bff1cb6c7896d7c3174bd3ba66f62f59965d4b38 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Fri, 9 Jan 2026 20:55:18 +0100 Subject: [PATCH 16/50] fix(i18n): Change German storage permission dialog to English --- .../eu/opencloud/android/ui/activity/FileDisplayActivity.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt index 5a842a2e9..2543760e2 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt @@ -294,14 +294,14 @@ class FileDisplayActivity : FileActivity(), if (!android.os.Environment.isExternalStorageManager()) { val builder = AlertDialog.Builder(this) builder.setTitle(getString(R.string.app_name)) - builder.setMessage("Um Offline-Dateien öffentlich speichern zu können, benötigt die App Zugriff auf alle Dateien.") - builder.setPositiveButton("Einstellungen") { _, _ -> + builder.setMessage("To save offline files, the app needs access to all files.") + builder.setPositiveButton("Settings") { _, _ -> val intent = Intent(android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) intent.addCategory("android.intent.category.DEFAULT") intent.data = Uri.parse("package:$packageName") startActivity(intent) } - builder.setNegativeButton("Abbrechen", null) + builder.setNegativeButton("Cancel", null) builder.show() } } From 0deb1402a711f840ad2f8b5eb9e24fe3ac76f157 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Fri, 9 Jan 2026 22:10:20 +0100 Subject: [PATCH 17/50] Fix: Save OAuth state (codeVerifier, state) in onSaveInstanceState to prevent login failure after process death --- .../android/presentation/authentication/LoginActivity.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt index 0a579ef54..dd7a222d4 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt @@ -929,12 +929,9 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted outState.putString(KEY_SERVER_BASE_URL, serverBaseUrl) } outState.putBoolean(KEY_OIDC_SUPPORTED, oidcSupported) -<<<<<<< HEAD outState.putString(KEY_CODE_VERIFIER, authenticationViewModel.codeVerifier) outState.putString(KEY_CODE_CHALLENGE, authenticationViewModel.codeChallenge) outState.putString(KEY_OIDC_STATE, authenticationViewModel.oidcState) -======= ->>>>>>> f5e9fdde5 (Fix: Critical login state and authentication issues) } override fun finish() { From a15ca0f444d67c756f6bc2d77ae904e626ac1df6 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Fri, 9 Jan 2026 22:23:14 +0100 Subject: [PATCH 18/50] Fix: Add missing super.onCreate call in LoginActivity --- .../android/presentation/authentication/LoginActivity.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt index dd7a222d4..906793504 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt @@ -121,6 +121,7 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted private var pendingAuthorizationIntent: Intent? = null override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) if (intent.data != null && (intent.data?.getQueryParameter("code") != null || intent.data?.getQueryParameter("error") != null)) { if (!isTaskRoot) { val newIntent = Intent(this, LoginActivity::class.java) From c834bc2ad5707516248f772a51fc59f9f76aa55f Mon Sep 17 00:00:00 2001 From: zerox80 Date: Fri, 9 Jan 2026 22:33:26 +0100 Subject: [PATCH 19/50] Fix: Remove duplicate super.onCreate call --- .../android/presentation/authentication/LoginActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt index 906793504..73f771050 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt @@ -132,7 +132,7 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted return } } - super.onCreate(savedInstanceState) + // Log OAuth redirect details for debugging (especially Firefox issues) Timber.d("onCreate called with intent data: ${intent.data}, isTaskRoot: $isTaskRoot") From 66b7691d489732c2e575cb954e27beef227b24d3 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Fri, 9 Jan 2026 22:43:18 +0100 Subject: [PATCH 20/50] Fix: Check if binding is initialized in handleGetAuthorizationCodeResponse --- .../android/presentation/authentication/LoginActivity.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt index 73f771050..2be54cebb 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt @@ -645,6 +645,8 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted } private fun handleGetAuthorizationCodeResponse(intent: Intent) { + if (!::binding.isInitialized) return + val authorizationCode = intent.data?.getQueryParameter("code") val state = intent.data?.getQueryParameter("state") From 9eb28a4944be5068695b3fafa70ca983f67c5354 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Sat, 10 Jan 2026 01:18:48 +0100 Subject: [PATCH 21/50] Fix: Defer authorization intent processing until binding is ready --- .../android/presentation/authentication/LoginActivity.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt index 2be54cebb..20e268cf4 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt @@ -114,6 +114,7 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted private var oidcSupported = false private lateinit var binding: AccountSetupBinding + private var pendingAuthorizationIntent: Intent? = null // For handling AbstractAccountAuthenticator responses private var accountAuthenticatorResponse: AccountAuthenticatorResponse? = null @@ -256,7 +257,6 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted pendingAuthorizationIntent = null } - } private fun handleDeepLink() { @@ -645,7 +645,10 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted } private fun handleGetAuthorizationCodeResponse(intent: Intent) { - if (!::binding.isInitialized) return + if (!::binding.isInitialized) { + pendingAuthorizationIntent = intent + return + } val authorizationCode = intent.data?.getQueryParameter("code") val state = intent.data?.getQueryParameter("state") From cdcffc4f0baee7e13dfb98622b38a661fc62e037 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Sat, 10 Jan 2026 11:52:44 +0100 Subject: [PATCH 22/50] Fix Firefox OAuth redirect issue - Add FLAG_ACTIVITY_NEW_TASK to CustomTabsIntent for Firefox compatibility - Add setIntent() in onNewIntent to properly handle OAuth redirects - Add logging for OAuth redirect debugging --- .../android/presentation/authentication/LoginActivity.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt index 20e268cf4..92280c42c 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt @@ -123,8 +123,14 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + // Log OAuth redirect details for debugging (especially Firefox issues) + Timber.d("onCreate called with intent data: ${intent.data}, isTaskRoot: $isTaskRoot") + if (intent.data != null && (intent.data?.getQueryParameter("code") != null || intent.data?.getQueryParameter("error") != null)) { + Timber.d("OAuth redirect detected with code or error parameter") if (!isTaskRoot) { + Timber.d("Not task root, forwarding OAuth redirect to existing LoginActivity instance") val newIntent = Intent(this, LoginActivity::class.java) newIntent.data = intent.data newIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) From 6d03573195af15cb80a8406eb39c6cd0c7ff3161 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Sat, 10 Jan 2026 11:56:39 +0100 Subject: [PATCH 23/50] Fix detekt trailing whitespace and spacing issues --- .../android/presentation/authentication/LoginActivity.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt index 92280c42c..093b4372e 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt @@ -123,10 +123,10 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - + // Log OAuth redirect details for debugging (especially Firefox issues) Timber.d("onCreate called with intent data: ${intent.data}, isTaskRoot: $isTaskRoot") - + if (intent.data != null && (intent.data?.getQueryParameter("code") != null || intent.data?.getQueryParameter("error") != null)) { Timber.d("OAuth redirect detected with code or error parameter") if (!isTaskRoot) { From d231ba2a677ebf94fe05a83f314ec54c708afe4b Mon Sep 17 00:00:00 2001 From: zerox80 Date: Sun, 11 Jan 2026 21:03:15 +0100 Subject: [PATCH 24/50] fix: Use Graph API endpoint for user avatars Changed avatar endpoint from legacy /index.php/avatar/ to /graph/v1.0/me/photo/ for openCloud compatibility. --- .../lib/resources/users/GetRemoteUserAvatarOperation.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/users/GetRemoteUserAvatarOperation.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/users/GetRemoteUserAvatarOperation.kt index 9e355c03c..e6ba9bd6f 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/users/GetRemoteUserAvatarOperation.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/users/GetRemoteUserAvatarOperation.kt @@ -31,7 +31,6 @@ import eu.opencloud.android.lib.common.network.WebdavUtils import eu.opencloud.android.lib.common.operations.RemoteOperation import eu.opencloud.android.lib.common.operations.RemoteOperationResult import timber.log.Timber -import java.io.File import java.io.IOException import java.io.InputStream import java.net.URL @@ -48,8 +47,7 @@ class GetRemoteUserAvatarOperation(private val avatarDimension: Int) : RemoteOpe var result: RemoteOperationResult try { - val endPoint = - client.baseUri.toString() + NON_OFFICIAL_AVATAR_PATH + client.credentials.username + File.separator + avatarDimension + val endPoint = client.baseUri.toString() + GRAPH_AVATAR_PATH Timber.d("avatar URI: %s", endPoint) val getMethod = GetMethod(URL(endPoint)) @@ -109,6 +107,6 @@ class GetRemoteUserAvatarOperation(private val avatarDimension: Int) : RemoteOpe private fun isSuccess(status: Int) = status == HttpConstants.HTTP_OK companion object { - private const val NON_OFFICIAL_AVATAR_PATH = "/index.php/avatar/" + private const val GRAPH_AVATAR_PATH = "/graph/v1.0/me/photo/\$value" } } From d33b4c444b64e7ac2df1e86d39355defd59224b4 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Sun, 11 Jan 2026 21:03:35 +0100 Subject: [PATCH 25/50] fix: Auto-resolve sync conflicts by uploading local version When a file changes both locally and remotely, automatically upload the local version instead of requiring manual conflict resolution. This provides a seamless auto-sync experience (last write wins). --- .../synchronization/SynchronizeFileUseCase.kt | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt b/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt index f75824b29..c37f8673f 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt @@ -83,17 +83,10 @@ class SynchronizeFileUseCase( Timber.i("So it has changed remotely: $changedRemotely") if (changedLocally && changedRemotely) { - // 5.1 File has changed locally and remotely. We got a conflict, save the conflict. - Timber.i("File ${fileToSynchronize.fileName} has changed locally and remotely. We got a conflict with etag: ${serverFile.etag}") - if (fileToSynchronize.etagInConflict == null) { - saveConflictUseCase( - SaveConflictUseCase.Params( - fileId = fileToSynchronize.id!!, - eTagInConflict = serverFile.etag!! - ) - ) - } - SyncType.ConflictDetected(serverFile.etag!!) + // 5.1 File has changed locally and remotely. Auto-resolve by uploading local version. + Timber.i("File ${fileToSynchronize.fileName} has changed locally and remotely. Auto-resolving by uploading local version.") + val uuid = requestForUpload(accountName, fileToSynchronize) + SyncType.UploadEnqueued(uuid) } else if (changedRemotely) { // 5.2 File has changed ONLY remotely -> download new version Timber.i("File ${fileToSynchronize.fileName} has changed remotely. Let's download the new version") From b616e1f85e8272290e7579a201e5f7809d4fe3b6 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Mon, 12 Jan 2026 16:46:00 +0100 Subject: [PATCH 26/50] feat: Auto-resolve sync conflicts with conflicted copies - When a file is modified both locally and remotely, create a conflicted copy of the local file and download the remote version. This ensures no data loss, matching desktop client behavior. --- .../DocumentsStorageProvider.kt | 10 ++-- .../files/details/FileDetailsFragment.kt | 6 +-- .../ui/activity/FileDisplayActivity.kt | 6 +-- .../synchronization/SynchronizeFileUseCase.kt | 47 +++++++++++++++++-- .../android/workers/LocalFileSyncWorker.kt | 4 +- opencloudApp/src/main/res/values/strings.xml | 1 + 6 files changed, 52 insertions(+), 22 deletions(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/DocumentsStorageProvider.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/DocumentsStorageProvider.kt index 45d8a58f3..37477b471 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/DocumentsStorageProvider.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/DocumentsStorageProvider.kt @@ -158,13 +158,9 @@ class DocumentsStorageProvider : DocumentsProvider() { ) ) Timber.d("Synced ${ocFile.remotePath} from ${ocFile.owner} with result: $result") - if (result.getDataOrNull() is SynchronizeFileUseCase.SyncType.ConflictDetected) { - context?.let { - NotificationUtils.notifyConflict( - fileInConflict = ocFile, - context = it - ) - } + if (result.getDataOrNull() is SynchronizeFileUseCase.SyncType.ConflictResolvedWithCopy) { + val conflictResult = result.getDataOrNull() as SynchronizeFileUseCase.SyncType.ConflictResolvedWithCopy + Timber.i("File sync conflict auto-resolved. Conflicted copy at: ${conflictResult.conflictedCopyPath}") } }.start() } 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 49e58c90c..84294bac4 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 @@ -192,10 +192,8 @@ class FileDetailsFragment : FileFragment() { SynchronizeFileUseCase.SyncType.AlreadySynchronized -> { showMessageInSnackbar(getString(R.string.sync_file_nothing_to_do_msg)) } - is SynchronizeFileUseCase.SyncType.ConflictDetected -> { - val showConflictActivityIntent = Intent(requireActivity(), ConflictsResolveActivity::class.java) - showConflictActivityIntent.putExtra(ConflictsResolveActivity.EXTRA_FILE, file) - startActivity(showConflictActivityIntent) + is SynchronizeFileUseCase.SyncType.ConflictResolvedWithCopy -> { + showMessageInSnackbar(getString(R.string.sync_conflict_resolved_with_copy)) } is SynchronizeFileUseCase.SyncType.DownloadEnqueued -> { diff --git a/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt index 2543760e2..b2dfd5706 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt @@ -1384,10 +1384,8 @@ class FileDisplayActivity : FileActivity(), } } - is SynchronizeFileUseCase.SyncType.ConflictDetected -> { - val showConflictActivityIntent = Intent(this, ConflictsResolveActivity::class.java) - showConflictActivityIntent.putExtra(ConflictsResolveActivity.EXTRA_FILE, file) - startActivity(showConflictActivityIntent) + is SynchronizeFileUseCase.SyncType.ConflictResolvedWithCopy -> { + showSnackMessage(getString(R.string.sync_conflict_resolved_with_copy)) } is SynchronizeFileUseCase.SyncType.DownloadEnqueued -> { diff --git a/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt b/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt index c37f8673f..3e7c6184c 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt @@ -31,6 +31,10 @@ import eu.opencloud.android.usecases.transfers.uploads.UploadFileInConflictUseCa import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import timber.log.Timber +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale import java.util.UUID class SynchronizeFileUseCase( @@ -83,10 +87,20 @@ class SynchronizeFileUseCase( Timber.i("So it has changed remotely: $changedRemotely") if (changedLocally && changedRemotely) { - // 5.1 File has changed locally and remotely. Auto-resolve by uploading local version. - Timber.i("File ${fileToSynchronize.fileName} has changed locally and remotely. Auto-resolving by uploading local version.") - val uuid = requestForUpload(accountName, fileToSynchronize) - SyncType.UploadEnqueued(uuid) + // 5.1 File has changed locally and remotely. Create conflicted copy of local, download remote. + Timber.i("File ${fileToSynchronize.fileName} has changed locally and remotely. Creating conflicted copy.") + val conflictedCopyPath = createConflictedCopyPath(fileToSynchronize) + val renamed = renameLocalFile(fileToSynchronize.storagePath!!, conflictedCopyPath) + if (renamed) { + Timber.i("Local file renamed to conflicted copy: $conflictedCopyPath") + val uuid = requestForDownload(accountName, fileToSynchronize) + SyncType.ConflictResolvedWithCopy(uuid, conflictedCopyPath) + } else { + Timber.w("Failed to rename local file to conflicted copy") + // Fallback: download remote anyway, local changes may be overwritten + val uuid = requestForDownload(accountName, fileToSynchronize) + SyncType.DownloadEnqueued(uuid) + } } else if (changedRemotely) { // 5.2 File has changed ONLY remotely -> download new version Timber.i("File ${fileToSynchronize.fileName} has changed remotely. Let's download the new version") @@ -124,13 +138,36 @@ class SynchronizeFileUseCase( ) ) + private fun createConflictedCopyPath(ocFile: OCFile): String { + val originalPath = ocFile.storagePath!! + val file = File(originalPath) + val nameWithoutExt = file.nameWithoutExtension + val extension = file.extension + val timestamp = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US).format(Date()) + val conflictedName = if (extension.isNotEmpty()) { + "${nameWithoutExt}_conflicted_copy_$timestamp.$extension" + } else { + "${nameWithoutExt}_conflicted_copy_$timestamp" + } + return File(file.parent, conflictedName).absolutePath + } + + private fun renameLocalFile(oldPath: String, newPath: String): Boolean { + return try { + File(oldPath).renameTo(File(newPath)) + } catch (e: Exception) { + Timber.e(e, "Failed to rename local file from $oldPath to $newPath") + false + } + } + data class Params( val fileToSynchronize: OCFile, ) sealed interface SyncType { object FileNotFound : SyncType - data class ConflictDetected(val etagInConflict: String) : SyncType + data class ConflictResolvedWithCopy(val workerId: UUID?, val conflictedCopyPath: String) : SyncType data class DownloadEnqueued(val workerId: UUID?) : SyncType data class UploadEnqueued(val workerId: UUID?) : SyncType object AlreadySynchronized : SyncType diff --git a/opencloudApp/src/main/java/eu/opencloud/android/workers/LocalFileSyncWorker.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/LocalFileSyncWorker.kt index bb43c047f..284d920a6 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/LocalFileSyncWorker.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/LocalFileSyncWorker.kt @@ -102,8 +102,8 @@ class LocalFileSyncWorker( Timber.i("File ${file.fileName} has remote changes, download enqueued") filesDownloaded++ } - is SynchronizeFileUseCase.SyncType.ConflictDetected -> { - Timber.w("File ${file.fileName} has a conflict with etag: ${syncResult.etagInConflict}") + is SynchronizeFileUseCase.SyncType.ConflictResolvedWithCopy -> { + Timber.i("File ${file.fileName} had a conflict. Conflicted copy created at: ${syncResult.conflictedCopyPath}") filesWithConflicts++ } is SynchronizeFileUseCase.SyncType.AlreadySynchronized -> { diff --git a/opencloudApp/src/main/res/values/strings.xml b/opencloudApp/src/main/res/values/strings.xml index 405f94c64..742d8d178 100644 --- a/opencloudApp/src/main/res/values/strings.xml +++ b/opencloudApp/src/main/res/values/strings.xml @@ -403,6 +403,7 @@ A new version was found in server. Downloading… Download enqueued Upload enqueued + Conflict resolved. Your local changes were saved as a separate copy. Folder could not be created File could not be created Forbidden characters: / \\ From 5d7bb929ac04884e8bd54be898d71654c098e27d Mon Sep 17 00:00:00 2001 From: zerox80 Date: Mon, 12 Jan 2026 16:50:08 +0100 Subject: [PATCH 27/50] feat: Refresh parent folder after creating conflicted copy - This makes the conflicted copy visible in the app's file list immediately after sync conflict resolution --- .../synchronization/SynchronizeFileUseCase.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt b/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt index 3e7c6184c..411d5c81b 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt @@ -93,6 +93,17 @@ class SynchronizeFileUseCase( val renamed = renameLocalFile(fileToSynchronize.storagePath!!, conflictedCopyPath) if (renamed) { Timber.i("Local file renamed to conflicted copy: $conflictedCopyPath") + // Refresh parent folder so the conflicted copy appears in the file list + try { + fileRepository.refreshFolder( + remotePath = fileToSynchronize.getParentRemotePath(), + accountName = accountName, + spaceId = fileToSynchronize.spaceId + ) + Timber.i("Parent folder refreshed after creating conflicted copy") + } catch (e: Exception) { + Timber.w(e, "Failed to refresh parent folder after creating conflicted copy") + } val uuid = requestForDownload(accountName, fileToSynchronize) SyncType.ConflictResolvedWithCopy(uuid, conflictedCopyPath) } else { From 71ec1d3d6c006d216037a46065633f73aedb5b30 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Mon, 12 Jan 2026 17:00:42 +0100 Subject: [PATCH 28/50] feat: Add 'Prefer local version on conflict' setting - New preference in Security settings to choose conflict resolution strategy - When enabled: upload local version (overwrites remote) - When disabled (default): create conflicted copy and download remote - Ensures no data loss while giving users control over conflict behavior --- .../security/SettingsSecurityFragment.kt | 9 +++ .../security/SettingsSecurityViewModel.kt | 7 +++ .../synchronization/SynchronizeFileUseCase.kt | 61 ++++++++++++------- opencloudApp/src/main/res/values/strings.xml | 4 ++ .../src/main/res/xml/settings_security.xml | 7 +++ 5 files changed, 65 insertions(+), 23 deletions(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityFragment.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityFragment.kt index b8a4d9ef2..056545b27 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityFragment.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityFragment.kt @@ -61,6 +61,7 @@ class SettingsSecurityFragment : PreferenceFragmentCompat() { private var prefTouchesWithOtherVisibleWindows: CheckBoxPreference? = null private var prefDownloadEverything: CheckBoxPreference? = null private var prefAutoSync: CheckBoxPreference? = null + private var prefPreferLocalOnConflict: CheckBoxPreference? = null private val enablePasscodeLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> @@ -139,6 +140,7 @@ class SettingsSecurityFragment : PreferenceFragmentCompat() { prefTouchesWithOtherVisibleWindows = findPreference(PREFERENCE_TOUCHES_WITH_OTHER_VISIBLE_WINDOWS) prefDownloadEverything = findPreference(PREFERENCE_DOWNLOAD_EVERYTHING) prefAutoSync = findPreference(PREFERENCE_AUTO_SYNC) + prefPreferLocalOnConflict = findPreference(PREFERENCE_PREFER_LOCAL_ON_CONFLICT) prefPasscode?.isVisible = !securityViewModel.isSecurityEnforcedEnabled() prefPattern?.isVisible = !securityViewModel.isSecurityEnforcedEnabled() @@ -277,6 +279,12 @@ class SettingsSecurityFragment : PreferenceFragmentCompat() { true } } + + // Conflict Resolution Strategy + prefPreferLocalOnConflict?.setOnPreferenceChangeListener { _: Preference?, newValue: Any -> + securityViewModel.setPreferLocalOnConflict(newValue as Boolean) + true + } } private fun enableBiometricAndLockApplication() { @@ -303,5 +311,6 @@ class SettingsSecurityFragment : PreferenceFragmentCompat() { const val PREFERENCE_LOCK_ATTEMPTS = "PrefLockAttempts" const val PREFERENCE_DOWNLOAD_EVERYTHING = "download_everything" const val PREFERENCE_AUTO_SYNC = "auto_sync_local_changes" + const val PREFERENCE_PREFER_LOCAL_ON_CONFLICT = "prefer_local_on_conflict" } } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityViewModel.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityViewModel.kt index f53afed83..c179eb94c 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityViewModel.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityViewModel.kt @@ -77,4 +77,11 @@ class SettingsSecurityViewModel( fun setAutoSync(enabled: Boolean) = preferencesProvider.putBoolean(SettingsSecurityFragment.PREFERENCE_AUTO_SYNC, enabled) + + // Conflict Resolution Strategy + fun isPreferLocalOnConflictEnabled(): Boolean = + preferencesProvider.getBoolean(SettingsSecurityFragment.PREFERENCE_PREFER_LOCAL_ON_CONFLICT, false) + + fun setPreferLocalOnConflict(enabled: Boolean) = + preferencesProvider.putBoolean(SettingsSecurityFragment.PREFERENCE_PREFER_LOCAL_ON_CONFLICT, enabled) } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt b/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt index 411d5c81b..d1b31bc96 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt @@ -21,11 +21,13 @@ package eu.opencloud.android.usecases.synchronization +import eu.opencloud.android.data.providers.SharedPreferencesProvider import eu.opencloud.android.domain.BaseUseCaseWithResult import eu.opencloud.android.domain.exceptions.FileNotFoundException import eu.opencloud.android.domain.files.FileRepository import eu.opencloud.android.domain.files.model.OCFile import eu.opencloud.android.domain.files.usecases.SaveConflictUseCase +import eu.opencloud.android.presentation.settings.security.SettingsSecurityFragment import eu.opencloud.android.usecases.transfers.downloads.DownloadFileUseCase import eu.opencloud.android.usecases.transfers.uploads.UploadFileInConflictUseCase import kotlinx.coroutines.CoroutineScope @@ -42,6 +44,7 @@ class SynchronizeFileUseCase( private val uploadFileInConflictUseCase: UploadFileInConflictUseCase, private val saveConflictUseCase: SaveConflictUseCase, private val fileRepository: FileRepository, + private val preferencesProvider: SharedPreferencesProvider, ) : BaseUseCaseWithResult() { override fun run(params: Params): SyncType { @@ -87,30 +90,42 @@ class SynchronizeFileUseCase( Timber.i("So it has changed remotely: $changedRemotely") if (changedLocally && changedRemotely) { - // 5.1 File has changed locally and remotely. Create conflicted copy of local, download remote. - Timber.i("File ${fileToSynchronize.fileName} has changed locally and remotely. Creating conflicted copy.") - val conflictedCopyPath = createConflictedCopyPath(fileToSynchronize) - val renamed = renameLocalFile(fileToSynchronize.storagePath!!, conflictedCopyPath) - if (renamed) { - Timber.i("Local file renamed to conflicted copy: $conflictedCopyPath") - // Refresh parent folder so the conflicted copy appears in the file list - try { - fileRepository.refreshFolder( - remotePath = fileToSynchronize.getParentRemotePath(), - accountName = accountName, - spaceId = fileToSynchronize.spaceId - ) - Timber.i("Parent folder refreshed after creating conflicted copy") - } catch (e: Exception) { - Timber.w(e, "Failed to refresh parent folder after creating conflicted copy") - } - val uuid = requestForDownload(accountName, fileToSynchronize) - SyncType.ConflictResolvedWithCopy(uuid, conflictedCopyPath) + // 5.1 File has changed locally and remotely. + val preferLocal = preferencesProvider.getBoolean( + SettingsSecurityFragment.PREFERENCE_PREFER_LOCAL_ON_CONFLICT, false + ) + + if (preferLocal) { + // User prefers local version - upload it (overwrites remote) + Timber.i("File ${fileToSynchronize.fileName} has conflict. User prefers local version, uploading.") + val uuid = requestForUpload(accountName, fileToSynchronize) + SyncType.UploadEnqueued(uuid) } else { - Timber.w("Failed to rename local file to conflicted copy") - // Fallback: download remote anyway, local changes may be overwritten - val uuid = requestForDownload(accountName, fileToSynchronize) - SyncType.DownloadEnqueued(uuid) + // Default: Create conflicted copy of local, download remote. + Timber.i("File ${fileToSynchronize.fileName} has changed locally and remotely. Creating conflicted copy.") + val conflictedCopyPath = createConflictedCopyPath(fileToSynchronize) + val renamed = renameLocalFile(fileToSynchronize.storagePath!!, conflictedCopyPath) + if (renamed) { + Timber.i("Local file renamed to conflicted copy: $conflictedCopyPath") + // Refresh parent folder so the conflicted copy appears in the file list + try { + fileRepository.refreshFolder( + remotePath = fileToSynchronize.getParentRemotePath(), + accountName = accountName, + spaceId = fileToSynchronize.spaceId + ) + Timber.i("Parent folder refreshed after creating conflicted copy") + } catch (e: Exception) { + Timber.w(e, "Failed to refresh parent folder after creating conflicted copy") + } + val uuid = requestForDownload(accountName, fileToSynchronize) + SyncType.ConflictResolvedWithCopy(uuid, conflictedCopyPath) + } else { + Timber.w("Failed to rename local file to conflicted copy") + // Fallback: download remote anyway, local changes may be overwritten + val uuid = requestForDownload(accountName, fileToSynchronize) + SyncType.DownloadEnqueued(uuid) + } } } else if (changedRemotely) { // 5.2 File has changed ONLY remotely -> download new version diff --git a/opencloudApp/src/main/res/values/strings.xml b/opencloudApp/src/main/res/values/strings.xml index 742d8d178..57df15b6a 100644 --- a/opencloudApp/src/main/res/values/strings.xml +++ b/opencloudApp/src/main/res/values/strings.xml @@ -866,4 +866,8 @@ Auto-Sync Local file changes will be automatically synced to the cloud. This requires a stable network connection. Continue? + + Prefer local version on conflict + When a file is modified both locally and on server, upload local version instead of creating a conflicted copy + diff --git a/opencloudApp/src/main/res/xml/settings_security.xml b/opencloudApp/src/main/res/xml/settings_security.xml index b81b6a783..3e2888145 100644 --- a/opencloudApp/src/main/res/xml/settings_security.xml +++ b/opencloudApp/src/main/res/xml/settings_security.xml @@ -63,4 +63,11 @@ app:summary="@string/prefs_auto_sync_summary" app:title="@string/prefs_auto_sync" /> + + + \ No newline at end of file From 3c180cdadd5990d093e20a25b13e3da90c3ad25c Mon Sep 17 00:00:00 2001 From: zerox80 Date: Mon, 12 Jan 2026 17:09:38 +0100 Subject: [PATCH 29/50] fix: Add conflict detection to direct upload path - UploadFileFromFileSystemWorker now checks for conflicts when forceOverwrite=true - Creates local conflicted copy before uploading when remote changed - Respects 'prefer local on conflict' setting - Works even without Auto-Sync enabled --- .../workers/UploadFileFromFileSystemWorker.kt | 66 ++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt index 218611fd6..8a7e49994 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt @@ -32,10 +32,12 @@ import androidx.work.WorkerParameters import androidx.work.workDataOf import eu.opencloud.android.R import eu.opencloud.android.data.executeRemoteOperation +import eu.opencloud.android.data.providers.SharedPreferencesProvider import eu.opencloud.android.domain.automaticuploads.model.UploadBehavior import eu.opencloud.android.domain.capabilities.usecases.GetStoredCapabilitiesUseCase import eu.opencloud.android.domain.exceptions.LocalFileNotFoundException import eu.opencloud.android.domain.exceptions.UnauthorizedException +import eu.opencloud.android.domain.files.FileRepository import eu.opencloud.android.domain.files.usecases.CleanConflictUseCase import eu.opencloud.android.domain.files.usecases.GetFileByRemotePathUseCase import eu.opencloud.android.domain.files.usecases.GetWebDavUrlForSpaceUseCase @@ -54,6 +56,7 @@ import eu.opencloud.android.lib.resources.files.CheckPathExistenceRemoteOperatio import eu.opencloud.android.lib.resources.files.CreateRemoteFolderOperation import eu.opencloud.android.lib.resources.files.UploadFileFromFileSystemOperation import eu.opencloud.android.presentation.authentication.AccountUtils +import eu.opencloud.android.presentation.settings.security.SettingsSecurityFragment import eu.opencloud.android.utils.NotificationUtils import eu.opencloud.android.utils.RemoteFileUtils.getAvailableRemotePath import eu.opencloud.android.utils.UPLOAD_NOTIFICATION_CHANNEL_ID @@ -66,6 +69,9 @@ import org.koin.core.component.inject import timber.log.Timber import java.io.File import java.io.IOException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale import kotlin.coroutines.cancellation.CancellationException class UploadFileFromFileSystemWorker( @@ -92,6 +98,8 @@ class UploadFileFromFileSystemWorker( private val saveFileOrFolderUseCase: SaveFileOrFolderUseCase by inject() private val cleanConflictUseCase: CleanConflictUseCase by inject() private val getWebdavUrlForSpaceUseCase: GetWebDavUrlForSpaceUseCase by inject() + private val preferencesProvider: SharedPreferencesProvider by inject() + private val fileRepository: FileRepository by inject() // Etag in conflict required to overwrite files in server. Otherwise, the upload will be rejected. private var eTagInConflict: String = "" @@ -230,7 +238,40 @@ class UploadFileFromFileSystemWorker( ) ) - eTagInConflict = useCaseResult.getDataOrNull()?.etagInConflict.orEmpty() + val remoteFile = useCaseResult.getDataOrNull() + eTagInConflict = remoteFile?.etagInConflict.orEmpty() + + // Check if remote file has changed since we last synced + // If so, we have a conflict - check user preference for handling + if (remoteFile != null && remoteFile.etag != remoteFile.etagInConflict) { + val preferLocal = preferencesProvider.getBoolean( + SettingsSecurityFragment.PREFERENCE_PREFER_LOCAL_ON_CONFLICT, false + ) + + if (!preferLocal) { + // User wants conflicted copy behavior - create a local copy before uploading + Timber.i("Conflict detected and user prefers conflicted copy. Creating copy of local file.") + val conflictedCopyPath = createConflictedCopyPath(fileSystemPath) + val copied = copyLocalFile(fileSystemPath, conflictedCopyPath) + if (copied) { + Timber.i("Local file copied to conflicted copy: $conflictedCopyPath") + // Refresh parent folder so the conflicted copy appears + try { + fileRepository.refreshFolder( + remotePath = remoteFile.getParentRemotePath(), + accountName = account.name, + spaceId = remoteFile.spaceId + ) + } catch (e: Exception) { + Timber.w(e, "Failed to refresh parent folder after creating conflicted copy") + } + } else { + Timber.w("Failed to copy local file to conflicted copy") + } + } else { + Timber.i("Conflict detected but user prefers local version. Uploading will overwrite remote.") + } + } Timber.d("Upload will overwrite current server file with the following etag in conflict: $eTagInConflict") } else { @@ -249,6 +290,29 @@ class UploadFileFromFileSystemWorker( } } + private fun createConflictedCopyPath(originalPath: String): String { + val file = File(originalPath) + val nameWithoutExt = file.nameWithoutExtension + val extension = file.extension + val timestamp = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US).format(Date()) + val conflictedName = if (extension.isNotEmpty()) { + "${nameWithoutExt}_conflicted_copy_$timestamp.$extension" + } else { + "${nameWithoutExt}_conflicted_copy_$timestamp" + } + return File(file.parent, conflictedName).absolutePath + } + + private fun copyLocalFile(sourcePath: String, destPath: String): Boolean { + return try { + File(sourcePath).copyTo(File(destPath), overwrite = false) + true + } catch (e: Exception) { + Timber.e(e, "Failed to copy local file from $sourcePath to $destPath") + false + } + } + private fun uploadDocument(client: OpenCloudClient) { val getStoredCapabilitiesUseCase: GetStoredCapabilitiesUseCase by inject() val capabilitiesForAccount = getStoredCapabilitiesUseCase( From 46ef32140de027668bc3b72c3efb859907c0ff19 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Mon, 12 Jan 2026 17:20:27 +0100 Subject: [PATCH 30/50] fix: Use actual file modification time for local change detection --- .../usecases/synchronization/SynchronizeFileUseCase.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt b/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt index d1b31bc96..ef147cab7 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt @@ -78,9 +78,11 @@ class SynchronizeFileUseCase( val uuid = requestForDownload(accountName = accountName, ocFile = fileToSynchronize) SyncType.DownloadEnqueued(uuid) } else { - // 3. Check if file has changed locally - val changedLocally = fileToSynchronize.localModificationTimestamp > fileToSynchronize.lastSyncDateForData!! - Timber.i("Local file modification timestamp :${fileToSynchronize.localModificationTimestamp}" + + // 3. Check if file has changed locally by reading ACTUAL file timestamp from filesystem + val localFile = File(fileToSynchronize.storagePath!!) + val actualFileModificationTime = localFile.lastModified() + val changedLocally = actualFileModificationTime > fileToSynchronize.lastSyncDateForData!! + Timber.i("Actual file modification timestamp :$actualFileModificationTime" + " and last sync date for data :${fileToSynchronize.lastSyncDateForData}") Timber.i("So it has changed locally: $changedLocally") From 7605d359c101476680f7ad2007bbe4b579448a8c Mon Sep 17 00:00:00 2001 From: zerox80 Date: Mon, 12 Jan 2026 17:38:37 +0100 Subject: [PATCH 31/50] Fix: Remove duplicate super.onCreate call causing SavedStateRegistry crash --- .../presentation/authentication/LoginActivity.kt | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt index 093b4372e..fb7c0d356 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt @@ -141,22 +141,6 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted } - // Log OAuth redirect details for debugging (especially Firefox issues) - Timber.d("onCreate called with intent data: ${intent.data}, isTaskRoot: $isTaskRoot") - - if (intent.data != null && (intent.data?.getQueryParameter("code") != null || intent.data?.getQueryParameter("error") != null)) { - Timber.d("OAuth redirect detected with code or error parameter") - if (!isTaskRoot) { - Timber.d("Not task root, forwarding OAuth redirect to existing LoginActivity instance") - val newIntent = Intent(this, LoginActivity::class.java) - newIntent.data = intent.data - newIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) - startActivity(newIntent) - finish() - return - } - } - checkPasscodeEnforced(this) // Protection against screen recording From e546604f4f50719ae2e5c07f65660acc8bf84443 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Fri, 21 Nov 2025 09:42:41 +0100 Subject: [PATCH 32/50] Fix: Critical login state and authentication issues --- .../presentation/authentication/LoginActivity.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt index fb7c0d356..096e3a6f9 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt @@ -122,6 +122,16 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted private var pendingAuthorizationIntent: Intent? = null override fun onCreate(savedInstanceState: Bundle?) { + if (intent.data != null && (intent.data?.getQueryParameter("code") != null || intent.data?.getQueryParameter("error") != null)) { + if (!isTaskRoot) { + val newIntent = Intent(this, LoginActivity::class.java) + newIntent.data = intent.data + newIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + startActivity(newIntent) + finish() + return + } + } super.onCreate(savedInstanceState) // Log OAuth redirect details for debugging (especially Firefox issues) @@ -925,9 +935,12 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted outState.putString(KEY_SERVER_BASE_URL, serverBaseUrl) } outState.putBoolean(KEY_OIDC_SUPPORTED, oidcSupported) +<<<<<<< HEAD outState.putString(KEY_CODE_VERIFIER, authenticationViewModel.codeVerifier) outState.putString(KEY_CODE_CHALLENGE, authenticationViewModel.codeChallenge) outState.putString(KEY_OIDC_STATE, authenticationViewModel.oidcState) +======= +>>>>>>> 19b5f1a2a (Fix: Critical login state and authentication issues) } override fun finish() { From 69b1bdd2cbab734c58f10e7b777fc79cdb43a017 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Tue, 25 Nov 2025 00:13:59 +0100 Subject: [PATCH 33/50] feat: Add ClientManager for managing OpenCloud clients and providing various service instances. --- .../test/java/eu/opencloud/android/data/ClientManagerTest.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/opencloudData/src/test/java/eu/opencloud/android/data/ClientManagerTest.kt b/opencloudData/src/test/java/eu/opencloud/android/data/ClientManagerTest.kt index 8b75fb06b..3382ffb89 100644 --- a/opencloudData/src/test/java/eu/opencloud/android/data/ClientManagerTest.kt +++ b/opencloudData/src/test/java/eu/opencloud/android/data/ClientManagerTest.kt @@ -4,9 +4,11 @@ import android.accounts.AccountManager import android.content.Context import eu.opencloud.android.data.providers.SharedPreferencesProvider import eu.opencloud.android.lib.common.ConnectionValidator +import eu.opencloud.android.lib.common.authentication.OpenCloudCredentialsFactory import io.mockk.mockk import io.mockk.mockkStatic import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test From e8b201797e91ffac20449ec0ecb1d643df4ef245 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Fri, 9 Jan 2026 22:10:20 +0100 Subject: [PATCH 34/50] Fix: Save OAuth state (codeVerifier, state) in onSaveInstanceState to prevent login failure after process death --- .../android/presentation/authentication/LoginActivity.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt index 096e3a6f9..e7b40e7e8 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt @@ -935,12 +935,9 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted outState.putString(KEY_SERVER_BASE_URL, serverBaseUrl) } outState.putBoolean(KEY_OIDC_SUPPORTED, oidcSupported) -<<<<<<< HEAD outState.putString(KEY_CODE_VERIFIER, authenticationViewModel.codeVerifier) outState.putString(KEY_CODE_CHALLENGE, authenticationViewModel.codeChallenge) outState.putString(KEY_OIDC_STATE, authenticationViewModel.oidcState) -======= ->>>>>>> 19b5f1a2a (Fix: Critical login state and authentication issues) } override fun finish() { From 52e3b1799e0f7c9fee8f6b8feffd41ff9f313b43 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Fri, 9 Jan 2026 22:23:14 +0100 Subject: [PATCH 35/50] Fix: Add missing super.onCreate call in LoginActivity --- .../android/presentation/authentication/LoginActivity.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt index e7b40e7e8..6a2aa8e12 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt @@ -122,6 +122,7 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted private var pendingAuthorizationIntent: Intent? = null override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) if (intent.data != null && (intent.data?.getQueryParameter("code") != null || intent.data?.getQueryParameter("error") != null)) { if (!isTaskRoot) { val newIntent = Intent(this, LoginActivity::class.java) From 9e03276416f619f0b751206c69db139b61661dbc Mon Sep 17 00:00:00 2001 From: zerox80 Date: Sat, 10 Jan 2026 11:52:44 +0100 Subject: [PATCH 36/50] Fix Firefox OAuth redirect issue - Add FLAG_ACTIVITY_NEW_TASK to CustomTabsIntent for Firefox compatibility - Add setIntent() in onNewIntent to properly handle OAuth redirects - Add logging for OAuth redirect debugging --- .../android/presentation/authentication/LoginActivity.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt index 6a2aa8e12..540df9aa5 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt @@ -123,8 +123,14 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + // Log OAuth redirect details for debugging (especially Firefox issues) + Timber.d("onCreate called with intent data: ${intent.data}, isTaskRoot: $isTaskRoot") + if (intent.data != null && (intent.data?.getQueryParameter("code") != null || intent.data?.getQueryParameter("error") != null)) { + Timber.d("OAuth redirect detected with code or error parameter") if (!isTaskRoot) { + Timber.d("Not task root, forwarding OAuth redirect to existing LoginActivity instance") val newIntent = Intent(this, LoginActivity::class.java) newIntent.data = intent.data newIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) From 29ed686d2d3d166b6d4b692684a4096294ba75ec Mon Sep 17 00:00:00 2001 From: zerox80 Date: Sat, 10 Jan 2026 11:56:39 +0100 Subject: [PATCH 37/50] Fix detekt trailing whitespace and spacing issues --- .../android/presentation/authentication/LoginActivity.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt index 540df9aa5..7e0398b25 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt @@ -123,10 +123,10 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - + // Log OAuth redirect details for debugging (especially Firefox issues) Timber.d("onCreate called with intent data: ${intent.data}, isTaskRoot: $isTaskRoot") - + if (intent.data != null && (intent.data?.getQueryParameter("code") != null || intent.data?.getQueryParameter("error") != null)) { Timber.d("OAuth redirect detected with code or error parameter") if (!isTaskRoot) { From 4e5b2f367e7b26e631bb50507679896d8ebf39e4 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Sat, 10 Jan 2026 11:59:52 +0100 Subject: [PATCH 38/50] Fix detekt issues in ClientManagerTest: remove unused imports and trailing whitespace --- .../test/java/eu/opencloud/android/data/ClientManagerTest.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/opencloudData/src/test/java/eu/opencloud/android/data/ClientManagerTest.kt b/opencloudData/src/test/java/eu/opencloud/android/data/ClientManagerTest.kt index 3382ffb89..8b75fb06b 100644 --- a/opencloudData/src/test/java/eu/opencloud/android/data/ClientManagerTest.kt +++ b/opencloudData/src/test/java/eu/opencloud/android/data/ClientManagerTest.kt @@ -4,11 +4,9 @@ import android.accounts.AccountManager import android.content.Context import eu.opencloud.android.data.providers.SharedPreferencesProvider import eu.opencloud.android.lib.common.ConnectionValidator -import eu.opencloud.android.lib.common.authentication.OpenCloudCredentialsFactory import io.mockk.mockk import io.mockk.mockkStatic import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test From cbb2889ca2dcd785b7c4d6163917f36af6b888b6 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Mon, 12 Jan 2026 17:38:37 +0100 Subject: [PATCH 39/50] Fix: Remove duplicate super.onCreate call causing SavedStateRegistry crash --- .../android/presentation/authentication/LoginActivity.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt index 7e0398b25..a02659baf 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt @@ -139,7 +139,6 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted return } } - super.onCreate(savedInstanceState) // Log OAuth redirect details for debugging (especially Firefox issues) Timber.d("onCreate called with intent data: ${intent.data}, isTaskRoot: $isTaskRoot") From aadc90ad8efdddc695d8d0649d9e5919efc5d098 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Mon, 12 Jan 2026 17:54:58 +0100 Subject: [PATCH 40/50] Fix: Restore ThumbnailsCacheManager call unintentionally removed --- opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt b/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt index 1e6c338e4..0971bb37c 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt @@ -40,6 +40,8 @@ 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.datamodel.ThumbnailsCacheManager import eu.opencloud.android.db.PreferenceManager import eu.opencloud.android.dependecyinjection.commonModule import eu.opencloud.android.dependecyinjection.localDataSourceModule @@ -116,6 +118,9 @@ class MainApp : Application() { SingleSessionManager.setUserAgent(userAgent) + // initialise thumbnails cache on background thread + ThumbnailsCacheManager.InitDiskCacheTask().execute() + initDependencyInjection() // register global protection with pass code, pattern lock and biometric lock From 366314d203e2a5fe99e0ed04f07034544265fe27 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Mon, 12 Jan 2026 17:55:14 +0100 Subject: [PATCH 41/50] Fix: Remove duplicate import --- opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt b/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt index 0971bb37c..4897b67c8 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt @@ -40,7 +40,6 @@ 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.datamodel.ThumbnailsCacheManager import eu.opencloud.android.db.PreferenceManager import eu.opencloud.android.dependecyinjection.commonModule From eab9a7377aa2e6562e58998a5ea3aab3b0abf458 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Mon, 12 Jan 2026 18:02:57 +0100 Subject: [PATCH 42/50] Fix: Add missing ThumbnailsCacheManager.java to dev-new --- .../datamodel/ThumbnailsCacheManager.java | 473 ++++++++++++++++++ 1 file changed, 473 insertions(+) create mode 100644 opencloudApp/src/main/java/eu/opencloud/android/datamodel/ThumbnailsCacheManager.java diff --git a/opencloudApp/src/main/java/eu/opencloud/android/datamodel/ThumbnailsCacheManager.java b/opencloudApp/src/main/java/eu/opencloud/android/datamodel/ThumbnailsCacheManager.java new file mode 100644 index 000000000..d4147ce3e --- /dev/null +++ b/opencloudApp/src/main/java/eu/opencloud/android/datamodel/ThumbnailsCacheManager.java @@ -0,0 +1,473 @@ +/** + * 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(); + } + } +} From 6f64920369a2c2a0d6a2e7d713d97e57258bce20 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Mon, 12 Jan 2026 18:04:22 +0100 Subject: [PATCH 43/50] Fix: Restore missing DiskLruImageCache.java needed by ThumbnailsCacheManager --- .../android/ui/adapter/DiskLruImageCache.java | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 opencloudApp/src/main/java/eu/opencloud/android/ui/adapter/DiskLruImageCache.java 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 new file mode 100644 index 000000000..a823281d3 --- /dev/null +++ b/opencloudApp/src/main/java/eu/opencloud/android/ui/adapter/DiskLruImageCache.java @@ -0,0 +1,147 @@ +/* + * 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); + } + } +} From 435adc603e37ecf9b8825eefcf5292a270ba1cc0 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Fri, 9 Jan 2026 19:27:38 +0100 Subject: [PATCH 44/50] feat: use external storage with human-readable folder names - Switch to External Storage Directory in ScopedStorageProvider. - Request MANAGE_EXTERNAL_STORAGE permission on Android R+. - Use Space names instead of IDs for folder structure. - Rename data folder to OpenCloud. --- .../opencloud/android/ui/activity/FileDisplayActivity.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt index b2dfd5706..7b5f6c760 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt @@ -294,14 +294,23 @@ class FileDisplayActivity : FileActivity(), if (!android.os.Environment.isExternalStorageManager()) { val builder = AlertDialog.Builder(this) builder.setTitle(getString(R.string.app_name)) +<<<<<<< HEAD builder.setMessage("To save offline files, the app needs access to all files.") builder.setPositiveButton("Settings") { _, _ -> +======= + builder.setMessage("Um Offline-Dateien öffentlich speichern zu können, benötigt die App Zugriff auf alle Dateien.") + builder.setPositiveButton("Einstellungen") { _, _ -> +>>>>>>> 6cb7aa1da (feat: use external storage with human-readable folder names) val intent = Intent(android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) intent.addCategory("android.intent.category.DEFAULT") intent.data = Uri.parse("package:$packageName") startActivity(intent) } +<<<<<<< HEAD builder.setNegativeButton("Cancel", null) +======= + builder.setNegativeButton("Abbrechen", null) +>>>>>>> 6cb7aa1da (feat: use external storage with human-readable folder names) builder.show() } } From 780ff3b4fa31213b28e18089617a1fd4dea4ce38 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Fri, 9 Jan 2026 19:56:00 +0100 Subject: [PATCH 45/50] Sync Option + Download all files --- .../settings/security/SettingsSecurityFragment.kt | 7 ++++++- opencloudApp/src/main/res/values/strings.xml | 1 - opencloudApp/src/main/res/xml/settings_security.xml | 1 - 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityFragment.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityFragment.kt index 056545b27..c1408a52d 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityFragment.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityFragment.kt @@ -61,7 +61,10 @@ class SettingsSecurityFragment : PreferenceFragmentCompat() { private var prefTouchesWithOtherVisibleWindows: CheckBoxPreference? = null private var prefDownloadEverything: CheckBoxPreference? = null private var prefAutoSync: CheckBoxPreference? = null +<<<<<<< HEAD private var prefPreferLocalOnConflict: CheckBoxPreference? = null +======= +>>>>>>> aa6841e77 (Sync Option + Download all files) private val enablePasscodeLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> @@ -140,7 +143,10 @@ class SettingsSecurityFragment : PreferenceFragmentCompat() { prefTouchesWithOtherVisibleWindows = findPreference(PREFERENCE_TOUCHES_WITH_OTHER_VISIBLE_WINDOWS) prefDownloadEverything = findPreference(PREFERENCE_DOWNLOAD_EVERYTHING) prefAutoSync = findPreference(PREFERENCE_AUTO_SYNC) +<<<<<<< HEAD prefPreferLocalOnConflict = findPreference(PREFERENCE_PREFER_LOCAL_ON_CONFLICT) +======= +>>>>>>> aa6841e77 (Sync Option + Download all files) prefPasscode?.isVisible = !securityViewModel.isSecurityEnforcedEnabled() prefPattern?.isVisible = !securityViewModel.isSecurityEnforcedEnabled() @@ -279,7 +285,6 @@ class SettingsSecurityFragment : PreferenceFragmentCompat() { true } } - // Conflict Resolution Strategy prefPreferLocalOnConflict?.setOnPreferenceChangeListener { _: Preference?, newValue: Any -> securityViewModel.setPreferLocalOnConflict(newValue as Boolean) diff --git a/opencloudApp/src/main/res/values/strings.xml b/opencloudApp/src/main/res/values/strings.xml index 57df15b6a..6eccb4f69 100644 --- a/opencloudApp/src/main/res/values/strings.xml +++ b/opencloudApp/src/main/res/values/strings.xml @@ -869,5 +869,4 @@ Prefer local version on conflict When a file is modified both locally and on server, upload local version instead of creating a conflicted copy - diff --git a/opencloudApp/src/main/res/xml/settings_security.xml b/opencloudApp/src/main/res/xml/settings_security.xml index 3e2888145..f77092c24 100644 --- a/opencloudApp/src/main/res/xml/settings_security.xml +++ b/opencloudApp/src/main/res/xml/settings_security.xml @@ -69,5 +69,4 @@ app:key="prefer_local_on_conflict" app:summary="@string/prefs_prefer_local_on_conflict_summary" app:title="@string/prefs_prefer_local_on_conflict" /> - \ No newline at end of file From 047681c076e90d45abc5d78d3e7fcffd6a03bd8f Mon Sep 17 00:00:00 2001 From: zerox80 Date: Fri, 9 Jan 2026 20:55:18 +0100 Subject: [PATCH 46/50] fix(i18n): Change German storage permission dialog to English --- .../opencloud/android/ui/activity/FileDisplayActivity.kt | 9 --------- 1 file changed, 9 deletions(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt index 7b5f6c760..b2dfd5706 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt @@ -294,23 +294,14 @@ class FileDisplayActivity : FileActivity(), if (!android.os.Environment.isExternalStorageManager()) { val builder = AlertDialog.Builder(this) builder.setTitle(getString(R.string.app_name)) -<<<<<<< HEAD builder.setMessage("To save offline files, the app needs access to all files.") builder.setPositiveButton("Settings") { _, _ -> -======= - builder.setMessage("Um Offline-Dateien öffentlich speichern zu können, benötigt die App Zugriff auf alle Dateien.") - builder.setPositiveButton("Einstellungen") { _, _ -> ->>>>>>> 6cb7aa1da (feat: use external storage with human-readable folder names) val intent = Intent(android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) intent.addCategory("android.intent.category.DEFAULT") intent.data = Uri.parse("package:$packageName") startActivity(intent) } -<<<<<<< HEAD builder.setNegativeButton("Cancel", null) -======= - builder.setNegativeButton("Abbrechen", null) ->>>>>>> 6cb7aa1da (feat: use external storage with human-readable folder names) builder.show() } } From 85554c62aeb1927aeb3c8138b2798714b4999843 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Mon, 12 Jan 2026 16:46:00 +0100 Subject: [PATCH 47/50] feat: Auto-resolve sync conflicts with conflicted copies - When a file is modified both locally and remotely, create a conflicted copy of the local file and download the remote version. This ensures no data loss, matching desktop client behavior. --- .../android/usecases/synchronization/SynchronizeFileUseCase.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt b/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt index ef147cab7..538f6a45f 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFileUseCase.kt @@ -14,7 +14,7 @@ * 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 . */ From beb469019f310f42105fbe6e19a688ede0d15d6e Mon Sep 17 00:00:00 2001 From: zerox80 Date: Mon, 12 Jan 2026 18:52:15 +0100 Subject: [PATCH 48/50] Fix: Remove duplicate pendingAuthorizationIntent declaration from merge --- .../android/presentation/authentication/LoginActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt index a02659baf..a03dfd06a 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt @@ -119,7 +119,7 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted // For handling AbstractAccountAuthenticator responses private var accountAuthenticatorResponse: AccountAuthenticatorResponse? = null private var resultBundle: Bundle? = null - private var pendingAuthorizationIntent: Intent? = null + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) From aa10ad60c1f60d479636808f155e9e1c48bad8d1 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Mon, 19 Jan 2026 13:24:49 +0100 Subject: [PATCH 49/50] Refactor thumbnail caching: optimize sizes, fix thread safety, remove dead code --- .../main/java/eu/opencloud/android/MainApp.kt | 5 +- .../datamodel/ThumbnailsCacheManager.java | 473 ------------------ .../presentation/avatar/AvatarUtils.kt | 2 +- .../files/details/FileDetailsFragment.kt | 4 +- .../thumbnails/ThumbnailsRequester.kt | 45 +- .../android/ui/adapter/DiskLruImageCache.java | 147 ------ .../adapter/ReceiveExternalFilesAdapter.java | 20 +- 7 files changed, 34 insertions(+), 662 deletions(-) delete mode 100644 opencloudApp/src/main/java/eu/opencloud/android/datamodel/ThumbnailsCacheManager.java 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 4897b67c8..4f8c64c26 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt @@ -40,7 +40,7 @@ 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 @@ -117,8 +117,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/presentation/avatar/AvatarUtils.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/avatar/AvatarUtils.kt index 1c9afd6da..0cc24df8a 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 @@ -33,7 +33,7 @@ class AvatarUtils : KoinComponent { /** * Show the avatar corresponding to the received account in an {@ImageView}. *

- * The avatar is shown if available locally in {@link ThumbnailsCacheManager}. The avatar is not + * The avatar is shown if available locally. The avatar is not * fetched from the server if not available. *

* If there is no avatar stored, a colored icon is generated with the first letter of the account username. 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 84294bac4..f4a34bcb7 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 @@ -431,7 +431,9 @@ class FileDetailsFragment : FileFragment() { imageView.load( ThumbnailsRequester.getPreviewUriForFile( OCFileWithSyncInfo(ocFile, null), - fileDetailsViewModel.getAccount() + fileDetailsViewModel.getAccount(), + 1024, + 1024 ), ThumbnailsRequester.getCoilImageLoader(fileDetailsViewModel.getAccount()) ) { 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 2d00b24e9..f38318e73 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 @@ -58,26 +58,17 @@ object ThumbnailsRequester : KoinComponent { private const val DISK_CACHE_SIZE: Long = 1024 * 1024 * 100 // 100MB private val imageLoaders = ConcurrentHashMap() - private var sharedDiskCache: DiskCache? = null - private var sharedMemoryCache: MemoryCache? = null - - private fun getSharedDiskCache(): DiskCache { - if (sharedDiskCache == null) { - sharedDiskCache = DiskCache.Builder() - .directory(appContext.cacheDir.resolve("thumbnails_coil_cache")) - .maxSizeBytes(DISK_CACHE_SIZE) - .build() - } - return sharedDiskCache!! + private val sharedDiskCache: DiskCache by lazy { + DiskCache.Builder() + .directory(appContext.cacheDir.resolve("thumbnails_coil_cache")) + .maxSizeBytes(DISK_CACHE_SIZE) + .build() } - private fun getSharedMemoryCache(): MemoryCache { - if (sharedMemoryCache == null) { - sharedMemoryCache = MemoryCache.Builder(appContext) - .maxSizePercent(0.25) - .build() - } - return sharedMemoryCache!! + private val sharedMemoryCache: MemoryCache by lazy { + MemoryCache.Builder(appContext) + .maxSizePercent(0.25) + .build() } fun getAvatarUri(account: Account): String { @@ -90,22 +81,24 @@ object ThumbnailsRequester : KoinComponent { return "$baseUrl/index.php/avatar/${android.net.Uri.encode(username)}/384" } - fun getPreviewUriForFile(file: OCFile, account: Account, etag: String? = null): String = - getPreviewUri(file.remotePath, etag ?: file.etag, account) + fun getPreviewUriForFile(file: OCFile, account: Account, etag: String? = null, width: Int = 1024, height: Int = 1024): String = + getPreviewUri(file.remotePath, etag ?: file.etag, account, width, height) - fun getPreviewUriForFile(fileWithSyncInfo: OCFileWithSyncInfo, account: Account): String = - getPreviewUriForFile(fileWithSyncInfo.file, account) + fun getPreviewUriForFile(fileWithSyncInfo: OCFileWithSyncInfo, account: Account, width: Int = 1024, height: Int = 1024): String = + getPreviewUriForFile(fileWithSyncInfo.file, account, null, width, height) 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 { + private fun getPreviewUri(remotePath: String?, etag: String?, account: Account, width: Int, height: Int): String { val accountManager = AccountManager.get(appContext) val baseUrl = accountManager.getUserData(account, eu.opencloud.android.lib.common.accounts.AccountUtils.Constants.KEY_OC_BASE_URL) + ?.trimEnd('/') + .orEmpty() 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) + return String.format(Locale.US, FILE_PREVIEW_URI, baseUrl, encodedPath, width, height, etag) } fun getCoilImageLoader(): ImageLoader { @@ -128,10 +121,10 @@ object ThumbnailsRequester : KoinComponent { .addInterceptor(coilRequestHeaderInterceptor).build() ).logger(DebugLogger()) .memoryCache { - getSharedMemoryCache() + sharedMemoryCache } .diskCache { - getSharedDiskCache() + sharedDiskCache } .build() } 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 a295f3405..ebc71585c 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 @@ -62,9 +62,9 @@ public class ReceiveExternalFilesAdapter extends BaseAdapter implements ListAdap private Boolean mShowHiddenFiles; public ReceiveExternalFilesAdapter(Context context, - FileDataStorageManager storageManager, - Account account, - boolean showHiddenFiles) { + FileDataStorageManager storageManager, + Account account, + boolean showHiddenFiles) { mStorageManager = storageManager; mContext = context; mInflater = (LayoutInflater) mContext @@ -123,8 +123,7 @@ public View getView(int position, View convertView, ViewGroup parent) { // Allow or disallow touches with other visible windows vi.setFilterTouchesWhenObscured( - PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(mContext) - ); + PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(mContext)); } OCFile file = mFiles.get(position); @@ -147,7 +146,7 @@ public View getView(int position, View convertView, ViewGroup parent) { // get Thumbnail if file is image if (file.isImage() && file.getRemoteId() != null) { - String uri = ThumbnailsRequester.INSTANCE.getPreviewUriForFile(file, mAccount, null); + String uri = ThumbnailsRequester.INSTANCE.getPreviewUriForFile(file, mAccount, null, 384, 384); ImageLoader imageLoader = ThumbnailsRequester.INSTANCE.getCoilImageLoader(mAccount); coil.request.ImageRequest request = new coil.request.ImageRequest.Builder(mContext) .data(uri) @@ -165,8 +164,7 @@ public View getView(int position, View convertView, ViewGroup parent) { .build(); imageLoader.enqueue(request); fileIcon.setImageResource( - MimetypeIconUtil.getFileTypeIconId(file.getMimeType(), file.getFileName()) - ); + MimetypeIconUtil.getFileTypeIconId(file.getMimeType(), file.getFileName())); } return vi; } @@ -180,8 +178,7 @@ public void setSortOrder(Integer order, boolean isAscending) { new SortFilesUtils().sortFiles( (Vector) mFiles, FileStorageUtils.mSortOrderFileDisp, - FileStorageUtils.mSortAscendingFileDisp - ); + FileStorageUtils.mSortAscendingFileDisp); } notifyDataSetChanged(); } @@ -194,7 +191,8 @@ public void filterBySearch(String query) { mOnSearchQueryUpdateListener.updateEmptyListMessage( mContext.getString(R.string.local_file_list_search_with_no_matches)); } else { - mOnSearchQueryUpdateListener.updateEmptyListMessage(mContext.getString(R.string.file_list_empty_title_all_files)); + mOnSearchQueryUpdateListener + .updateEmptyListMessage(mContext.getString(R.string.file_list_empty_title_all_files)); } notifyDataSetChanged(); From 4f01521b4c99200109e84590d114c7b4bd32917d Mon Sep 17 00:00:00 2001 From: zerox80 Date: Mon, 19 Jan 2026 13:24:49 +0100 Subject: [PATCH 50/50] Refactor thumbnail caching: optimize sizes, fix thread safety, remove dead code --- .../main/java/eu/opencloud/android/MainApp.kt | 1 + .../presentation/avatar/AvatarUtils.kt | 2 +- .../files/details/FileDetailsFragment.kt | 4 +- .../thumbnails/ThumbnailsRequester.kt | 45 ++++++++----------- .../adapter/ReceiveExternalFilesAdapter.java | 20 ++++----- 5 files changed, 33 insertions(+), 39 deletions(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt b/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt index 2d27cb6b5..4f8c64c26 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt @@ -40,6 +40,7 @@ import androidx.appcompat.app.AlertDialog import androidx.core.content.pm.PackageInfoCompat import eu.opencloud.android.data.providers.implementation.OCSharedPreferencesProvider + import eu.opencloud.android.db.PreferenceManager import eu.opencloud.android.dependecyinjection.commonModule import eu.opencloud.android.dependecyinjection.localDataSourceModule 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 1c9afd6da..0cc24df8a 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 @@ -33,7 +33,7 @@ class AvatarUtils : KoinComponent { /** * Show the avatar corresponding to the received account in an {@ImageView}. *

- * The avatar is shown if available locally in {@link ThumbnailsCacheManager}. The avatar is not + * The avatar is shown if available locally. The avatar is not * fetched from the server if not available. *

* If there is no avatar stored, a colored icon is generated with the first letter of the account username. 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 49e58c90c..7ffff9fd3 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 @@ -433,7 +433,9 @@ class FileDetailsFragment : FileFragment() { imageView.load( ThumbnailsRequester.getPreviewUriForFile( OCFileWithSyncInfo(ocFile, null), - fileDetailsViewModel.getAccount() + fileDetailsViewModel.getAccount(), + 1024, + 1024 ), ThumbnailsRequester.getCoilImageLoader(fileDetailsViewModel.getAccount()) ) { 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 2d00b24e9..f38318e73 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 @@ -58,26 +58,17 @@ object ThumbnailsRequester : KoinComponent { private const val DISK_CACHE_SIZE: Long = 1024 * 1024 * 100 // 100MB private val imageLoaders = ConcurrentHashMap() - private var sharedDiskCache: DiskCache? = null - private var sharedMemoryCache: MemoryCache? = null - - private fun getSharedDiskCache(): DiskCache { - if (sharedDiskCache == null) { - sharedDiskCache = DiskCache.Builder() - .directory(appContext.cacheDir.resolve("thumbnails_coil_cache")) - .maxSizeBytes(DISK_CACHE_SIZE) - .build() - } - return sharedDiskCache!! + private val sharedDiskCache: DiskCache by lazy { + DiskCache.Builder() + .directory(appContext.cacheDir.resolve("thumbnails_coil_cache")) + .maxSizeBytes(DISK_CACHE_SIZE) + .build() } - private fun getSharedMemoryCache(): MemoryCache { - if (sharedMemoryCache == null) { - sharedMemoryCache = MemoryCache.Builder(appContext) - .maxSizePercent(0.25) - .build() - } - return sharedMemoryCache!! + private val sharedMemoryCache: MemoryCache by lazy { + MemoryCache.Builder(appContext) + .maxSizePercent(0.25) + .build() } fun getAvatarUri(account: Account): String { @@ -90,22 +81,24 @@ object ThumbnailsRequester : KoinComponent { return "$baseUrl/index.php/avatar/${android.net.Uri.encode(username)}/384" } - fun getPreviewUriForFile(file: OCFile, account: Account, etag: String? = null): String = - getPreviewUri(file.remotePath, etag ?: file.etag, account) + fun getPreviewUriForFile(file: OCFile, account: Account, etag: String? = null, width: Int = 1024, height: Int = 1024): String = + getPreviewUri(file.remotePath, etag ?: file.etag, account, width, height) - fun getPreviewUriForFile(fileWithSyncInfo: OCFileWithSyncInfo, account: Account): String = - getPreviewUriForFile(fileWithSyncInfo.file, account) + fun getPreviewUriForFile(fileWithSyncInfo: OCFileWithSyncInfo, account: Account, width: Int = 1024, height: Int = 1024): String = + getPreviewUriForFile(fileWithSyncInfo.file, account, null, width, height) 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 { + private fun getPreviewUri(remotePath: String?, etag: String?, account: Account, width: Int, height: Int): String { val accountManager = AccountManager.get(appContext) val baseUrl = accountManager.getUserData(account, eu.opencloud.android.lib.common.accounts.AccountUtils.Constants.KEY_OC_BASE_URL) + ?.trimEnd('/') + .orEmpty() 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) + return String.format(Locale.US, FILE_PREVIEW_URI, baseUrl, encodedPath, width, height, etag) } fun getCoilImageLoader(): ImageLoader { @@ -128,10 +121,10 @@ object ThumbnailsRequester : KoinComponent { .addInterceptor(coilRequestHeaderInterceptor).build() ).logger(DebugLogger()) .memoryCache { - getSharedMemoryCache() + sharedMemoryCache } .diskCache { - getSharedDiskCache() + sharedDiskCache } .build() } 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 a295f3405..ebc71585c 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 @@ -62,9 +62,9 @@ public class ReceiveExternalFilesAdapter extends BaseAdapter implements ListAdap private Boolean mShowHiddenFiles; public ReceiveExternalFilesAdapter(Context context, - FileDataStorageManager storageManager, - Account account, - boolean showHiddenFiles) { + FileDataStorageManager storageManager, + Account account, + boolean showHiddenFiles) { mStorageManager = storageManager; mContext = context; mInflater = (LayoutInflater) mContext @@ -123,8 +123,7 @@ public View getView(int position, View convertView, ViewGroup parent) { // Allow or disallow touches with other visible windows vi.setFilterTouchesWhenObscured( - PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(mContext) - ); + PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(mContext)); } OCFile file = mFiles.get(position); @@ -147,7 +146,7 @@ public View getView(int position, View convertView, ViewGroup parent) { // get Thumbnail if file is image if (file.isImage() && file.getRemoteId() != null) { - String uri = ThumbnailsRequester.INSTANCE.getPreviewUriForFile(file, mAccount, null); + String uri = ThumbnailsRequester.INSTANCE.getPreviewUriForFile(file, mAccount, null, 384, 384); ImageLoader imageLoader = ThumbnailsRequester.INSTANCE.getCoilImageLoader(mAccount); coil.request.ImageRequest request = new coil.request.ImageRequest.Builder(mContext) .data(uri) @@ -165,8 +164,7 @@ public View getView(int position, View convertView, ViewGroup parent) { .build(); imageLoader.enqueue(request); fileIcon.setImageResource( - MimetypeIconUtil.getFileTypeIconId(file.getMimeType(), file.getFileName()) - ); + MimetypeIconUtil.getFileTypeIconId(file.getMimeType(), file.getFileName())); } return vi; } @@ -180,8 +178,7 @@ public void setSortOrder(Integer order, boolean isAscending) { new SortFilesUtils().sortFiles( (Vector) mFiles, FileStorageUtils.mSortOrderFileDisp, - FileStorageUtils.mSortAscendingFileDisp - ); + FileStorageUtils.mSortAscendingFileDisp); } notifyDataSetChanged(); } @@ -194,7 +191,8 @@ public void filterBySearch(String query) { mOnSearchQueryUpdateListener.updateEmptyListMessage( mContext.getString(R.string.local_file_list_search_with_no_matches)); } else { - mOnSearchQueryUpdateListener.updateEmptyListMessage(mContext.getString(R.string.file_list_empty_title_all_files)); + mOnSearchQueryUpdateListener + .updateEmptyListMessage(mContext.getString(R.string.file_list_empty_title_all_files)); } notifyDataSetChanged();