@@ -41,6 +41,7 @@ import eu.opencloud.android.lib.common.http.HttpConstants.OC_X_REQUEST_ID
4141import eu.opencloud.android.lib.common.http.HttpConstants.USER_AGENT_HEADER
4242import eu.opencloud.android.lib.common.utils.RandomUtils
4343import eu.opencloud.android.presentation.authentication.AccountUtils
44+ import okhttp3.Cache
4445import okhttp3.Headers.Companion.toHeaders
4546import okhttp3.Interceptor
4647import okhttp3.Response
@@ -52,16 +53,20 @@ import java.util.Locale
5253object ThumbnailsRequester : KoinComponent {
5354 private val clientManager: ClientManager by inject()
5455
56+ // https://docs.opencloud.eu/docs/next/dev/server/services/thumbnails/information/#thumbnail-query-string-parameters
5557 private const val SPACE_SPECIAL_PREVIEW_URI = " %s?scalingup=0&a=1&x=%d&y=%d&c=%s&preview=1"
5658 private const val FILE_PREVIEW_URI = " %s/webdav%s?x=%d&y=%d&c=%s&preview=1"
5759
58- private const val DISK_CACHE_SIZE : Long = 1024 * 1024 * 100 // 100MB
60+ private const val THUMBNAIL_DISK_CACHE_SIZE : Long = 1024 * 1024 * 100 // 100MB
61+ private const val AVATAR_HTTP_CACHE_SIZE : Long = 10L * 1024 * 1024 // 10MB
62+
63+ private val thumbnailImageLoaders = ConcurrentHashMap <String , ImageLoader >()
64+ private val avatarImageLoaders = ConcurrentHashMap <String , ImageLoader >()
5965
60- private val imageLoaders = ConcurrentHashMap <String , ImageLoader >()
6166 private val sharedDiskCache: DiskCache by lazy {
6267 DiskCache .Builder ()
6368 .directory(appContext.cacheDir.resolve(" thumbnails_coil_cache" ))
64- .maxSizeBytes(DISK_CACHE_SIZE )
69+ .maxSizeBytes(THUMBNAIL_DISK_CACHE_SIZE )
6570 .build()
6671 }
6772
@@ -71,13 +76,22 @@ object ThumbnailsRequester : KoinComponent {
7176 .build()
7277 }
7378
79+ // OkHttp's built-in HTTP cache for avatar responses. Unlike Coil's disk cache,
80+ // OkHttp's cache natively serves stale responses when the network is unavailable
81+ // (when must-revalidate is not set), giving us offline fallback for avatars.
82+ private val avatarHttpCache: Cache by lazy {
83+ Cache (appContext.cacheDir.resolve(" avatar_http_cache" ), AVATAR_HTTP_CACHE_SIZE )
84+ }
85+
7486 fun getAvatarUri (account : Account ): String {
7587 val accountManager = AccountManager .get(appContext)
7688 val baseUrl =
7789 accountManager.getUserData(account, eu.opencloud.android.lib.common.accounts.AccountUtils .Constants .KEY_OC_BASE_URL )
7890 ?.trimEnd(' /' )
7991 .orEmpty()
80- return " $baseUrl /graph/v1.0/me/photo/\$ value"
92+ // ?u= disambiguates the Coil cache key per account; without it two accounts
93+ // on the same server share the same URL and collide in the shared disk/memory cache.
94+ return " $baseUrl /graph/v1.0/me/photo/\$ value?u=${account.name.hashCode().toString(16 )} "
8195 }
8296
8397 fun getPreviewUriForFile (file : OCFile , account : Account , etag : String? = null, width : Int = 1024, height : Int = 1024): String =
@@ -100,33 +114,69 @@ object ThumbnailsRequester : KoinComponent {
100114 return String .format(Locale .US , FILE_PREVIEW_URI , baseUrl, encodedPath, width, height, etag.orEmpty())
101115 }
102116
103- fun getCoilImageLoader (): ImageLoader {
117+ fun getContentAddressedImageLoader (): ImageLoader {
104118 val account = AccountUtils .getCurrentOpenCloudAccount(appContext)
105- return getCoilImageLoader (account)
119+ return getContentAddressedImageLoader (account)
106120 }
107121
108- fun getCoilImageLoader (account : Account ): ImageLoader {
109- val accountName = account.name
110- return imageLoaders.getOrPut(accountName) {
111- val openCloudClient = clientManager.getClientForCoilThumbnails(accountName)
122+ /* *
123+ * Thumbnail URLs are content-addressed: the file etag is baked into the URL, so the
124+ * URL changes when the file changes. The disk cache entry is therefore always valid
125+ * and can be served offline without server revalidation.
126+ *
127+ * Uses Coil's disk cache with respectCacheHeaders(false).
128+ */
129+ fun getContentAddressedImageLoader (account : Account ): ImageLoader =
130+ thumbnailImageLoaders.getOrPut(account.name) {
131+ buildThumbnailImageLoader(account)
132+ }
133+
134+ /* *
135+ * Avatar URLs are NOT content-addressed: the URL (/graph/v1.0/me/photo/$value) is
136+ * fixed regardless of whether the user changes their profile picture. We need server
137+ * revalidation so the app picks up avatar changes, but we also need offline fallback.
138+ *
139+ * Coil's disk cache cannot serve stale entries on network error, so we use OkHttp's
140+ * built-in HTTP cache instead (which does). Coil still handles decoding, memory cache,
141+ * and transformations (CircleCrop etc.).
142+ */
143+ fun getRevalidatingImageLoader (account : Account ): ImageLoader =
144+ avatarImageLoaders.getOrPut(account.name) {
145+ buildAvatarImageLoader(account)
146+ }
112147
113- val coilRequestHeaderInterceptor = CoilRequestHeaderInterceptor (
114- clientManager = clientManager,
115- accountName = accountName
148+ private fun buildThumbnailImageLoader (account : Account ): ImageLoader {
149+ val openCloudClient = clientManager.getClientForCoilThumbnails(account.name)
150+ val interceptor = CoilRequestHeaderInterceptor (clientManager, account.name)
151+ return ImageLoader (appContext).newBuilder()
152+ .okHttpClient(
153+ openCloudClient.okHttpClient.newBuilder()
154+ .addInterceptor(interceptor).build()
116155 )
156+ .logger(DebugLogger ())
157+ .memoryCache { sharedMemoryCache }
158+ .diskCache { sharedDiskCache }
159+ .respectCacheHeaders(false )
160+ .build()
161+ }
117162
118- ImageLoader (appContext).newBuilder().okHttpClient(
119- okHttpClient = openCloudClient.okHttpClient.newBuilder()
120- .addInterceptor(coilRequestHeaderInterceptor).build()
121- ).logger(DebugLogger ())
122- .memoryCache {
123- sharedMemoryCache
124- }
125- .diskCache {
126- sharedDiskCache
127- }
128- .build()
129- }
163+ private fun buildAvatarImageLoader (account : Account ): ImageLoader {
164+ val openCloudClient = clientManager.getClientForCoilThumbnails(account.name)
165+ val interceptor = CoilRequestHeaderInterceptor (clientManager, account.name)
166+ return ImageLoader (appContext).newBuilder()
167+ .okHttpClient(
168+ openCloudClient.okHttpClient.newBuilder()
169+ .addInterceptor(interceptor)
170+ .cache(avatarHttpCache)
171+ .build()
172+ )
173+ .logger(DebugLogger ())
174+ .memoryCache { sharedMemoryCache }
175+ // No Coil disk cache — OkHttp's HTTP cache handles persistence
176+ // and offline fallback instead.
177+ .diskCache(null )
178+ .respectCacheHeaders(true )
179+ .build()
130180 }
131181
132182 private class CoilRequestHeaderInterceptor (
@@ -152,10 +202,15 @@ object ThumbnailsRequester : KoinComponent {
152202 var builder = response.newBuilder()
153203 var changed = false
154204
205+ // The server sends no-cache (or no Cache-Control) for avatar responses.
206+ // Rewrite to max-age=300 so OkHttp's HTTP cache caches them.
207+ // Deliberately omitting must-revalidate: without it OkHttp serves stale
208+ // responses when the network is unavailable, giving us offline fallback.
209+ // For thumbnails this rewrite is ignored (respectCacheHeaders=false).
155210 val cacheControl = response.header(" Cache-Control" )
156211 if (cacheControl.isNullOrEmpty() || cacheControl.contains(" no-cache" )) {
157212 builder.removeHeader(" Cache-Control" )
158- builder.addHeader(" Cache-Control" , " max-age=5000, must-revalidate " )
213+ builder.addHeader(" Cache-Control" , " max-age=300 " )
159214 changed = true
160215 }
161216
0 commit comments