From 5a0e5ee78b6e652f88d0d31071b79d75dd4d8346 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Sun, 2 Aug 2026 21:23:07 -0300 Subject: [PATCH] fix: update credential gates without an app restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screens read hasApiKey/hasYouCamKey once in their ViewModel's init. Both platforms keep every tab's screen alive — Android in a HorizontalPager, iOS in a paged TabView — so connecting YouCam or Claude on Settings left Try-It, Wardrobe and Gaps showing their locked state until the process restarted. The platform secret stores have no change notification, so the shared repository owns the signal: a revision StateFlow bumped after every successful credential write, which the new flows re-read through. The ViewModels collect those instead of sampling once. Co-Authored-By: Claude Opus 5 (1M context) --- .../data/repository/SettingsRepositoryImpl.kt | 41 ++++++++++++++++++- .../domain/repository/SettingsRepository.kt | 9 ++++ .../presentation/viewmodel/GapsViewModel.kt | 9 ++-- .../presentation/viewmodel/TryItViewModel.kt | 22 +++++++++- .../viewmodel/WardrobeViewModel.kt | 10 +++-- .../worn/fake/FakeSettingsRepository.kt | 32 +++++++++++++++ .../worn/viewmodel/TryItViewModelTest.kt | 32 +++++++++++++++ .../worn/viewmodel/WardrobeViewModelTest.kt | 10 +++++ 8 files changed, 154 insertions(+), 11 deletions(-) diff --git a/shared/src/commonMain/kotlin/com/github/worn/data/repository/SettingsRepositoryImpl.kt b/shared/src/commonMain/kotlin/com/github/worn/data/repository/SettingsRepositoryImpl.kt index 522625d..cd3e890 100644 --- a/shared/src/commonMain/kotlin/com/github/worn/data/repository/SettingsRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/com/github/worn/data/repository/SettingsRepositoryImpl.kt @@ -18,8 +18,12 @@ import com.github.worn.data.source.ai.OnDeviceAiEngine import com.github.worn.data.source.local.PhotoFileStorage import com.github.worn.util.secret.SecretStore import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import kotlin.coroutines.CoroutineContext @@ -32,6 +36,14 @@ class SettingsRepositoryImpl( private val dispatcher: CoroutineContext, ) : SettingsRepository { + /** + * Ticks on every credential write. The platform secret stores (Keystore, Keychain) have no + * change notification, so the flows below re-read through this instead of observing storage. + * One repository instance is shared app-wide (Koin `single`), which is what lets a write from + * the Settings screen reach a gate collected by another screen. + */ + private val credentialRevision = MutableStateFlow(0) + override fun getUserProfile(): Flow = dataStore.data.map { prefs -> UserProfile( bodyType = prefs[KEY_BODY_TYPE]?.let { @@ -142,11 +154,17 @@ class SettingsRepositoryImpl( } override suspend fun saveApiKey(key: String): Result = runCatching { - withContext(dispatcher) { secretStore.saveApiKey(key) } + withContext(dispatcher) { + secretStore.saveApiKey(key) + notifyCredentialChange() + } } override suspend fun clearApiKey(): Result = runCatching { - withContext(dispatcher) { secretStore.clearApiKey() } + withContext(dispatcher) { + secretStore.clearApiKey() + notifyCredentialChange() + } } override suspend fun hasYouCamCredentials(): Result = runCatching { @@ -163,6 +181,7 @@ class SettingsRepositoryImpl( withContext(dispatcher) { secretStore.saveSecret(SecretStore.YOUCAM_CLIENT_ID, clientId) secretStore.saveSecret(SecretStore.YOUCAM_CLIENT_SECRET, clientSecret) + notifyCredentialChange() } } @@ -170,9 +189,21 @@ class SettingsRepositoryImpl( withContext(dispatcher) { secretStore.clearSecret(SecretStore.YOUCAM_CLIENT_ID) secretStore.clearSecret(SecretStore.YOUCAM_CLIENT_SECRET) + notifyCredentialChange() } } + override fun hasApiKeyFlow(): Flow = credentialRevision + .map { hasApiKey().getOrDefault(false) } + .distinctUntilChanged() + + override fun hasYouCamCredentialsFlow(): Flow = credentialRevision + .map { hasYouCamCredentials().getOrDefault(false) } + .distinctUntilChanged() + + /** Bumped after a successful write so the credential flows re-read the secret store. */ + private fun notifyCredentialChange() = credentialRevision.update { it + 1 } + override fun isOnDeviceAiEnabled(): Flow = dataStore.data.map { it[KEY_ON_DEVICE_AI] == true } @@ -192,6 +223,12 @@ class SettingsRepositoryImpl( (isOnDeviceAiEnabled().first() && onDeviceAi.availability().isUsable) } + // Both inputs to isAiAvailable() are observable: the key through the revision tick, the + // on-device opt-in through DataStore. Availability itself is re-queried on each emission. + override fun isAiAvailableFlow(): Flow = + combine(credentialRevision, isOnDeviceAiEnabled()) { _, _ -> isAiAvailable().getOrDefault(false) } + .distinctUntilChanged() + companion object { private val KEY_ON_DEVICE_AI = booleanPreferencesKey("on_device_ai_enabled") private val KEY_BODY_TYPE = stringPreferencesKey("body_type") diff --git a/shared/src/commonMain/kotlin/com/github/worn/domain/repository/SettingsRepository.kt b/shared/src/commonMain/kotlin/com/github/worn/domain/repository/SettingsRepository.kt index 01d13fb..c0ec7dd 100644 --- a/shared/src/commonMain/kotlin/com/github/worn/domain/repository/SettingsRepository.kt +++ b/shared/src/commonMain/kotlin/com/github/worn/domain/repository/SettingsRepository.kt @@ -32,6 +32,12 @@ interface SettingsRepository { suspend fun saveYouCamCredentials(clientId: String, clientSecret: String): Result suspend fun clearYouCamCredentials(): Result + // Observable counterparts of the credential checks above. Both platforms keep every tab's + // screen alive, so a ViewModel that read its gate once at construction would still show the + // locked state after the user entered a key on Settings. Screens collect these instead. + fun hasApiKeyFlow(): Flow + fun hasYouCamCredentialsFlow(): Flow + /** Whether the user has opted into running AI on the device instead of calling Claude. */ fun isOnDeviceAiEnabled(): Flow suspend fun setOnDeviceAiEnabled(enabled: Boolean): Result @@ -44,4 +50,7 @@ interface SettingsRepository { * is both enabled and available. Gates the AI features in Wardrobe and Gaps. */ suspend fun isAiAvailable(): Result + + /** Observable [isAiAvailable], for the same reason as [hasApiKeyFlow]. */ + fun isAiAvailableFlow(): Flow } diff --git a/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/GapsViewModel.kt b/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/GapsViewModel.kt index 8279eb0..13ec3b9 100644 --- a/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/GapsViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/GapsViewModel.kt @@ -45,10 +45,13 @@ class GapsViewModel( init { // Resolve the provider before branching, and off the main thread — see SettingsRepository. + // Collected, not read once: adding a key on Settings has to switch this screen over to AI + // recommendations without a restart, which means recomputing the gaps too. viewModelScope.launch { - val hasAi = settingsRepository.isAiAvailable().getOrDefault(false) - _state.update { it.copy(isAiAvailable = hasAi, isAiMode = hasAi) } - loadGaps() + settingsRepository.isAiAvailableFlow().collect { hasAi -> + _state.update { it.copy(isAiAvailable = hasAi, isAiMode = hasAi) } + loadGaps() + } } } diff --git a/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/TryItViewModel.kt b/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/TryItViewModel.kt index 28787a2..f75cecd 100644 --- a/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/TryItViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/TryItViewModel.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -65,7 +66,7 @@ class TryItViewModel( val effects: Flow = _effects.receiveAsFlow() init { - viewModelScope.launch { loadCredentialState() } + observeCredentials() viewModelScope.launch { settingsRepository.getModelPhoto().onSuccess { bytes -> _state.update { it.copy(personImage = bytes) } @@ -93,9 +94,26 @@ class TryItViewModel( } } + /** + * Keys are entered on the Settings screen while this ViewModel stays alive — both platforms + * keep every tab's screen in memory — so the gate has to follow the credentials rather than + * sample them once at construction. + */ + private fun observeCredentials() { + viewModelScope.launch { + combine( + settingsRepository.hasApiKeyFlow(), + settingsRepository.hasYouCamCredentialsFlow(), + ::Pair, + ).collect { (hasApiKey, hasYouCamKey) -> + _state.update { it.copy(hasApiKey = hasApiKey, hasYouCamKey = hasYouCamKey) } + } + } + } + /** * Reads both credential flags into state and returns them, so callers that need to branch on - * them do not race the [init] load. + * them do not race the [observeCredentials] collector's first emission. */ private suspend fun loadCredentialState(): Pair { val hasApiKey = settingsRepository.hasApiKey().getOrDefault(false) diff --git a/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/WardrobeViewModel.kt b/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/WardrobeViewModel.kt index 02a5d28..e165b51 100644 --- a/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/WardrobeViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/WardrobeViewModel.kt @@ -94,13 +94,15 @@ class WardrobeViewModel( }.stateIn(viewModelScope, SharingStarted.Eagerly, WardrobeState(isLoading = true)) init { - refreshAiAvailability() + observeAiAvailability() } - private fun refreshAiAvailability() { + /** Collected rather than read once: a key added on Settings must unlock this screen live. */ + private fun observeAiAvailability() { viewModelScope.launch { - val isAiAvailable = settingsRepository.isAiAvailable().getOrDefault(false) - _uiState.update { it.copy(isAiAvailable = isAiAvailable) } + settingsRepository.isAiAvailableFlow().collect { isAiAvailable -> + _uiState.update { it.copy(isAiAvailable = isAiAvailable) } + } } } diff --git a/shared/src/commonTest/kotlin/com/github/worn/fake/FakeSettingsRepository.kt b/shared/src/commonTest/kotlin/com/github/worn/fake/FakeSettingsRepository.kt index d883e02..89d954a 100644 --- a/shared/src/commonTest/kotlin/com/github/worn/fake/FakeSettingsRepository.kt +++ b/shared/src/commonTest/kotlin/com/github/worn/fake/FakeSettingsRepository.kt @@ -12,13 +12,33 @@ import com.github.worn.domain.model.isUsable import com.github.worn.domain.repository.SettingsRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update class FakeSettingsRepository : SettingsRepository { val profile = MutableStateFlow(UserProfile()) val modelPhoto = MutableStateFlow(null) + + /** Mirrors the real repository's tick, so tests can assign the fields below and see it emit. */ + private val credentialRevision = MutableStateFlow(0) + var apiKey: String? = null + set(value) { + field = value + credentialRevision.update { it + 1 } + } var youCamClientId: String? = null + set(value) { + field = value + credentialRevision.update { it + 1 } + } var youCamClientSecret: String? = null + set(value) { + field = value + credentialRevision.update { it + 1 } + } val onDeviceAiEnabled = MutableStateFlow(false) var onDeviceAiAvailability: OnDeviceAiAvailability = OnDeviceAiAvailability.Unavailable(OnDeviceAiUnavailableReason.UNSUPPORTED_DEVICE) @@ -71,6 +91,18 @@ class FakeSettingsRepository : SettingsRepository { return Result.success(Unit) } + override fun hasApiKeyFlow(): Flow = credentialRevision + .map { apiKey != null } + .distinctUntilChanged() + + override fun hasYouCamCredentialsFlow(): Flow = credentialRevision + .map { !youCamClientId.isNullOrBlank() && !youCamClientSecret.isNullOrBlank() } + .distinctUntilChanged() + + override fun isAiAvailableFlow(): Flow = + combine(credentialRevision, onDeviceAiEnabled) { _, _ -> isAiAvailable().getOrDefault(false) } + .distinctUntilChanged() + override fun isOnDeviceAiEnabled(): Flow = onDeviceAiEnabled override suspend fun setOnDeviceAiEnabled(enabled: Boolean): Result { diff --git a/shared/src/commonTest/kotlin/com/github/worn/viewmodel/TryItViewModelTest.kt b/shared/src/commonTest/kotlin/com/github/worn/viewmodel/TryItViewModelTest.kt index cfa006b..61156a4 100644 --- a/shared/src/commonTest/kotlin/com/github/worn/viewmodel/TryItViewModelTest.kt +++ b/shared/src/commonTest/kotlin/com/github/worn/viewmodel/TryItViewModelTest.kt @@ -68,6 +68,38 @@ class TryItViewModelTest { assertFalse(vm.state.value.hasYouCamKey) } + @Test + fun `YouCam credentials saved after construction unlock the screen without a restart`() { + val vm = createViewModel() + assertFalse(vm.state.value.hasYouCamKey) + + settings.youCamClientId = "id" + settings.youCamClientSecret = "secret" + + assertTrue(vm.state.value.hasYouCamKey) + } + + @Test + fun `clearing YouCam credentials after construction re-locks the screen`() { + settings.youCamClientId = "id" + settings.youCamClientSecret = "secret" + val vm = createViewModel() + + settings.youCamClientSecret = null + + assertFalse(vm.state.value.hasYouCamKey) + } + + @Test + fun `a Claude key saved after construction unlocks analysis without a restart`() { + val vm = createViewModel() + assertFalse(vm.state.value.hasApiKey) + + settings.apiKey = "test-key" + + assertTrue(vm.state.value.hasApiKey) + } + @Test fun `init loads the saved person photo`() { settings.modelPhoto.value = byteArrayOf(1, 2) diff --git a/shared/src/commonTest/kotlin/com/github/worn/viewmodel/WardrobeViewModelTest.kt b/shared/src/commonTest/kotlin/com/github/worn/viewmodel/WardrobeViewModelTest.kt index 865f616..ac4bef1 100644 --- a/shared/src/commonTest/kotlin/com/github/worn/viewmodel/WardrobeViewModelTest.kt +++ b/shared/src/commonTest/kotlin/com/github/worn/viewmodel/WardrobeViewModelTest.kt @@ -56,6 +56,16 @@ class WardrobeViewModelTest { assertTrue(vm.state.value.isAiAvailable) } + @Test + fun `a Claude key saved after construction flips isAiAvailable without a restart`() { + val vm = createViewModel() + assertFalse(vm.state.value.isAiAvailable) + + settingsRepository.apiKey = "test-key" + + assertTrue(vm.state.value.isAiAvailable) + } + @Test fun `init sets isAiAvailable false when there is no provider at all`() { settingsRepository.apiKey = null