66 */
77package com.nextcloud.talk.profile
88
9+ import android.content.Context
10+ import androidx.compose.foundation.isSystemInDarkTheme
911import androidx.compose.foundation.layout.Arrangement
1012import androidx.compose.foundation.layout.Column
1113import androidx.compose.foundation.layout.Row
@@ -20,6 +22,9 @@ import androidx.compose.material3.Icon
2022import androidx.compose.material3.MaterialTheme
2123import androidx.compose.material3.Text
2224import androidx.compose.runtime.Composable
25+ import androidx.compose.runtime.LaunchedEffect
26+ import androidx.compose.runtime.remember
27+ import androidx.compose.runtime.rememberCoroutineScope
2328import androidx.compose.ui.Alignment
2429import androidx.compose.ui.Modifier
2530import androidx.compose.ui.draw.clip
@@ -30,32 +35,137 @@ import androidx.compose.ui.res.stringResource
3035import androidx.compose.ui.text.style.TextOverflow
3136import androidx.compose.ui.unit.Dp
3237import androidx.compose.ui.unit.dp
38+ import coil.annotation.ExperimentalCoilApi
3339import coil.compose.AsyncImage
40+ import coil.compose.AsyncImagePainter
41+ import coil.imageLoader
42+ import coil.memory.MemoryCache
43+ import coil.request.CachePolicy
3444import coil.request.ImageRequest
3545import com.nextcloud.talk.R
3646import com.nextcloud.talk.utils.ApiUtils
47+ import kotlinx.coroutines.CoroutineScope
48+ import kotlinx.coroutines.Dispatchers
49+ import kotlinx.coroutines.launch
3750
51+ @OptIn(ExperimentalCoilApi ::class )
3852@Composable
3953private fun AvatarImage (state : ProfileUiState , avatarSize : Dp ) {
54+ val context = LocalContext .current
4055 val user = state.currentUser
41- val url = ApiUtils .getUrlForAvatar(user?.baseUrl,state.currentUser?.userId, true )
42- val model = ImageRequest .Builder (LocalContext .current)
43- .data(url)
44- .crossfade(true )
45- .build()
46-
56+ val isDark = isSystemInDarkTheme()
57+ val url = ApiUtils .getUrlForAvatar(user?.baseUrl, user?.userId, requestBigSize = true , darkMode = isDark)
58+ val cachePolicy = if (state.avatarRefreshKey > 0 ) CachePolicy .WRITE_ONLY else CachePolicy .ENABLED
59+ val model = remember(url, state.avatarRefreshKey) {
60+ ImageRequest .Builder (context)
61+ .data(url)
62+ .memoryCachePolicy(cachePolicy)
63+ .diskCachePolicy(cachePolicy)
64+ .crossfade(true )
65+ .build()
66+ }
67+ val coroutineScope = rememberCoroutineScope()
68+ AvatarCacheEffect (state, isDark)
4769 AsyncImage (
4870 model = model,
4971 contentDescription = stringResource(R .string.avatar),
5072 contentScale = ContentScale .Crop ,
5173 placeholder = painterResource(R .drawable.account_circle_96dp),
5274 error = painterResource(R .drawable.account_circle_96dp),
75+ onSuccess = { successState ->
76+ if (state.avatarRefreshKey > 0 && ! state.avatarIsDeleted) {
77+ val otherUrl = ApiUtils .getUrlForAvatar(
78+ user?.baseUrl, user?.userId, requestBigSize = true , darkMode = ! isDark
79+ )
80+ copyAvatarToOtherThemeCache(successState, context, otherUrl, url, coroutineScope)
81+ }
82+ },
5383 modifier = Modifier
5484 .size(avatarSize)
5585 .clip(CircleShape )
5686 )
5787}
5888
89+ /* *
90+ * Side-effect composable that evicts stale avatar cache entries and, for the delete case,
91+ * prefetches the other theme's server-generated avatar whenever [ProfileUiState.avatarRefreshKey]
92+ * changes.
93+ */
94+ @OptIn(ExperimentalCoilApi ::class )
95+ @Composable
96+ private fun AvatarCacheEffect (state : ProfileUiState , isDark : Boolean ) {
97+ val context = LocalContext .current
98+ val user = state.currentUser
99+ LaunchedEffect (state.avatarRefreshKey) {
100+ if (state.avatarRefreshKey > 0 ) {
101+ val imageLoader = context.imageLoader
102+ val urlLight =
103+ ApiUtils .getUrlForAvatar(user?.baseUrl, user?.userId, requestBigSize = true , darkMode = false )
104+ val urlDark = ApiUtils .getUrlForAvatar(user?.baseUrl, user?.userId, requestBigSize = true , darkMode = true )
105+ // Evict both theme variants so no stale image survives in either cache layer.
106+ listOf (urlLight, urlDark).forEach { variantUrl ->
107+ imageLoader.memoryCache?.let { cache ->
108+ cache.keys.filter { it.key == variantUrl }.forEach { cache.remove(it) }
109+ }
110+ imageLoader.diskCache?.remove(variantUrl)
111+ }
112+ // Delete: server returns different theme-aware avatars per URL, so the other theme
113+ // must be fetched independently via a dedicated network request.
114+ if (state.avatarIsDeleted) {
115+ val otherUrl = if (isDark) urlLight else urlDark
116+ imageLoader.enqueue(
117+ ImageRequest .Builder (context)
118+ .data(otherUrl)
119+ .memoryCachePolicy(CachePolicy .WRITE_ONLY )
120+ .diskCachePolicy(CachePolicy .WRITE_ONLY )
121+ .build()
122+ )
123+ }
124+ // Upload/picker/camera: the other theme's cache is populated via onSuccess once the
125+ // current theme's image has been fetched, avoiding a redundant network request.
126+ }
127+ }
128+ }
129+
130+ /* *
131+ * Copies the freshly loaded avatar into the opposite theme's cache slots (memory + disk)
132+ * preventing the need for a second network request.
133+ */
134+ @OptIn(ExperimentalCoilApi ::class )
135+ private fun copyAvatarToOtherThemeCache (
136+ successState : AsyncImagePainter .State .Success ,
137+ context : Context ,
138+ otherUrl : String ,
139+ url : String ,
140+ coroutineScope : CoroutineScope
141+ ) {
142+ val imageLoader = context.imageLoader
143+ // Copy memory-cache entry, preserving key extras (e.g. resolved image size).
144+ val currentMemKey = successState.result.memoryCacheKey
145+ val memValue = currentMemKey?.let { imageLoader.memoryCache?.get(it) }
146+ if (currentMemKey != null && memValue != null ) {
147+ imageLoader.memoryCache?.set(MemoryCache .Key (otherUrl, currentMemKey.extras), memValue)
148+ }
149+ // Copy disk-cache bytes on a background thread.
150+ val diskKey = successState.result.diskCacheKey ? : url
151+ coroutineScope.launch(Dispatchers .IO ) {
152+ val diskCache = imageLoader.diskCache ? : return @launch
153+ diskCache.openSnapshot(diskKey)?.use { snapshot ->
154+ diskCache.openEditor(otherUrl)?.let { editor ->
155+ try {
156+ java.io.File (snapshot.data.toString())
157+ .copyTo(java.io.File (editor.data.toString()), overwrite = true )
158+ java.io.File (snapshot.metadata.toString())
159+ .copyTo(java.io.File (editor.metadata.toString()), overwrite = true )
160+ editor.commitAndOpenSnapshot()?.close()
161+ } catch (_: Exception ) {
162+ editor.abort()
163+ }
164+ }
165+ }
166+ }
167+ }
168+
59169@Composable
60170fun AvatarSection (state : ProfileUiState , callbacks : ProfileCallbacks , modifier : Modifier ) {
61171 Column (modifier = modifier.padding(top = 16 .dp), horizontalAlignment = Alignment .CenterHorizontally ) {
0 commit comments