Skip to content

Commit a076045

Browse files
authored
Merge pull request #531 from OpenHub-Store/fix/whatsnew-locale-race
2 parents 51bccf9 + 2b273c0 commit a076045

3 files changed

Lines changed: 53 additions & 25 deletions

File tree

composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/whatsnew/WhatsNewLoaderImpl.kt

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,25 +18,34 @@ class WhatsNewLoaderImpl(
1818

1919
private val json = Json { ignoreUnknownKeys = true }
2020

21-
override suspend fun loadAll(): List<WhatsNewEntry> =
21+
override suspend fun loadAll(languageTag: String?): List<WhatsNewEntry> =
2222
knownVersionCodes
23-
.mapNotNull { vc -> loadOrNull(vc) }
23+
.mapNotNull { vc -> loadOrNull(vc, languageTag) }
2424
.sortedByDescending { it.versionCode }
2525

26-
override suspend fun forVersionCode(versionCode: Int): WhatsNewEntry? = loadOrNull(versionCode)
26+
override suspend fun forVersionCode(versionCode: Int, languageTag: String?): WhatsNewEntry? =
27+
loadOrNull(versionCode, languageTag)
2728

28-
private suspend fun loadOrNull(versionCode: Int): WhatsNewEntry? {
29-
val candidates = candidatePaths(versionCode)
29+
private suspend fun loadOrNull(versionCode: Int, languageTag: String?): WhatsNewEntry? {
30+
val candidates = candidatePaths(versionCode, languageTag)
3031
for (path in candidates) {
3132
val parsed = readEntry(path)
3233
if (parsed != null) return parsed
3334
}
3435
return null
3536
}
3637

37-
private fun candidatePaths(versionCode: Int): List<String> {
38-
val full = localizationManager.getCurrentLanguageCode()
39-
val primary = localizationManager.getPrimaryLanguageCode()
38+
private fun candidatePaths(versionCode: Int, languageTag: String?): List<String> {
39+
// Explicit tag passed in wins over global Locale lookup —
40+
// prevents the race with MainActivity's `setActiveLanguageTag`
41+
// when both this VM and MainActivity subscribe to the same
42+
// `getAppLanguage()` flow (#526 follow-up).
43+
val (full, primary) = if (!languageTag.isNullOrBlank()) {
44+
languageTag to languageTag.substringBefore('-')
45+
} else {
46+
localizationManager.getCurrentLanguageCode() to
47+
localizationManager.getPrimaryLanguageCode()
48+
}
4049
val paths = LinkedHashSet<String>()
4150
if (full.isNotBlank()) paths += "files/whatsnew/$full/$versionCode.json"
4251
if (primary.isNotBlank() && primary != full) paths += "files/whatsnew/$primary/$versionCode.json"

composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/whatsnew/WhatsNewViewModel.kt

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -31,52 +31,59 @@ class WhatsNewViewModel(
3131
private val _hasHistory = MutableStateFlow(false)
3232
val hasHistory: StateFlow<Boolean> = _hasHistory.asStateFlow()
3333

34+
@Volatile
35+
private var lastLanguageTag: String? = null
36+
3437
init {
3538
// Re-load whenever the user's selected app language changes.
36-
// The loader resolves locale per call, but cached state on this
37-
// VM would otherwise serve the previous language's text after
38-
// the user switches in Tweaks. distinctUntilChanged guards
39-
// against the initial replay-emit firing the load twice.
39+
// The tag is threaded explicitly into the loader so we beat
40+
// the race against MainActivity's setActiveLanguageTag — both
41+
// collectors fan out from the same flow with no ordering
42+
// guarantee, so reading Locale.getDefault() inside the loader
43+
// can return the previous language. distinctUntilChanged
44+
// guards against the initial replay-emit firing the load
45+
// twice.
4046
viewModelScope.launch {
4147
try {
4248
tweaksRepository
4349
.getAppLanguage()
4450
.distinctUntilChanged()
45-
.collect {
46-
reloadHistory()
47-
reloadPending()
51+
.collect { tag ->
52+
lastLanguageTag = tag
53+
reloadHistory(tag)
54+
reloadPending(tag)
4855
}
4956
} catch (t: Throwable) {
5057
logger.e(t) { "Failed to observe app-language for what's-new reloads" }
5158
}
5259
}
5360
}
5461

55-
private suspend fun reloadHistory() {
62+
private suspend fun reloadHistory(languageTag: String?) {
5663
try {
57-
val entries = whatsNewLoader.loadAll()
64+
val entries = whatsNewLoader.loadAll(languageTag)
5865
_historyEntries.value = entries
5966
_hasHistory.value = entries.size > 1
6067
} catch (t: Throwable) {
6168
logger.e(t) { "Failed to load what's-new history" }
6269
}
6370
}
6471

65-
private suspend fun reloadPending() {
72+
private suspend fun reloadPending(languageTag: String?) {
6673
try {
67-
evaluate()
74+
evaluate(languageTag)
6875
} catch (t: Throwable) {
6976
logger.e(t) { "Failed to evaluate what's-new state" }
7077
}
7178
}
7279

73-
private suspend fun evaluate() {
80+
private suspend fun evaluate(languageTag: String? = lastLanguageTag) {
7481
val current = appVersionInfo.versionCode
7582
val lastSeen = tweaksRepository.getLastSeenWhatsNewVersionCode().first() ?: Int.MIN_VALUE
7683

7784
if (lastSeen >= current) return
7885

79-
val entry = whatsNewLoader.forVersionCode(current)
86+
val entry = whatsNewLoader.forVersionCode(current, languageTag)
8087
if (entry == null || !entry.showAsSheet) {
8188
tweaksRepository.setLastSeenWhatsNewVersionCode(current)
8289
return
@@ -100,10 +107,11 @@ class WhatsNewViewModel(
100107
fun forceShowLatest() {
101108
viewModelScope.launch {
102109
try {
110+
val tag = lastLanguageTag
103111
val current = appVersionInfo.versionCode
104112
val entry =
105-
whatsNewLoader.forVersionCode(current)
106-
?: whatsNewLoader.loadAll().firstOrNull()
113+
whatsNewLoader.forVersionCode(current, tag)
114+
?: whatsNewLoader.loadAll(tag).firstOrNull()
107115
?: return@launch
108116
_pendingEntry.value = entry
109117
} catch (t: Throwable) {

core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/WhatsNewLoader.kt

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,18 @@ package zed.rainxch.core.domain.repository
33
import zed.rainxch.core.domain.model.WhatsNewEntry
44

55
interface WhatsNewLoader {
6-
suspend fun loadAll(): List<WhatsNewEntry>
6+
/**
7+
* Optional explicit BCP-47 [languageTag] (e.g. `"zh-CN"`, `"fr"`,
8+
* `null`). When non-null, this takes precedence over the global
9+
* `LocalizationManager` lookup — used to defeat the race where the
10+
* `getAppLanguage()` flow fans out to multiple subscribers and the
11+
* loader runs before the global `Locale.getDefault()` has caught up
12+
* with the user's just-picked language. Null falls back to
13+
* whatever `LocalizationManager.getCurrentLanguageCode()` returns
14+
* at call time (suitable for paths where the tag isn't known
15+
* upfront, e.g. a deep-linked one-shot load).
16+
*/
17+
suspend fun loadAll(languageTag: String? = null): List<WhatsNewEntry>
718

8-
suspend fun forVersionCode(versionCode: Int): WhatsNewEntry?
19+
suspend fun forVersionCode(versionCode: Int, languageTag: String? = null): WhatsNewEntry?
920
}

0 commit comments

Comments
 (0)