Skip to content

Commit e786d68

Browse files
committed
feat: implement cross-platform repository fetching and merging
- Introduce `HomePlatform` enum to support platform-specific repository filtering (Android, MacOS, Windows, Linux, and All). - Update `CachedRepositoriesDataSource` and `HomeRepository` to accept a `HomePlatform` parameter across all repository fetching methods. - Implement logic in `CachedRepositoriesDataSourceImpl` to fetch and merge repository data from multiple platform-specific JSON files when `HomePlatform.All` is selected. - Add support for merging repositories with duplicate IDs and sorting by trending score, popularity, and release date. - Refactor memory caching to use a composite `CacheKey` containing both platform and category. - Simplify fetching logic by removing secondary mirror URLs in favor of a direct GitHub raw content request. - Update `HomeViewModel` to default repository requests to `HomePlatform.All`. - Add `trendingScore` and `popularityScore` fields to `CachedGithubRepoSummary` DTO for improved sorting.
1 parent cc768fa commit e786d68

9 files changed

Lines changed: 193 additions & 54 deletions

File tree

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
package zed.rainxch.home.data.data_source
22

33
import zed.rainxch.home.data.dto.CachedRepoResponse
4+
import zed.rainxch.home.domain.model.HomePlatform
45

56
interface CachedRepositoriesDataSource {
6-
suspend fun getCachedTrendingRepos(): CachedRepoResponse?
7+
suspend fun getCachedTrendingRepos(platform: HomePlatform): CachedRepoResponse?
78

8-
suspend fun getCachedHotReleaseRepos(): CachedRepoResponse?
9+
suspend fun getCachedHotReleaseRepos(platform: HomePlatform): CachedRepoResponse?
910

10-
suspend fun getCachedMostPopularRepos(): CachedRepoResponse?
11+
suspend fun getCachedMostPopularRepos(platform: HomePlatform): CachedRepoResponse?
1112
}

feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/data_source/impl/CachedRepositoriesDataSourceImpl.kt

Lines changed: 137 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,32 @@ import io.ktor.client.request.get
66
import io.ktor.client.statement.HttpResponse
77
import io.ktor.client.statement.bodyAsText
88
import io.ktor.http.isSuccess
9+
import kotlinx.coroutines.Deferred
910
import kotlinx.coroutines.Dispatchers
11+
import kotlinx.coroutines.Job
12+
import kotlinx.coroutines.async
13+
import kotlinx.coroutines.awaitAll
14+
import kotlinx.coroutines.coroutineScope
15+
import kotlinx.coroutines.launch
1016
import kotlinx.coroutines.sync.Mutex
1117
import kotlinx.coroutines.sync.withLock
1218
import kotlinx.coroutines.withContext
1319
import kotlinx.serialization.SerializationException
1420
import kotlinx.serialization.json.Json
1521
import zed.rainxch.core.domain.logging.GitHubStoreLogger
16-
import zed.rainxch.core.domain.model.Platform
1722
import zed.rainxch.home.data.data_source.CachedRepositoriesDataSource
23+
import zed.rainxch.home.data.dto.CachedGithubRepoSummary
1824
import zed.rainxch.home.data.dto.CachedRepoResponse
1925
import zed.rainxch.home.domain.model.HomeCategory
26+
import zed.rainxch.home.domain.model.HomePlatform
27+
import kotlin.let
2028
import kotlin.time.Clock
2129
import kotlin.time.Duration.Companion.minutes
2230
import kotlin.time.ExperimentalTime
2331
import kotlin.time.Instant
2432

2533
@OptIn(ExperimentalTime::class)
2634
class CachedRepositoriesDataSourceImpl(
27-
private val platform: Platform,
2835
private val logger: GitHubStoreLogger,
2936
) : CachedRepositoriesDataSource {
3037
private val json =
@@ -44,69 +51,156 @@ class CachedRepositoriesDataSourceImpl(
4451
}
4552

4653
private val cacheMutex = Mutex()
47-
private val memoryCache = mutableMapOf<HomeCategory, CacheEntry>()
54+
private val memoryCache = mutableMapOf<CacheKey, CacheEntry>()
4855

4956
private data class CacheEntry(
5057
val data: CachedRepoResponse,
5158
val fetchedAt: Instant,
5259
)
5360

54-
override suspend fun getCachedTrendingRepos(): CachedRepoResponse? = fetchCachedReposForCategory(HomeCategory.TRENDING)
61+
override suspend fun getCachedTrendingRepos(platform: HomePlatform): CachedRepoResponse? =
62+
fetchCachedReposForCategory(platform, HomeCategory.TRENDING)
5563

56-
override suspend fun getCachedHotReleaseRepos(): CachedRepoResponse? = fetchCachedReposForCategory(HomeCategory.HOT_RELEASE)
64+
override suspend fun getCachedHotReleaseRepos(platform: HomePlatform): CachedRepoResponse? =
65+
fetchCachedReposForCategory(platform, HomeCategory.HOT_RELEASE)
5766

58-
override suspend fun getCachedMostPopularRepos(): CachedRepoResponse? = fetchCachedReposForCategory(HomeCategory.MOST_POPULAR)
67+
override suspend fun getCachedMostPopularRepos(platform: HomePlatform): CachedRepoResponse? =
68+
fetchCachedReposForCategory(platform, HomeCategory.MOST_POPULAR)
5969

60-
private suspend fun fetchCachedReposForCategory(category: HomeCategory): CachedRepoResponse? {
61-
val cached = cacheMutex.withLock { memoryCache[category] }
70+
private suspend fun fetchCachedReposForCategory(
71+
platform: HomePlatform,
72+
category: HomeCategory,
73+
): CachedRepoResponse? {
74+
val cacheKey = CacheKey(platform, category)
75+
76+
val cached = cacheMutex.withLock { memoryCache[cacheKey] }
6277
if (cached != null) {
6378
val age = Clock.System.now() - cached.fetchedAt
6479
if (age < CACHE_TTL) {
65-
logger.debug("Memory cache hit for $category (age: ${age.inWholeSeconds}s)")
80+
logger.debug("Memory cache hit for $cacheKey (age: ${age.inWholeSeconds}s)")
6681
return cached.data
6782
} else {
68-
logger.debug("Memory cache expired for $category (age: ${age.inWholeSeconds}s)")
83+
logger.debug("Memory cache expired for $cacheKey (age: ${age.inWholeSeconds}s)")
6984
}
7085
}
7186

7287
return withContext(Dispatchers.IO) {
73-
val platformName =
74-
when (platform) {
75-
Platform.ANDROID -> "android"
76-
Platform.WINDOWS -> "windows"
77-
Platform.MACOS -> "macos"
78-
Platform.LINUX -> "linux"
88+
if (platform == HomePlatform.All) {
89+
val paths =
90+
when (category) {
91+
HomeCategory.TRENDING -> {
92+
listOf(
93+
"cached-data/trending/android.json",
94+
"cached-data/trending/windows.json",
95+
"cached-data/trending/macos.json",
96+
"cached-data/trending/linux.json",
97+
)
98+
}
99+
100+
HomeCategory.HOT_RELEASE -> {
101+
listOf(
102+
"cached-data/new-releases/android.json",
103+
"cached-data/new-releases/windows.json",
104+
"cached-data/new-releases/macos.json",
105+
"cached-data/new-releases/linux.json",
106+
)
107+
}
108+
109+
HomeCategory.MOST_POPULAR -> {
110+
listOf(
111+
"cached-data/most-popular/android.json",
112+
"cached-data/most-popular/windows.json",
113+
"cached-data/most-popular/macos.json",
114+
"cached-data/most-popular/linux.json",
115+
)
116+
}
117+
}
118+
119+
val responses =
120+
coroutineScope {
121+
paths
122+
.map { path ->
123+
async {
124+
val url = "https://raw.githubusercontent.com/OpenHub-Store/api/main/$path"
125+
try {
126+
logger.debug("Fetching from: $url")
127+
val response: HttpResponse = httpClient.get(url)
128+
if (response.status.isSuccess()) {
129+
json.decodeFromString<CachedRepoResponse>(response.bodyAsText())
130+
} else {
131+
logger.error("HTTP ${response.status.value} from $url")
132+
null
133+
}
134+
} catch (e: SerializationException) {
135+
logger.error("Parse error from $url: ${e.message}")
136+
null
137+
} catch (e: Exception) {
138+
logger.error("Error with $url: ${e.message}")
139+
null
140+
}
141+
}
142+
}.awaitAll()
143+
.filterNotNull()
144+
}
145+
146+
if (responses.isEmpty()) {
147+
logger.error("All mirrors failed for $cacheKey")
148+
return@withContext null
79149
}
80150

81-
val path =
82-
when (category) {
83-
HomeCategory.TRENDING -> "cached-data/trending/$platformName.json"
84-
HomeCategory.HOT_RELEASE -> "cached-data/new-releases/$platformName.json"
85-
HomeCategory.MOST_POPULAR -> "cached-data/most-popular/$platformName.json"
151+
val mergedRepos =
152+
responses
153+
.asSequence()
154+
.flatMap { it.repositories }
155+
.distinctBy { it.id }
156+
.sortedWith(
157+
compareByDescending<CachedGithubRepoSummary> { it.trendingScore }
158+
.thenByDescending { it.popularityScore }
159+
.thenByDescending { it.latestReleaseDate },
160+
).toList()
161+
162+
val merged =
163+
CachedRepoResponse(
164+
category = responses.first().category,
165+
platform = "all",
166+
lastUpdated = responses.maxOf { it.lastUpdated },
167+
totalCount = mergedRepos.size,
168+
repositories = mergedRepos,
169+
)
170+
171+
cacheMutex.withLock {
172+
memoryCache[cacheKey] = CacheEntry(data = merged, fetchedAt = Clock.System.now())
86173
}
87174

88-
val mirrorUrls =
89-
listOf(
90-
"https://raw.githubusercontent.com/OpenHub-Store/api/main/$path",
91-
"https://cdn.jsdelivr.net/gh/OpenHub-Store/api@main/$path",
92-
"https://cdn.statically.io/gh/OpenHub-Store/api/main/$path",
93-
)
175+
merged
176+
} else {
177+
val platformName =
178+
when (platform) {
179+
HomePlatform.Android -> "android"
180+
HomePlatform.Windows -> "windows"
181+
HomePlatform.Macos -> "macos"
182+
HomePlatform.Linux -> "linux"
183+
HomePlatform.All -> error("Unreachable: All is handled above")
184+
}
185+
186+
val path =
187+
when (category) {
188+
HomeCategory.TRENDING -> "cached-data/trending/$platformName.json"
189+
HomeCategory.HOT_RELEASE -> "cached-data/new-releases/$platformName.json"
190+
HomeCategory.MOST_POPULAR -> "cached-data/most-popular/$platformName.json"
191+
}
192+
193+
val url = "https://raw.githubusercontent.com/OpenHub-Store/api/main/$path"
94194

95-
for (url in mirrorUrls) {
96195
try {
97196
logger.debug("Fetching from: $url")
98197
val response: HttpResponse = httpClient.get(url)
99198

100199
if (response.status.isSuccess()) {
101-
val responseText = response.bodyAsText()
102-
val parsed = json.decodeFromString<CachedRepoResponse>(responseText)
200+
val parsed = json.decodeFromString<CachedRepoResponse>(response.bodyAsText())
103201

104202
cacheMutex.withLock {
105-
memoryCache[category] =
106-
CacheEntry(
107-
data = parsed,
108-
fetchedAt = Clock.System.now(),
109-
)
203+
memoryCache[cacheKey] = CacheEntry(data = parsed, fetchedAt = Clock.System.now())
110204
}
111205

112206
return@withContext parsed
@@ -118,14 +212,19 @@ class CachedRepositoriesDataSourceImpl(
118212
} catch (e: Exception) {
119213
logger.error("Error with $url: ${e.message}")
120214
}
121-
}
122215

123-
logger.error("All mirrors failed for $category")
124-
null
216+
logger.error("Fetch failed for $cacheKey")
217+
null
218+
}
125219
}
126220
}
127221

128222
private companion object {
129223
private val CACHE_TTL = 5.minutes
130224
}
225+
226+
private data class CacheKey(
227+
val platform: HomePlatform,
228+
val category: HomeCategory,
229+
)
131230
}

feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/di/SharedModule.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ val homeModule =
2020

2121
single<CachedRepositoriesDataSource> {
2222
CachedRepositoriesDataSourceImpl(
23-
platform = get(),
2423
logger = get(),
2524
)
2625
}

feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/dto/CachedGithubRepoSummary.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,6 @@ data class CachedGithubRepoSummary(
1818
val releasesUrl: String,
1919
val updatedAt: String,
2020
val latestReleaseDate: String? = null,
21+
val trendingScore: Double? = null,
22+
val popularityScore: Int? = null,
2123
)

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

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import zed.rainxch.core.domain.model.Platform
3636
import zed.rainxch.core.domain.model.RateLimitException
3737
import zed.rainxch.home.data.data_source.CachedRepositoriesDataSource
3838
import zed.rainxch.home.data.mappers.toGithubRepoSummary
39+
import zed.rainxch.home.domain.model.HomePlatform
3940
import zed.rainxch.home.domain.repository.HomeRepository
4041
import kotlin.time.Clock
4142
import kotlin.time.Duration.Companion.days
@@ -54,12 +55,15 @@ class HomeRepositoryImpl(
5455
): String = "home:$category:${platform.name}:page$page"
5556

5657
@OptIn(ExperimentalTime::class)
57-
override fun getTrendingRepositories(page: Int): Flow<PaginatedDiscoveryRepositories> =
58+
override fun getTrendingRepositories(
59+
platform: HomePlatform,
60+
page: Int,
61+
): Flow<PaginatedDiscoveryRepositories> =
5862
flow {
5963
if (page == 1) {
6064
logger.debug("Attempting to load cached trending repositories...")
6165

62-
val cachedData = cachedDataSource.getCachedTrendingRepos()
66+
val cachedData = cachedDataSource.getCachedTrendingRepos(platform)
6367

6468
if (cachedData != null && cachedData.repositories.isNotEmpty()) {
6569
logger.debug("Using mirror cached data: ${cachedData.repositories.size} repos")
@@ -106,12 +110,15 @@ class HomeRepositoryImpl(
106110
}.flowOn(Dispatchers.IO)
107111

108112
@OptIn(ExperimentalTime::class)
109-
override fun getHotReleaseRepositories(page: Int): Flow<PaginatedDiscoveryRepositories> =
113+
override fun getHotReleaseRepositories(
114+
platform: HomePlatform,
115+
page: Int,
116+
): Flow<PaginatedDiscoveryRepositories> =
110117
flow {
111118
if (page == 1) {
112119
logger.debug("Attempting to load cached hot release repositories...")
113120

114-
val cachedData = cachedDataSource.getCachedHotReleaseRepos()
121+
val cachedData = cachedDataSource.getCachedHotReleaseRepos(platform)
115122

116123
if (cachedData != null && cachedData.repositories.isNotEmpty()) {
117124
logger.debug("Using mirror cached data: ${cachedData.repositories.size} repos")
@@ -158,12 +165,15 @@ class HomeRepositoryImpl(
158165
}.flowOn(Dispatchers.IO)
159166

160167
@OptIn(ExperimentalTime::class)
161-
override fun getMostPopular(page: Int): Flow<PaginatedDiscoveryRepositories> =
168+
override fun getMostPopular(
169+
platform: HomePlatform,
170+
page: Int,
171+
): Flow<PaginatedDiscoveryRepositories> =
162172
flow {
163173
if (page == 1) {
164174
logger.debug("Attempting to load cached most popular repositories...")
165175

166-
val cachedData = cachedDataSource.getCachedMostPopularRepos()
176+
val cachedData = cachedDataSource.getCachedMostPopularRepos(platform)
167177

168178
if (cachedData != null && cachedData.repositories.isNotEmpty()) {
169179
logger.debug("Using mirror cached data: ${cachedData.repositories.size} repos")
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package zed.rainxch.home.domain.model
2+
3+
enum class HomePlatform {
4+
All,
5+
Android,
6+
Macos,
7+
Windows,
8+
Linux,
9+
}

feature/home/domain/src/commonMain/kotlin/zed/rainxch/home/domain/repository/HomeRepository.kt

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,21 @@ package zed.rainxch.home.domain.repository
22

33
import kotlinx.coroutines.flow.Flow
44
import zed.rainxch.core.domain.model.PaginatedDiscoveryRepositories
5+
import zed.rainxch.home.domain.model.HomePlatform
56

67
interface HomeRepository {
7-
fun getTrendingRepositories(page: Int): Flow<PaginatedDiscoveryRepositories>
8+
fun getTrendingRepositories(
9+
platform: HomePlatform,
10+
page: Int,
11+
): Flow<PaginatedDiscoveryRepositories>
812

9-
fun getHotReleaseRepositories(page: Int): Flow<PaginatedDiscoveryRepositories>
13+
fun getHotReleaseRepositories(
14+
platform: HomePlatform,
15+
page: Int,
16+
): Flow<PaginatedDiscoveryRepositories>
1017

11-
fun getMostPopular(page: Int): Flow<PaginatedDiscoveryRepositories>
18+
fun getMostPopular(
19+
platform: HomePlatform,
20+
page: Int,
21+
): Flow<PaginatedDiscoveryRepositories>
1222
}

feature/home/presentation/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ kotlin {
1313
implementation(projects.feature.home.domain)
1414

1515
implementation(libs.liquid)
16+
implementation(libs.kotlinx.collections.immutable)
1617

1718
implementation(compose.components.resources)
1819

0 commit comments

Comments
 (0)