Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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<UserProfile> = dataStore.data.map { prefs ->
UserProfile(
bodyType = prefs[KEY_BODY_TYPE]?.let {
Expand Down Expand Up @@ -142,11 +154,17 @@ class SettingsRepositoryImpl(
}

override suspend fun saveApiKey(key: String): Result<Unit> = runCatching {
withContext(dispatcher) { secretStore.saveApiKey(key) }
withContext(dispatcher) {
secretStore.saveApiKey(key)
notifyCredentialChange()
}
}

override suspend fun clearApiKey(): Result<Unit> = runCatching {
withContext(dispatcher) { secretStore.clearApiKey() }
withContext(dispatcher) {
secretStore.clearApiKey()
notifyCredentialChange()
}
}

override suspend fun hasYouCamCredentials(): Result<Boolean> = runCatching {
Expand All @@ -163,16 +181,29 @@ class SettingsRepositoryImpl(
withContext(dispatcher) {
secretStore.saveSecret(SecretStore.YOUCAM_CLIENT_ID, clientId)
secretStore.saveSecret(SecretStore.YOUCAM_CLIENT_SECRET, clientSecret)
notifyCredentialChange()
}
}

override suspend fun clearYouCamCredentials(): Result<Unit> = runCatching {
withContext(dispatcher) {
secretStore.clearSecret(SecretStore.YOUCAM_CLIENT_ID)
secretStore.clearSecret(SecretStore.YOUCAM_CLIENT_SECRET)
notifyCredentialChange()
}
}

override fun hasApiKeyFlow(): Flow<Boolean> = credentialRevision
.map { hasApiKey().getOrDefault(false) }
.distinctUntilChanged()

override fun hasYouCamCredentialsFlow(): Flow<Boolean> = 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<Boolean> =
dataStore.data.map { it[KEY_ON_DEVICE_AI] == true }

Expand All @@ -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<Boolean> =
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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ interface SettingsRepository {
suspend fun saveYouCamCredentials(clientId: String, clientSecret: String): Result<Unit>
suspend fun clearYouCamCredentials(): Result<Unit>

// 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<Boolean>
fun hasYouCamCredentialsFlow(): Flow<Boolean>

/** Whether the user has opted into running AI on the device instead of calling Claude. */
fun isOnDeviceAiEnabled(): Flow<Boolean>
suspend fun setOnDeviceAiEnabled(enabled: Boolean): Result<Unit>
Expand All @@ -44,4 +50,7 @@ interface SettingsRepository {
* is both enabled and available. Gates the AI features in Wardrobe and Gaps.
*/
suspend fun isAiAvailable(): Result<Boolean>

/** Observable [isAiAvailable], for the same reason as [hasApiKeyFlow]. */
fun isAiAvailableFlow(): Flow<Boolean>
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -65,7 +66,7 @@ class TryItViewModel(
val effects: Flow<TryItEffect> = _effects.receiveAsFlow()

init {
viewModelScope.launch { loadCredentialState() }
observeCredentials()
viewModelScope.launch {
settingsRepository.getModelPhoto().onSuccess { bytes ->
_state.update { it.copy(personImage = bytes) }
Expand Down Expand Up @@ -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<Boolean, Boolean> {
val hasApiKey = settingsRepository.hasApiKey().getOrDefault(false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ByteArray?>(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)
Expand Down Expand Up @@ -71,6 +91,18 @@ class FakeSettingsRepository : SettingsRepository {
return Result.success(Unit)
}

override fun hasApiKeyFlow(): Flow<Boolean> = credentialRevision
.map { apiKey != null }
.distinctUntilChanged()

override fun hasYouCamCredentialsFlow(): Flow<Boolean> = credentialRevision
.map { !youCamClientId.isNullOrBlank() && !youCamClientSecret.isNullOrBlank() }
.distinctUntilChanged()

override fun isAiAvailableFlow(): Flow<Boolean> =
combine(credentialRevision, onDeviceAiEnabled) { _, _ -> isAiAvailable().getOrDefault(false) }
.distinctUntilChanged()

override fun isOnDeviceAiEnabled(): Flow<Boolean> = onDeviceAiEnabled

override suspend fun setOnDeviceAiEnabled(enabled: Boolean): Result<Unit> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading