Skip to content

Commit cd41c1f

Browse files
committed
refactor(core): improve caching and profile data mapping
This commit refines the caching mechanism and data layer for user profiles and repository details. It consolidates cache time-to-live constants, improves cache invalidation logic, and introduces proper DTO-to-domain mapping for GitHub user profiles. - **refactor(core)**: Moved `CacheTtl` constants into a companion object within `CacheManager` and updated `HOME_REPOS` TTL to 12 hours. - **fix(core)**: Updated `CacheManager` to use explicit key filtering during `invalidateByPrefix` and `cleanupExpired` to avoid potential `ConcurrentModificationException`. - **feat(profile)**: Added `UserProfileMappers.kt` and updated `ProfileViewModel` to properly manage user profile fetch jobs, preventing race conditions. - **feat(details)**: Introduced `GithubUserProfileDto` and associated mapper to separate network and domain models. - **fix(details)**: Enhanced `DetailsRepositoryImpl` with better error handling, falling back to stale cache data on network failures for repository and profile lookups. - **feat(ui)**: Updated `AccountSection` to display real stats (repos, followers, following) and improved name display logic. - **chore**: Removed unnecessary `@Serializable` annotations from domain models.
1 parent faf0483 commit cd41c1f

12 files changed

Lines changed: 139 additions & 57 deletions

File tree

core/data/src/commonMain/kotlin/zed/rainxch/core/data/cache/CacheManager.kt

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,6 @@ import zed.rainxch.core.data.local.db.entities.CacheEntryEntity
77
import kotlin.time.Clock
88
import kotlin.time.Duration.Companion.hours
99

10-
object CacheTtl {
11-
val HOME_REPOS = 3.hours.inWholeMilliseconds
12-
val REPO_DETAILS = 6.hours.inWholeMilliseconds
13-
val RELEASES = 6.hours.inWholeMilliseconds
14-
val README = 12.hours.inWholeMilliseconds
15-
val USER_PROFILE = 6.hours.inWholeMilliseconds
16-
val SEARCH_RESULTS = 1.hours.inWholeMilliseconds
17-
val REPO_STATS = 6.hours.inWholeMilliseconds
18-
}
19-
2010
class CacheManager(
2111
val cacheDao: CacheDao
2212
) {
@@ -90,13 +80,27 @@ class CacheManager(
9080
}
9181

9282
suspend fun invalidateByPrefix(prefix: String) {
93-
memoryCache.keys.removeAll { it.startsWith(prefix) }
83+
val keysToRemove = memoryCache.keys.filter { it.startsWith(prefix) }
84+
keysToRemove.forEach { memoryCache.remove(it) }
9485
cacheDao.deleteByPrefix(prefix)
9586
}
9687

9788
suspend fun cleanupExpired() {
9889
val currentTime = now()
99-
memoryCache.entries.removeAll { it.value.first <= currentTime }
90+
val expiredKeys = memoryCache.entries
91+
.filter { it.value.first <= currentTime }
92+
.map { it.key }
93+
expiredKeys.forEach { memoryCache.remove(it) }
10094
cacheDao.deleteExpired(currentTime)
10195
}
96+
97+
companion object CacheTtl {
98+
val HOME_REPOS = 12.hours.inWholeMilliseconds
99+
val REPO_DETAILS = 6.hours.inWholeMilliseconds
100+
val RELEASES = 6.hours.inWholeMilliseconds
101+
val README = 12.hours.inWholeMilliseconds
102+
val USER_PROFILE = 6.hours.inWholeMilliseconds
103+
val SEARCH_RESULTS = 1.hours.inWholeMilliseconds
104+
val REPO_STATS = 6.hours.inWholeMilliseconds
105+
}
102106
}

core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubUserProfile.kt

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
package zed.rainxch.core.domain.model
22

3-
import kotlinx.serialization.Serializable
4-
5-
@Serializable
63
data class GithubUserProfile(
74
val id: Long,
85
val login: String,

core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/PaginatedDiscoveryRepositories.kt

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
package zed.rainxch.core.domain.model
22

3-
import kotlinx.serialization.Serializable
4-
5-
@Serializable
63
data class PaginatedDiscoveryRepositories(
74
val repos: List<GithubRepoSummary>,
85
val hasMore: Boolean,
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package zed.rainxch.details.data.mappers
2+
3+
import zed.rainxch.core.domain.model.GithubUserProfile
4+
import zed.rainxch.details.data.model.GithubUserProfileDto
5+
6+
fun GithubUserProfileDto.toDomain(): GithubUserProfile {
7+
return GithubUserProfile(
8+
id = id,
9+
login = login,
10+
name = name,
11+
bio = bio,
12+
avatarUrl = avatarUrl,
13+
htmlUrl = htmlUrl,
14+
followers = followers,
15+
following = following,
16+
publicRepos = publicRepos,
17+
location = location,
18+
company = company,
19+
blog = blog,
20+
twitterUsername = twitterUsername
21+
)
22+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package zed.rainxch.details.data.model
2+
3+
import kotlinx.serialization.Serializable
4+
5+
@Serializable
6+
data class GithubUserProfileDto(
7+
val id: Long,
8+
val login: String,
9+
val name: String?,
10+
val bio: String?,
11+
val avatarUrl: String,
12+
val htmlUrl: String,
13+
val followers: Int,
14+
val following: Int,
15+
val publicRepos: Int,
16+
val location: String?,
17+
val company: String?,
18+
val blog: String?,
19+
val twitterUsername: String?
20+
)

feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,25 @@ import kotlinx.coroutines.awaitAll
1111
import kotlinx.coroutines.coroutineScope
1212
import kotlinx.serialization.Serializable
1313
import zed.rainxch.core.data.cache.CacheManager
14-
import zed.rainxch.core.data.cache.CacheTtl
14+
import zed.rainxch.core.data.cache.CacheManager.CacheTtl.README
15+
import zed.rainxch.core.data.cache.CacheManager.CacheTtl.RELEASES
16+
import zed.rainxch.core.data.cache.CacheManager.CacheTtl.REPO_DETAILS
17+
import zed.rainxch.core.data.cache.CacheManager.CacheTtl.REPO_STATS
18+
import zed.rainxch.core.data.cache.CacheManager.CacheTtl.USER_PROFILE
1519
import zed.rainxch.core.data.network.executeRequest
1620
import zed.rainxch.core.data.services.LocalizationManager
1721
import zed.rainxch.core.domain.model.GithubRelease
1822
import zed.rainxch.core.domain.model.GithubRepoSummary
1923
import zed.rainxch.core.domain.model.GithubUser
20-
import zed.rainxch.core.domain.model.GithubUserProfile
2124
import zed.rainxch.core.data.dto.ReleaseNetwork
2225
import zed.rainxch.core.data.dto.RepoByIdNetwork
2326
import zed.rainxch.core.data.dto.RepoInfoNetwork
2427
import zed.rainxch.core.data.dto.UserProfileNetwork
2528
import zed.rainxch.core.domain.logging.GitHubStoreLogger
2629
import zed.rainxch.core.data.mappers.toDomain
30+
import zed.rainxch.core.domain.model.GithubUserProfile
31+
import zed.rainxch.details.data.mappers.toDomain
32+
import zed.rainxch.details.data.model.GithubUserProfileDto
2733
import zed.rainxch.details.data.utils.ReadmeLocalizationHelper
2834
import zed.rainxch.details.data.utils.preprocessMarkdown
2935
import zed.rainxch.details.domain.model.RepoStats
@@ -76,17 +82,28 @@ class DetailsRepositoryImpl(
7682
return cached
7783
}
7884

79-
val result = httpClient.executeRequest<RepoByIdNetwork> {
80-
get("/repositories/$id") {
81-
header(HttpHeaders.Accept, "application/vnd.github+json")
85+
return try {
86+
val result = httpClient.executeRequest<RepoByIdNetwork> {
87+
get("/repositories/$id") {
88+
header(HttpHeaders.Accept, "application/vnd.github+json")
89+
}
90+
}.getOrThrow().toGithubRepoSummary()
91+
cacheManager.put(cacheKey, result, REPO_DETAILS)
92+
result
93+
} catch (e: Exception) {
94+
cacheManager.getStale<GithubRepoSummary>(cacheKey)?.let { stale ->
95+
logger.debug("Network error, using stale cache for repo id=$id")
96+
return stale
8297
}
83-
}.getOrThrow().toGithubRepoSummary()
98+
throw e
99+
}
84100

85-
cacheManager.put(cacheKey, result, CacheTtl.REPO_DETAILS)
86-
return result
87101
}
88102

89-
override suspend fun getRepositoryByOwnerAndName(owner: String, name: String): GithubRepoSummary {
103+
override suspend fun getRepositoryByOwnerAndName(
104+
owner: String,
105+
name: String
106+
): GithubRepoSummary {
90107
val cacheKey = "details:repo:$owner/$name"
91108

92109
cacheManager.get<GithubRepoSummary>(cacheKey)?.let { cached ->
@@ -101,7 +118,7 @@ class DetailsRepositoryImpl(
101118
}
102119
}.getOrThrow().toGithubRepoSummary()
103120

104-
cacheManager.put(cacheKey, result, CacheTtl.REPO_DETAILS)
121+
cacheManager.put(cacheKey, result, REPO_DETAILS)
105122
result
106123
} catch (e: Exception) {
107124
cacheManager.getStale<GithubRepoSummary>(cacheKey)?.let { stale ->
@@ -142,7 +159,7 @@ class DetailsRepositoryImpl(
142159
body = processReleaseBody(latest.body, owner, repo, defaultBranch)
143160
).toDomain()
144161

145-
cacheManager.put(cacheKey, result, CacheTtl.RELEASES)
162+
cacheManager.put(cacheKey, result, RELEASES)
146163
result
147164
} catch (e: Exception) {
148165
cacheManager.getStale<GithubRelease>(cacheKey)?.let { stale ->
@@ -185,7 +202,7 @@ class DetailsRepositoryImpl(
185202
.sortedByDescending { it.publishedAt }
186203

187204
if (result.isNotEmpty()) {
188-
cacheManager.put(cacheKey, result, CacheTtl.RELEASES)
205+
cacheManager.put(cacheKey, result, RELEASES)
189206
}
190207
result
191208
} catch (e: Exception) {
@@ -237,7 +254,7 @@ class DetailsRepositoryImpl(
237254
languageCode = result.second,
238255
path = result.third
239256
)
240-
cacheManager.put(cacheKey, cachedReadme, CacheTtl.README)
257+
cacheManager.put(cacheKey, cachedReadme, README)
241258
}
242259

243260
return result
@@ -388,7 +405,7 @@ class DetailsRepositoryImpl(
388405
openIssues = info.openIssues,
389406
)
390407

391-
cacheManager.put(cacheKey, result, CacheTtl.REPO_STATS)
408+
cacheManager.put(cacheKey, result, REPO_STATS)
392409
result
393410
} catch (e: Exception) {
394411
cacheManager.getStale<RepoStats>(cacheKey)?.let { stale ->
@@ -402,9 +419,9 @@ class DetailsRepositoryImpl(
402419
override suspend fun getUserProfile(username: String): GithubUserProfile {
403420
val cacheKey = "details:profile:$username"
404421

405-
cacheManager.get<GithubUserProfile>(cacheKey)?.let { cached ->
422+
cacheManager.get<GithubUserProfileDto>(cacheKey)?.let { cached ->
406423
logger.debug("Cache hit for user profile $username")
407-
return cached
424+
return cached.toDomain()
408425
}
409426

410427
return try {
@@ -414,7 +431,7 @@ class DetailsRepositoryImpl(
414431
}
415432
}.getOrThrow()
416433

417-
val result = GithubUserProfile(
434+
val result = GithubUserProfileDto(
418435
id = user.id,
419436
login = user.login,
420437
name = user.name,
@@ -428,14 +445,14 @@ class DetailsRepositoryImpl(
428445
company = user.company,
429446
blog = user.blog,
430447
twitterUsername = user.twitterUsername
431-
)
448+
).toDomain()
432449

433-
cacheManager.put(cacheKey, result, CacheTtl.USER_PROFILE)
450+
cacheManager.put(cacheKey, result, USER_PROFILE)
434451
result
435452
} catch (e: Exception) {
436-
cacheManager.getStale<GithubUserProfile>(cacheKey)?.let { stale ->
453+
cacheManager.getStale<GithubUserProfileDto>(cacheKey)?.let { stale ->
437454
logger.debug("Network error, using stale cache for profile $username")
438-
return stale
455+
return stale.toDomain()
439456
}
440457
throw e
441458
}

feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/repository/HomeRepositoryImpl.kt

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import kotlinx.datetime.toLocalDateTime
2424
import kotlinx.serialization.SerialName
2525
import kotlinx.serialization.Serializable
2626
import zed.rainxch.core.data.cache.CacheManager
27-
import zed.rainxch.core.data.cache.CacheTtl
27+
import zed.rainxch.core.data.cache.CacheManager.CacheTtl.HOME_REPOS
2828
import zed.rainxch.core.data.dto.GithubRepoNetworkModel
2929
import zed.rainxch.core.data.dto.GithubRepoSearchResponse
3030
import zed.rainxch.core.data.mappers.toSummary
@@ -69,7 +69,7 @@ class HomeRepositoryImpl(
6969
hasMore = false,
7070
nextPageIndex = 2
7171
)
72-
cacheManager.put(cacheKey("trending", page), result, CacheTtl.HOME_REPOS)
72+
cacheManager.put(cacheKey("trending", page), result, CacheManager.CacheTtl.HOME_REPOS)
7373
emit(result)
7474
return@flow
7575
} else {
@@ -117,7 +117,7 @@ class HomeRepositoryImpl(
117117
hasMore = false,
118118
nextPageIndex = 2
119119
)
120-
cacheManager.put(cacheKey("hot_release", page), result, CacheTtl.HOME_REPOS)
120+
cacheManager.put(cacheKey("hot_release", page), result, CacheManager.HOME_REPOS)
121121
emit(result)
122122
return@flow
123123
} else {
@@ -165,7 +165,7 @@ class HomeRepositoryImpl(
165165
hasMore = false,
166166
nextPageIndex = 2
167167
)
168-
cacheManager.put(cacheKey("most_popular", page), result, CacheTtl.HOME_REPOS)
168+
cacheManager.put(cacheKey("most_popular", page), result, HOME_REPOS)
169169
emit(result)
170170
return@flow
171171
} else {
@@ -302,7 +302,7 @@ class HomeRepositoryImpl(
302302
currentApiPage++
303303
pagesFetchedCount++
304304

305-
} catch (e: RateLimitException) {
305+
} catch (_: RateLimitException) {
306306
logger.error("Rate limited during search")
307307
break
308308
} catch (e: CancellationException) {
@@ -341,7 +341,7 @@ class HomeRepositoryImpl(
341341
hasMore = pagesFetchedCount < maxPagesToFetch && results.size >= desiredCount,
342342
nextPageIndex = currentApiPage + 1
343343
)
344-
cacheManager.put(cacheKey(category, startPage), allResults, CacheTtl.HOME_REPOS)
344+
cacheManager.put(cacheKey(category, startPage), allResults, HOME_REPOS)
345345
logger.debug("Cached ${results.size} repos for $category page $startPage")
346346
}
347347
}.flowOn(Dispatchers.IO)
@@ -424,7 +424,7 @@ class HomeRepositoryImpl(
424424
} else {
425425
null
426426
}
427-
} catch (e: Exception) {
427+
} catch (_: Exception) {
428428
null
429429
}
430430
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package zed.rainxch.profile.data.mappers
2+
3+
import zed.rainxch.core.data.dto.UserProfileNetwork
4+
import zed.rainxch.profile.domain.model.UserProfile
5+
6+
fun UserProfileNetwork.toUserProfile(): UserProfile {
7+
return UserProfile(
8+
id = id.toInt(),
9+
imageUrl = avatarUrl,
10+
name = name ?: login,
11+
username = login,
12+
bio = bio,
13+
repositoryCount = publicRepos,
14+
followers = followers,
15+
following = following
16+
)
17+
}

feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/repository/ProfileRepositoryImpl.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.Flow
99
import kotlinx.coroutines.flow.flow
1010
import kotlinx.coroutines.flow.flowOn
1111
import zed.rainxch.core.data.cache.CacheManager
12+
import zed.rainxch.core.data.cache.CacheManager.CacheTtl.USER_PROFILE
1213
import zed.rainxch.core.data.cache.CacheTtl
1314
import zed.rainxch.core.data.data_source.TokenStore
1415
import zed.rainxch.core.data.dto.UserProfileNetwork
@@ -60,7 +61,7 @@ class ProfileRepositoryImpl(
6061
}.getOrThrow()
6162

6263
val userProfile = networkProfile.toUserProfile()
63-
cacheManager.put(CACHE_KEY, userProfile, CacheTtl.USER_PROFILE)
64+
cacheManager.put(CACHE_KEY, userProfile, USER_PROFILE)
6465
logger.debug("Fetched and cached user profile: ${userProfile.username}")
6566
emit(userProfile)
6667
} catch (e: Exception) {

feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package zed.rainxch.profile.presentation
22

33
import androidx.lifecycle.ViewModel
44
import androidx.lifecycle.viewModelScope
5+
import kotlinx.coroutines.Job
56
import kotlinx.coroutines.channels.Channel
67
import kotlinx.coroutines.flow.MutableStateFlow
78
import kotlinx.coroutines.flow.SharingStarted
@@ -29,6 +30,8 @@ class ProfileViewModel(
2930
private val proxyRepository: ProxyRepository
3031
) : ViewModel() {
3132

33+
private var userProfileJob: Job? = null
34+
3235
private var hasLoadedInitialData = false
3336

3437
private val _state = MutableStateFlow(ProfileState())
@@ -78,7 +81,9 @@ class ProfileViewModel(
7881
}
7982

8083
private fun loadUserProfile() {
81-
viewModelScope.launch {
84+
userProfileJob?.cancel()
85+
86+
userProfileJob = viewModelScope.launch {
8287
profileRepository.getUser().collect { profile ->
8388
_state.update { it.copy(userProfile = profile) }
8489
}

0 commit comments

Comments
 (0)