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 300dbe41e..e2f8d625d 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 @@ -23,6 +23,7 @@ package eu.opencloud.android.presentation.thumbnails import android.accounts.Account import android.accounts.AccountManager import android.net.Uri +import androidx.annotation.VisibleForTesting import coil.ImageLoader import coil.disk.DiskCache import coil.memory.MemoryCache @@ -30,7 +31,6 @@ import coil.util.DebugLogger import eu.opencloud.android.MainApp.Companion.appContext import eu.opencloud.android.data.ClientManager import eu.opencloud.android.data.providers.SharedPreferencesProvider -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 @@ -50,6 +50,7 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject import timber.log.Timber import java.util.Locale +import java.util.concurrent.ConcurrentHashMap object ThumbnailsRequester : KoinComponent { private val clientManager: ClientManager by inject() @@ -57,7 +58,7 @@ object ThumbnailsRequester : KoinComponent { // https://docs.opencloud.eu/docs/next/dev/server/services/thumbnails/information/#thumbnail-query-string-parameters 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/webdav%s?x=%d&y=%d&c=%s&preview=1" + private const val FILE_PREVIEW_URI = "%s%s?x=%d&y=%d&c=%s&preview=1" private const val THUMBNAIL_DISK_CACHE_SIZE: Long = 1024 * 1024 * 100 // 100MB private const val AVATAR_HTTP_CACHE_SIZE: Long = 10L * 1024 * 1024 // 10MB @@ -99,25 +100,43 @@ object ThumbnailsRequester : KoinComponent { } fun getPreviewUriForFile(file: OCFile, account: Account, etag: String? = null, width: Int = 1024, height: Int = 1024): String = - getPreviewUri(file.remotePath, etag ?: file.remoteEtag, account, width, height) + getPreviewUri(file, null, etag ?: file.remoteEtag, account, width, height) fun getPreviewUriForFile(fileWithSyncInfo: OCFileWithSyncInfo, account: Account, width: Int = 1024, height: Int = 1024): String = - getPreviewUriForFile(fileWithSyncInfo.file, account, null, width, height) + getPreviewUri(fileWithSyncInfo.file, fileWithSyncInfo.space?.root?.webDavUrl, fileWithSyncInfo.file.remoteEtag, account, 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, width: Int, height: Int): String { + private fun getPreviewUri(file: OCFile, spaceWebDavUrl: String?, etag: String?, account: Account, width: Int, height: Int): String { val baseUrl = accountBaseUrls.getOrPut(account.name) { val accountManager = AccountManager.get(appContext) accountManager.getUserData(account, eu.opencloud.android.lib.common.accounts.AccountUtils.Constants.KEY_OC_BASE_URL) ?.trimEnd('/') .orEmpty() } + return buildPreviewUri(baseUrl, file.remotePath, file.spaceId, spaceWebDavUrl, etag, width, height) + } + + @VisibleForTesting + internal fun buildPreviewUri( + accountBaseUrl: String, + remotePath: String?, + spaceId: String?, + spaceWebDavUrl: String?, + etag: String?, + width: Int, + height: Int, + ): String { + val previewBaseUrl = when { + !spaceWebDavUrl.isNullOrBlank() -> spaceWebDavUrl.trimEnd('/') + !spaceId.isNullOrBlank() -> "${accountBaseUrl.trimEnd('/')}/dav/spaces/${Uri.encode(spaceId, "\$")}" + else -> "${accountBaseUrl.trimEnd('/')}/webdav" + } val path = if (remotePath?.startsWith("/") == true) remotePath else "/$remotePath" val encodedPath = Uri.encode(path, "/") - return String.format(Locale.US, FILE_PREVIEW_URI, baseUrl, encodedPath, width, height, etag.orEmpty()) + return String.format(Locale.US, FILE_PREVIEW_URI, previewBaseUrl, encodedPath, width, height, etag.orEmpty()) } fun getContentAddressedImageLoader(): ImageLoader { diff --git a/opencloudApp/src/test/java/eu/opencloud/android/presentation/thumbnails/ThumbnailsRequesterTest.kt b/opencloudApp/src/test/java/eu/opencloud/android/presentation/thumbnails/ThumbnailsRequesterTest.kt new file mode 100644 index 000000000..ac869b96b --- /dev/null +++ b/opencloudApp/src/test/java/eu/opencloud/android/presentation/thumbnails/ThumbnailsRequesterTest.kt @@ -0,0 +1,119 @@ +/** + * openCloud Android client application + * + * Copyright (C) 2026 opencloud. + * + * 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.thumbnails + +import android.net.Uri +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class ThumbnailsRequesterTest { + + @Before + fun setUp() { + mockkStatic(Uri::class) + every { Uri.encode(any(), any()) } answers { + encodeSpaces(firstArg()) + } + } + + @After + fun tearDown() { + unmockkStatic(Uri::class) + } + + @Test + fun `preview uri for personal file uses legacy webdav path`() { + val uri = ThumbnailsRequester.buildPreviewUri( + accountBaseUrl = "https://server.url/", + remotePath = "/Photos/image.jpg", + spaceId = null, + spaceWebDavUrl = null, + etag = "etag", + width = 1024, + height = 768, + ) + + assertEquals( + "https://server.url/webdav/Photos/image.jpg?x=1024&y=768&c=etag&preview=1", + uri + ) + } + + @Test + fun `preview uri for space file uses space webdav url from sync info`() { + val uri = ThumbnailsRequester.buildPreviewUri( + accountBaseUrl = "https://server.url", + remotePath = "/MyFolder/test.jpg", + spaceId = "ignored-space-id", + spaceWebDavUrl = "https://server.url/dav/spaces/space-id\$opaque/", + etag = "space-etag", + width = 512, + height = 512, + ) + + assertEquals( + "https://server.url/dav/spaces/space-id\$opaque/MyFolder/test.jpg?x=512&y=512&c=space-etag&preview=1", + uri + ) + } + + @Test + fun `preview uri for space file falls back to account base url and space id`() { + val uri = ThumbnailsRequester.buildPreviewUri( + accountBaseUrl = "https://server.url/", + remotePath = "/MyFolder/test.jpg", + spaceId = "space-id\$opaque", + spaceWebDavUrl = null, + etag = "space-etag", + width = 256, + height = 256, + ) + + assertEquals( + "https://server.url/dav/spaces/space-id\$opaque/MyFolder/test.jpg?x=256&y=256&c=space-etag&preview=1", + uri + ) + } + + @Test + fun `preview uri preserves subfolders and encodes spaces`() { + val uri = ThumbnailsRequester.buildPreviewUri( + accountBaseUrl = "https://server.url", + remotePath = "/My Folder/test image.jpg", + spaceId = "space-id\$opaque", + spaceWebDavUrl = "https://server.url/dav/spaces/space-id\$opaque", + etag = null, + width = 1024, + height = 1024, + ) + + assertEquals( + "https://server.url/dav/spaces/space-id\$opaque/My%20Folder/test%20image.jpg?x=1024&y=1024&c=&preview=1", + uri + ) + } + + private fun encodeSpaces(value: String): String = + value.replace(" ", "%20") +}