Skip to content

Commit bec78fb

Browse files
feat: Add caching to avatar
AI-assistant: Copilot 1.0.6 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
1 parent 8a0849e commit bec78fb

3 files changed

Lines changed: 124 additions & 9 deletions

File tree

app/src/main/java/com/nextcloud/talk/profile/AvatarSection.kt

Lines changed: 116 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
*/
77
package com.nextcloud.talk.profile
88

9+
import android.content.Context
10+
import androidx.compose.foundation.isSystemInDarkTheme
911
import androidx.compose.foundation.layout.Arrangement
1012
import androidx.compose.foundation.layout.Column
1113
import androidx.compose.foundation.layout.Row
@@ -20,6 +22,9 @@ import androidx.compose.material3.Icon
2022
import androidx.compose.material3.MaterialTheme
2123
import androidx.compose.material3.Text
2224
import androidx.compose.runtime.Composable
25+
import androidx.compose.runtime.LaunchedEffect
26+
import androidx.compose.runtime.remember
27+
import androidx.compose.runtime.rememberCoroutineScope
2328
import androidx.compose.ui.Alignment
2429
import androidx.compose.ui.Modifier
2530
import androidx.compose.ui.draw.clip
@@ -30,32 +35,137 @@ import androidx.compose.ui.res.stringResource
3035
import androidx.compose.ui.text.style.TextOverflow
3136
import androidx.compose.ui.unit.Dp
3237
import androidx.compose.ui.unit.dp
38+
import coil.annotation.ExperimentalCoilApi
3339
import coil.compose.AsyncImage
40+
import coil.compose.AsyncImagePainter
41+
import coil.imageLoader
42+
import coil.memory.MemoryCache
43+
import coil.request.CachePolicy
3444
import coil.request.ImageRequest
3545
import com.nextcloud.talk.R
3646
import 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
3953
private 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
60170
fun AvatarSection(state: ProfileUiState, callbacks: ProfileCallbacks, modifier: Modifier) {
61171
Column(modifier = modifier.padding(top = 16.dp), horizontalAlignment = Alignment.CenterHorizontally) {

app/src/main/java/com/nextcloud/talk/profile/ProfileActivity.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -560,7 +560,8 @@ class ProfileActivity : BaseActivity() {
560560

561561
override fun onNext(genericOverall: GenericOverall) {
562562
profileUiState = profileUiState.copy(
563-
avatarRefreshKey = profileUiState.avatarRefreshKey + 1
563+
avatarRefreshKey = profileUiState.avatarRefreshKey + 1,
564+
avatarIsDeleted = true
564565
)
565566
}
566567

@@ -591,7 +592,8 @@ class ProfileActivity : BaseActivity() {
591592

592593
override fun onNext(genericOverall: GenericOverall) {
593594
profileUiState = profileUiState.copy(
594-
avatarRefreshKey = profileUiState.avatarRefreshKey + 1
595+
avatarRefreshKey = profileUiState.avatarRefreshKey + 1,
596+
avatarIsDeleted = false
595597
)
596598
}
597599

app/src/main/java/com/nextcloud/talk/profile/ProfileState.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,11 @@ data class ProfileUiState(
2525
val displayName: String = "",
2626
val baseUrl: String = "",
2727
val currentUser: User? = null,
28-
/** Increment to tell the avatar AndroidView to reload (e.g. after upload/delete). */
28+
/** Increment to tell the avatar composable to reload (e.g. after upload/delete). */
2929
val avatarRefreshKey: Int = 0,
30+
/** True when the most recent avatar action was a delete (server-generated avatars differ per
31+
* theme); false for upload/choose/camera (same image for both themes). */
32+
val avatarIsDeleted: Boolean = false,
3033
val isEditMode: Boolean = false,
3134
val showAvatarButtons: Boolean = false,
3235
val showProfileEnabledCard: Boolean = false,

0 commit comments

Comments
 (0)