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")
+}