Skip to content
Closed
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 @@ -6,6 +6,11 @@ package mozilla.components.concept.ai.controls

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.update
import mozilla.components.concept.ai.controls.AIFeatureState.Disabled
import mozilla.components.concept.ai.controls.AIFeatureState.Enabled
import mozilla.components.concept.ai.controls.AIFeatureState.Unknown

/**
* Metadata defining an AI Feature.
Expand All @@ -30,11 +35,39 @@ interface AIFeatureMetadata {
val description: Description
}

/**
* Describes the state of the AI feature. Represents all the possible states as
* [Enabled], [Disabled] and [Unknown].
*/
sealed class AIFeatureState {

/**
* Describes the state when the AI feature has been enabled.
*/
object Enabled : AIFeatureState()

/**
* Describes the state where the AI feature has been disabled.
*/
object Disabled : AIFeatureState()

/**
* Describes the state where it is not known if the AI feature is enabled or disabled.
* This state is likely to occur if we check the AI controls state before the underlying
* AI feature is able to say whether it's enabled or not.
*
* Because it is possible for the [AIFeatureBlock.isBlocked] to be true, for a specific feature
* to be turned on, we need to know if that feature is explicitly turned on or if the user
* has simply never interacted with the AI controls, and that is when this comes in handy.
*/
object Unknown : AIFeatureState()
}

/**
* A feature that can be enabled or disabled by AI controls.
*/
interface AIControllableFeature : AIFeatureMetadata {
val isEnabled: Flow<Boolean>
val featureState: Flow<AIFeatureState>

/**
* Enables or disables this feature.
Expand All @@ -48,20 +81,38 @@ interface AIControllableFeature : AIFeatureMetadata {
fun inMemory(
id: AIFeatureMetadata.FeatureId = AIFeatureMetadata.FeatureId("inMemory"),
description: AIFeatureMetadata.Description = AIFeatureMetadata.Description(0, 0, 0),
initialEnabled: Boolean = false,
): AIControllableFeature = InMemoryAIControllableFeature(id, description, initialEnabled)
initialFeatureState: AIFeatureState = AIFeatureState.Unknown,
): AIControllableFeature = InMemoryAIControllableFeature(id, description, initialFeatureState)
}
}

/**
* Convenience function for mapping [AIControllableFeature.featureState] to a [Flow] of non-null [Boolean] values.
* [AIFeatureState.Unknown] is resolved as not enabled
*/
val AIControllableFeature.isEnabled: Flow<Boolean>
get() = featureState.mapNotNull {
when (it) {
is Enabled -> true
is Disabled -> false
is Unknown -> null
}
}

private class InMemoryAIControllableFeature(
override val id: AIFeatureMetadata.FeatureId,
override val description: AIFeatureMetadata.Description,
initialEnabled: Boolean,
initialFeatureState: AIFeatureState,
) : AIControllableFeature {
private val _isEnabled = MutableStateFlow(initialEnabled)
override val isEnabled: Flow<Boolean> = _isEnabled

private val _featureState =
MutableStateFlow(initialFeatureState)
override val featureState: Flow<AIFeatureState>
get() = _featureState

override suspend fun set(enabled: Boolean) {
_isEnabled.value = enabled
_featureState.update {
if (enabled) Enabled else Disabled
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
package mozilla.components.feature.summarize

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import mozilla.components.concept.ai.controls.AIControllableFeature
import mozilla.components.concept.ai.controls.AIFeatureMetadata
import mozilla.components.concept.ai.controls.AIFeatureState
import mozilla.components.feature.summarize.settings.SummarizationSettings
import mozilla.components.ui.icons.R as iconsR

Expand All @@ -19,7 +19,15 @@ class PageSummaryFeature(
private val settings: SummarizationSettings,
) : AIControllableFeature, AIFeatureMetadata by Companion {

override val isEnabled: Flow<Boolean> = flow { emitAll(settings.getFeatureEnabledUserStatus()) }
override val featureState: Flow<AIFeatureState>
get() = settings.getFeatureEnabledUserStatus()
.map { enabledState ->
when {
enabledState == null -> AIFeatureState.Unknown
enabledState -> AIFeatureState.Enabled
else -> AIFeatureState.Disabled
}
}

override suspend fun set(enabled: Boolean) {
settings.setFeatureEnabledUserStatus(enabled)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,13 @@ interface SummarizationSettings {
/**
* @return A [Flow] emitting the user's current preference for whether the summarization
* feature is enabled.
*
* - It emits true when the feature is enabled
* - false when it's not, or
* - null when we don't know this means that the preference for this feature has never been set -
* either by the user, or by the app.
*/
suspend fun getFeatureEnabledUserStatus(): Flow<Boolean>
fun getFeatureEnabledUserStatus(): Flow<Boolean?>

/**
* Persists the user's preference for whether the summarization feature is enabled.
Expand Down Expand Up @@ -81,7 +86,7 @@ interface SummarizationSettings {
* @return An in-memory [SummarizationSettings] instance.
*/
fun inMemory(
isFeatureEnabled: Boolean = false,
isFeatureEnabled: Boolean? = null,
isGestureEnabled: Boolean = false,
hasConsentedToShake: Boolean = false,
shakeConsentRejectedCount: Int = 0,
Expand All @@ -94,7 +99,7 @@ interface SummarizationSettings {
MutableStateFlow(hasConsentedToShake)
private var shakeConsentRejectedCount = 0

override suspend fun getFeatureEnabledUserStatus(): Flow<Boolean> = isFeatureEnabledFlow
override fun getFeatureEnabledUserStatus(): Flow<Boolean?> = isFeatureEnabledFlow

override suspend fun setFeatureEnabledUserStatus(newValue: Boolean) {
isFeatureEnabledFlow.emit(newValue)
Expand Down Expand Up @@ -140,8 +145,8 @@ internal class DataStoreBackedSettings(private val dataStore: DataStore<Preferen
private val hasConsentedToShakeKey = booleanPreferencesKey("has_consented_to_shake_key")
private val shakeConsentRejectedCountKey = intPreferencesKey("shake_consent_rejected_count_key")

override suspend fun getFeatureEnabledUserStatus(): Flow<Boolean> = dataStore.data.map { preferences ->
preferences[featureEnabledKey] ?: true
override fun getFeatureEnabledUserStatus(): Flow<Boolean?> = dataStore.data.map { preferences ->
preferences[featureEnabledKey]
}

override suspend fun setFeatureEnabledUserStatus(newValue: Boolean) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import mozilla.components.lib.state.Store
/**
* Middleware for the summarize settings screen that persists preference changes.
*
* @param settings The [SummarizationFeatureSettings] to persist preference changes to.
* @param settings The [SummarizationSettings] to persist preference changes to.
* @param onLearnMoreClicked Callback invoked when the learn more link is clicked.
*/
class SummarizeSettingsMiddleware(
Expand All @@ -35,7 +35,7 @@ class SummarizeSettingsMiddleware(
ViewAppeared -> scope.launch {
store.dispatch(
SettingsLoaded(
isFeatureEnabled = settings.getFeatureEnabledUserStatus().first(),
isFeatureEnabled = settings.getFeatureEnabledUserStatus().first() == true,
isGestureEnabled = settings.getGestureEnabledUserStatus().first(),
),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package mozilla.components.feature.summarize

import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import mozilla.components.concept.ai.controls.isEnabled
import mozilla.components.feature.summarize.settings.SummarizationSettings
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ package mozilla.components.feature.summarize.settings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertFalse
Expand Down Expand Up @@ -41,7 +39,7 @@ class PageSummariesSettingsMiddlewareTest {
store.dispatch(SummarizePagesPreferenceToggled)
this.runCurrent()

assertTrue(settings.getFeatureEnabledUserStatus().first())
assertTrue(settings.getFeatureEnabledUserStatus().first() == true)
}

@Test
Expand All @@ -59,7 +57,7 @@ class PageSummariesSettingsMiddlewareTest {
store.dispatch(SummarizePagesPreferenceToggled)
this.runCurrent()

assertFalse(settings.getFeatureEnabledUserStatus().first())
assertFalse(settings.getFeatureEnabledUserStatus().first() == true)
}

@Test
Expand All @@ -77,7 +75,7 @@ class PageSummariesSettingsMiddlewareTest {
store.dispatch(ShakeToSummarizePreferenceToggled)
this.runCurrent()

assertTrue(settings.getFeatureEnabledUserStatus().first())
assertTrue(settings.getFeatureEnabledUserStatus().first() == true)
}

@Test
Expand Down Expand Up @@ -113,7 +111,7 @@ class PageSummariesSettingsMiddlewareTest {
store.dispatch(SummarizePagesPreferenceToggled)
this.runCurrent()

assertFalse(settings.getFeatureEnabledUserStatus().first())
assertFalse(settings.getFeatureEnabledUserStatus().first() == true)
assertTrue(settings.getGestureEnabledUserStatus().first())
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,27 @@ import mozilla.components.support.test.fakes.android.FakePreferencesDataStore
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.test.assertNull

class SummarizationSettingsTest {

@Test
fun `that user preference for feature returns null if the value has never been set`() =
runTest {
val dataStore = FakePreferencesDataStore()
val settings = DataStoreBackedSettings(dataStore)

assertNull(
settings.getFeatureEnabledUserStatus().first(),
"Expected initial preference to be null because it has not been previous set",
)
}

@Test
fun `that user preference for feature is persisted`() = runTest {
val dataStore = FakePreferencesDataStore()
val settings = DataStoreBackedSettings(dataStore)

assertTrue(settings.getFeatureEnabledUserStatus().first())
settings.setFeatureEnabledUserStatus(false)
assertFalse(settings.getHasConsentedToShake().first())
}
Expand All @@ -27,7 +40,6 @@ class SummarizationSettingsTest {
val dataStore = FakePreferencesDataStore()
val settings = DataStoreBackedSettings(dataStore)

assertTrue(settings.getGestureEnabledUserStatus().first())
settings.setGestureEnabledUserStatus(false)
assertFalse(settings.getHasConsentedToShake().first())
}
Expand All @@ -37,7 +49,6 @@ class SummarizationSettingsTest {
val dataStore = FakePreferencesDataStore()
val settings = DataStoreBackedSettings(dataStore)

assertFalse(settings.getHasConsentedToShake().first())
settings.setHasConsentedToShake(true)
assertTrue(settings.getHasConsentedToShake().first())
}
Expand All @@ -47,7 +58,6 @@ class SummarizationSettingsTest {
val dataStore = FakePreferencesDataStore()
val settings = DataStoreBackedSettings(dataStore)

assertTrue(settings.getGestureEnabledUserStatus().first())
settings.incrementShakeConsentRejectedCount()
settings.incrementShakeConsentRejectedCount()
settings.incrementShakeConsentRejectedCount()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,48 @@

package mozilla.components.lib.ai.controls

import android.content.Context
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import mozilla.components.concept.ai.controls.AIControllableFeature
import mozilla.components.concept.ai.controls.AIFeatureMetadata
import mozilla.components.concept.ai.controls.AIFeatureRegistry
import mozilla.components.concept.ai.controls.AIFeatureState

/**
* Creates the default implementation of [AIFeatureRegistry], which enforces unique feature IDs.
* Creates the implementation of [AIFeatureRegistry], which enforces unique feature IDs.
*/
fun AIFeatureRegistry.Companion.default() = object : AIFeatureRegistry {
fun AIFeatureRegistry.Companion.default(
scope: CoroutineScope,
context: Context,
): AIFeatureRegistry =
DefaultAIFeatureRegistry(scope, AIFeatureBlockStorage.dataStore(context))

/**
* Default implementation of [AIFeatureRegistry] that enforces unique feature IDs and
* initializes feature states based on the block status stored in [AIFeatureBlockStorage].
*/
internal class DefaultAIFeatureRegistry(
private val scope: CoroutineScope,
private val storage: AIFeatureBlockStorage,
) : AIFeatureRegistry {
// LinkedHashMap allows us to maintain the order for later use.
private val features = LinkedHashMap<AIFeatureMetadata.FeatureId, AIControllableFeature>()

override fun register(feature: AIControllableFeature) {
check(feature.id !in features.keys) {
"AI feature with id=${feature.id} is already registered"
}

scope.launch(Dispatchers.IO) {
if (feature.featureState.first() is AIFeatureState.Unknown) {
val aiFeaturesBlocked = storage.isBlocked.first()
feature.set(!aiFeaturesBlocked)
}
}

features[feature.id] = feature
}

Expand Down
Loading
Loading