diff --git a/README.md b/README.md index 0c1ff9a2..a67688ea 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,47 @@ The app stores your settings behind a content provider, ensuring configurations ## Advanced Examples +### Scoped Toggles (Per-User Feature Flags) + +Want to enable different features for different users or environments? Use scoped instances: + +```kotlin +import se.eelde.toggles.flow.TogglesImpl + +class MultiUserActivity : AppCompatActivity() { + // Create separate toggle instances for each user + private val adminToggles = TogglesImpl(this, scope = "admin") + private val guestToggles = TogglesImpl(this, scope = "guest") + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + lifecycleScope.launch { + // Admin sees advanced features + adminToggles.toggle("advanced_mode", false).collect { enabled -> + if (enabled) showAdminFeatures() + } + } + + lifecycleScope.launch { + // Guest has limited features + guestToggles.toggle("advanced_mode", false).collect { enabled -> + // Will use guest scope value + if (enabled) showAdminFeatures() + } + } + } +} +``` + +**Use cases for scoped toggles:** +- Per-user feature flags (admin vs regular users) +- A/B testing with different user groups +- Environment-specific settings (dev, staging, prod) +- Multi-tenant applications + +The scope parameter is optional. If not provided, the system uses the currently selected scope in the Toggles app. + ### Working with Enums [![Flow](https://maven-badges.herokuapp.com/maven-central/se.eelde.toggles/toggles-flow/badge.png)](https://maven-badges.herokuapp.com/maven-central/se.eelde.toggles/toggles-flow) @@ -108,12 +149,18 @@ implementation("se.eelde.toggles:toggles-prefs:0.0.2") ```kotlin import se.eelde.toggles.prefs.TogglesPreferences +import se.eelde.toggles.prefs.TogglesPreferencesImpl -val togglesPrefs = TogglesPreferences(context) +// Default scope +val togglesPrefs = TogglesPreferencesImpl(context) val isEnabled = togglesPrefs.getBoolean("feature_toggle_key", false) + +// With custom scope for per-user toggles +val userToggles = TogglesPreferencesImpl(context, scope = "user_123") +val userFeature = userToggles.getBoolean("feature_toggle_key", false) ``` -**Note:** Consider using toggles-flow for reactive updates and better integration with modern Android development patterns. +**Note:** Consider using toggles-flow for reactive updates and better integration with modern Android development patterns. Scoped toggles are also supported in the prefs library. ### Toggles-core library (Advanced) [![Core](https://maven-badges.herokuapp.com/maven-central/se.eelde.toggles/toggles-core/badge.png)](https://maven-badges.herokuapp.com/maven-central/se.eelde.toggles/toggles-core) diff --git a/modules/database/implementation/src/main/java/se/eelde/toggles/database/dao/provider/ProviderScopeDao.kt b/modules/database/implementation/src/main/java/se/eelde/toggles/database/dao/provider/ProviderScopeDao.kt index 3066aa3e..684a8373 100644 --- a/modules/database/implementation/src/main/java/se/eelde/toggles/database/dao/provider/ProviderScopeDao.kt +++ b/modules/database/implementation/src/main/java/se/eelde/toggles/database/dao/provider/ProviderScopeDao.kt @@ -29,6 +29,11 @@ interface ProviderScopeDao { ) fun getDefaultScope(applicationId: Long): TogglesScope? + @Query( + "SELECT * FROM " + ScopeTable.TABLE_NAME + " WHERE " + ScopeTable.COL_APP_ID + " = (:applicationId) AND " + ScopeTable.COL_NAME + " = (:scopeName)" + ) + fun getScopeByName(applicationId: Long, scopeName: String): TogglesScope? + @Update suspend fun update(scope: TogglesScope) } diff --git a/modules/provider/implementation/src/main/java/se/eelde/toggles/provider/TogglesProvider.kt b/modules/provider/implementation/src/main/java/se/eelde/toggles/provider/TogglesProvider.kt index d862f6b2..83d724e5 100644 --- a/modules/provider/implementation/src/main/java/se/eelde/toggles/provider/TogglesProvider.kt +++ b/modules/provider/implementation/src/main/java/se/eelde/toggles/provider/TogglesProvider.kt @@ -124,7 +124,7 @@ class TogglesProvider : ContentProvider() { when (togglesUriMatcher.match(uri)) { UriMatch.CURRENT_CONFIGURATION_ID -> { - val scope = getSelectedScope(scopeDao, callingApplication.id) + val scope = getScopeFromUri(uri, scopeDao, callingApplication.id) cursor = configurationDao.getToggle( java.lang.Long.valueOf(uri.lastPathSegment!!), scope.id @@ -142,7 +142,7 @@ class TogglesProvider : ContentProvider() { } UriMatch.CURRENT_CONFIGURATION_KEY -> { - val scope = getSelectedScope(scopeDao, callingApplication.id) + val scope = getScopeFromUri(uri, scopeDao, callingApplication.id) cursor = configurationDao.getToggle(uri.lastPathSegment!!, scope.id) if (cursor.count == 0) { @@ -485,6 +485,21 @@ class TogglesProvider : ContentProvider() { ): TogglesScope = scopeDao.getSelectedScope(applicationId) ?: error("No selected scope for application $applicationId") + @Synchronized + private fun getScopeFromUri( + uri: Uri, + scopeDao: ProviderScopeDao, + applicationId: Long + ): TogglesScope { + val scopeName = uri.getQueryParameter("SCOPE") + return if (scopeName != null) { + scopeDao.getScopeByName(applicationId, scopeName) + ?: getDefaultScope(scopeDao, applicationId) + } else { + getSelectedScope(scopeDao, applicationId) + } + } + private fun createDefaultScope( scopeDao: ProviderScopeDao, applicationId: Long diff --git a/modules/provider/implementation/src/test/java/se/eelde/toggles/provider/ScopedTogglesProviderTest.kt b/modules/provider/implementation/src/test/java/se/eelde/toggles/provider/ScopedTogglesProviderTest.kt new file mode 100644 index 00000000..cf42c6bb --- /dev/null +++ b/modules/provider/implementation/src/test/java/se/eelde/toggles/provider/ScopedTogglesProviderTest.kt @@ -0,0 +1,227 @@ +package se.eelde.toggles.provider + +import android.app.Application +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.HiltTestApplication +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import se.eelde.toggles.core.Toggle +import se.eelde.toggles.core.TogglesProviderContract +import se.eelde.toggles.database.TogglesDatabase +import se.eelde.toggles.database.TogglesScope +import java.util.Date +import javax.inject.Inject + +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +@Config(application = HiltTestApplication::class, sdk = [Build.VERSION_CODES.P]) +class ScopedTogglesProviderTest { + @get:Rule + var hiltRule = HiltAndroidRule(this) + + private lateinit var togglesProvider: TogglesProvider + + @Inject + lateinit var togglesDatabase: TogglesDatabase + + @Before + fun setUp() { + hiltRule.inject() + + val contentProviderController = + Robolectric.buildContentProvider(TogglesProvider::class.java) + .create("se.eelde.toggles.configprovider") + togglesProvider = contentProviderController.get() + + val context = ApplicationProvider.getApplicationContext() + shadowOf(context.packageManager).setApplicationIcon( + context.applicationInfo.packageName, + ColorDrawable(Color.RED) + ) + } + + @Test + fun testQueryWithScopeParameter() { + val toggleKey = "scopedToggleKey" + + // Insert a toggle (will go to default scope) + val uri = TogglesProviderContract.toggleUri() + val insertToggle = getToggle(toggleKey, "default_value") + val insertToggleUri = togglesProvider.insert(uri, insertToggle.toContentValues()) + assertNotNull(insertToggleUri) + + // Create a custom scope + val customScope = TogglesScope( + id = 0, + applicationId = 1, + name = "custom_scope", + timeStamp = Date() + ) + customScope.id = togglesDatabase.togglesScopeDao().insert(customScope) + + // Insert a different value for the custom scope + val config = togglesDatabase.togglesConfigurationDao() + .getTogglesConfiguration(1, toggleKey) + assertNotNull(config) + + togglesDatabase.togglesConfigurationValueDao().insert( + se.eelde.toggles.database.TogglesConfigurationValue( + id = 0, + configurationId = config!!.id, + value = "custom_value", + scope = customScope.id + ) + ) + + // Query without scope parameter - should get default scope value + var cursor = togglesProvider.query( + TogglesProviderContract.toggleUri(toggleKey), + null, + null, + null, + null + ) + assertNotNull(cursor) + assertTrue(cursor.moveToFirst()) + var toggle = Toggle.fromCursor(cursor) + assertEquals("default_value", toggle.value) + cursor.close() + + // Query with custom scope parameter - should get custom scope value + cursor = togglesProvider.query( + TogglesProviderContract.toggleUri(toggleKey, "custom_scope"), + null, + null, + null, + null + ) + assertNotNull(cursor) + assertTrue(cursor.moveToFirst()) + toggle = Toggle.fromCursor(cursor) + assertEquals("custom_value", toggle.value) + cursor.close() + } + + @Test + fun testQueryWithNonExistentScope() { + val toggleKey = "nonExistentScopeKey" + + // Insert a toggle in default scope + val uri = TogglesProviderContract.toggleUri() + val insertToggle = getToggle(toggleKey, "default_value") + val insertToggleUri = togglesProvider.insert(uri, insertToggle.toContentValues()) + assertNotNull(insertToggleUri) + + // Query with non-existent scope - should fall back to default scope + val cursor = togglesProvider.query( + TogglesProviderContract.toggleUri(toggleKey, "nonexistent_scope"), + null, + null, + null, + null + ) + assertNotNull(cursor) + assertTrue(cursor.moveToFirst()) + val toggle = Toggle.fromCursor(cursor) + assertEquals("default_value", toggle.value) + cursor.close() + } + + @Test + fun testMultipleScopesWithDifferentValues() { + val toggleKey = "multiScopeKey" + + // Insert base toggle + val uri = TogglesProviderContract.toggleUri() + val insertToggle = getToggle(toggleKey, "value_default") + togglesProvider.insert(uri, insertToggle.toContentValues()) + + // Create scope1 + val scope1 = TogglesScope( + id = 0, + applicationId = 1, + name = "scope1", + timeStamp = Date() + ) + scope1.id = togglesDatabase.togglesScopeDao().insert(scope1) + + // Create scope2 + val scope2 = TogglesScope( + id = 0, + applicationId = 1, + name = "scope2", + timeStamp = Date() + ) + scope2.id = togglesDatabase.togglesScopeDao().insert(scope2) + + // Get configuration + val config = togglesDatabase.togglesConfigurationDao() + .getTogglesConfiguration(1, toggleKey) + assertNotNull(config) + + // Insert different values for each scope + togglesDatabase.togglesConfigurationValueDao().insert( + se.eelde.toggles.database.TogglesConfigurationValue( + id = 0, + configurationId = config!!.id, + value = "value_scope1", + scope = scope1.id + ) + ) + + togglesDatabase.togglesConfigurationValueDao().insert( + se.eelde.toggles.database.TogglesConfigurationValue( + id = 0, + configurationId = config.id, + value = "value_scope2", + scope = scope2.id + ) + ) + + // Verify each scope returns its own value + var cursor = togglesProvider.query( + TogglesProviderContract.toggleUri(toggleKey, "scope1"), + null, + null, + null, + null + ) + assertTrue(cursor.moveToFirst()) + assertEquals("value_scope1", Toggle.fromCursor(cursor).value) + cursor.close() + + cursor = togglesProvider.query( + TogglesProviderContract.toggleUri(toggleKey, "scope2"), + null, + null, + null, + null + ) + assertTrue(cursor.moveToFirst()) + assertEquals("value_scope2", Toggle.fromCursor(cursor).value) + cursor.close() + } + + private fun getToggle(key: String, value: String): Toggle { + return Toggle { + id = 0L + type = Toggle.TYPE.STRING + this.key = key + this.value = value + } + } +} diff --git a/toggles-core/src/main/java/se/eelde/toggles/core/TogglesProviderContract.kt b/toggles-core/src/main/java/se/eelde/toggles/core/TogglesProviderContract.kt index e5a2df65..257a3218 100644 --- a/toggles-core/src/main/java/se/eelde/toggles/core/TogglesProviderContract.kt +++ b/toggles-core/src/main/java/se/eelde/toggles/core/TogglesProviderContract.kt @@ -6,6 +6,7 @@ public object TogglesProviderContract { private const val TOGGLES_AUTHORITY = "se.eelde.toggles.configprovider" private const val TOGGLES_API_VERSION_QUERY_PARAM = "API_VERSION" private const val TOGGLES_API_VERSION = 1 + private const val TOGGLES_SCOPE_QUERY_PARAM = "SCOPE" private val configurationUri: Uri = Uri.parse("content://$TOGGLES_AUTHORITY/configuration") @@ -25,6 +26,18 @@ public object TogglesProviderContract { .build() } + @JvmStatic + public fun toggleUri(id: Long, scope: String?): Uri { + val builder = configurationValueUri + .buildUpon() + .appendPath(id.toString()) + .appendQueryParameter(TOGGLES_API_VERSION_QUERY_PARAM, TOGGLES_API_VERSION.toString()) + if (scope != null) { + builder.appendQueryParameter(TOGGLES_SCOPE_QUERY_PARAM, scope) + } + return builder.build() + } + @JvmStatic public fun toggleUri(key: String): Uri { return configurationValueUri @@ -34,6 +47,18 @@ public object TogglesProviderContract { .build() } + @JvmStatic + public fun toggleUri(key: String, scope: String?): Uri { + val builder = configurationValueUri + .buildUpon() + .appendPath(key) + .appendQueryParameter(TOGGLES_API_VERSION_QUERY_PARAM, TOGGLES_API_VERSION.toString()) + if (scope != null) { + builder.appendQueryParameter(TOGGLES_SCOPE_QUERY_PARAM, scope) + } + return builder.build() + } + @JvmStatic public fun toggleUri(): Uri { return configurationValueUri @@ -42,6 +67,17 @@ public object TogglesProviderContract { .build() } + @JvmStatic + public fun toggleUri(scope: String?): Uri { + val builder = configurationValueUri + .buildUpon() + .appendQueryParameter(TOGGLES_API_VERSION_QUERY_PARAM, TOGGLES_API_VERSION.toString()) + if (scope != null) { + builder.appendQueryParameter(TOGGLES_SCOPE_QUERY_PARAM, scope) + } + return builder.build() + } + @JvmStatic public fun toggleValueUri(): Uri { return predefinedConfigurationvalueUri diff --git a/toggles-flow/src/main/java/se/eelde/toggles/flow/TogglesImpl.kt b/toggles-flow/src/main/java/se/eelde/toggles/flow/TogglesImpl.kt index 94e7ee44..6273f0ce 100644 --- a/toggles-flow/src/main/java/se/eelde/toggles/flow/TogglesImpl.kt +++ b/toggles-flow/src/main/java/se/eelde/toggles/flow/TogglesImpl.kt @@ -26,6 +26,7 @@ import se.eelde.toggles.core.TogglesProviderContract.toggleValueUri @Suppress("LibraryEntitiesShouldNotBePublic") public class TogglesImpl( context: Context, + private val scope: String? = null, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO ) : Toggles { private val context = context.applicationContext @@ -159,7 +160,7 @@ public class TogglesImpl( var cursor: Cursor? = null try { cursor = contentResolver.query( - toggleUri(key), + toggleUri(key, scope), null, null, null, diff --git a/toggles-flow/src/test/java/se/eelde/toggles/flow/ScopedFlowTest.kt b/toggles-flow/src/test/java/se/eelde/toggles/flow/ScopedFlowTest.kt new file mode 100644 index 00000000..17cef173 --- /dev/null +++ b/toggles-flow/src/test/java/se/eelde/toggles/flow/ScopedFlowTest.kt @@ -0,0 +1,150 @@ +package se.eelde.toggles.flow + +import android.app.Application +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import se.eelde.toggles.database.FakeTogglesDatabase +import se.eelde.toggles.database.TogglesDatabase +import se.eelde.toggles.database.TogglesScope +import se.eelde.toggles.provider.RobolectricTogglesProvider +import se.eelde.toggles.provider.TogglesProvider +import java.util.Date + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [Build.VERSION_CODES.P]) +internal class ScopedFlowTest { + private val context = ApplicationProvider.getApplicationContext() + + private lateinit var togglesProvider: TogglesProvider + private lateinit var database: TogglesDatabase + + @Before + fun setUp() { + database = FakeTogglesDatabase.create(context) + val standardTestDispatcher = StandardTestDispatcher() + togglesProvider = RobolectricTogglesProvider.create( + context = context, + database = database, + toggles = FakeToggles(), + ioDispatcher = standardTestDispatcher, + ) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun testScopedToggleAccess() = runTest { + // Create a custom scope for user1 + val user1Scope = TogglesScope( + id = 0, + applicationId = 1, + name = "user1", + timeStamp = Date() + ) + user1Scope.id = database.togglesScopeDao().insert(user1Scope) + + // Create a custom scope for user2 + val user2Scope = TogglesScope( + id = 0, + applicationId = 1, + name = "user2", + timeStamp = Date() + ) + user2Scope.id = database.togglesScopeDao().insert(user2Scope) + + // Set up a toggle with different values for each scope + val configId = database.togglesConfigurationDao().insert( + se.eelde.toggles.database.TogglesConfiguration( + id = 0, + applicationId = 1, + key = "feature_flag", + type = "boolean" + ) + ) + + // Set value for user1 scope to "true" + database.togglesConfigurationValueDao().insert( + se.eelde.toggles.database.TogglesConfigurationValue( + id = 0, + configurationId = configId, + value = "true", + scope = user1Scope.id + ) + ) + + // Set value for user2 scope to "false" + database.togglesConfigurationValueDao().insert( + se.eelde.toggles.database.TogglesConfigurationValue( + id = 0, + configurationId = configId, + value = "false", + scope = user2Scope.id + ) + ) + + // Test that user1 scope gets "true" + val togglesUser1 = TogglesImpl(context, scope = "user1") + togglesUser1.toggle("feature_flag", false).first().apply { + advanceUntilIdle() + assert(this == true) { "Expected true for user1 scope, got $this" } + } + + // Test that user2 scope gets "false" + val togglesUser2 = TogglesImpl(context, scope = "user2") + togglesUser2.toggle("feature_flag", false).first().apply { + advanceUntilIdle() + assert(this == false) { "Expected false for user2 scope, got $this" } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun testDefaultScopeWhenScopeNotSpecified() = runTest { + // Without specifying scope, should use the selected scope + val toggles = TogglesImpl(context) + toggles.toggle("test_flag", true).first().apply { + advanceUntilIdle() + assert(this == true) { "Expected default value when toggle not set" } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun testFallbackToDefaultScopeWhenCustomScopeNotFound() = runTest { + // Create a toggle value in the default scope + val configId = database.togglesConfigurationDao().insert( + se.eelde.toggles.database.TogglesConfiguration( + id = 0, + applicationId = 1, + key = "fallback_flag", + type = "string" + ) + ) + + val defaultScope = database.togglesScopeDao().getDefaultScope(1) + database.togglesConfigurationValueDao().insert( + se.eelde.toggles.database.TogglesConfigurationValue( + id = 0, + configurationId = configId, + value = "default_value", + scope = defaultScope!!.id + ) + ) + + // Request with non-existent scope should fall back to default + val toggles = TogglesImpl(context, scope = "nonexistent_scope") + toggles.toggle("fallback_flag", "fallback").first().apply { + advanceUntilIdle() + assert(this == "default_value") { "Expected default scope value, got $this" } + } + } +} diff --git a/toggles-prefs/src/main/java/se/eelde/toggles/prefs/TogglesPreferencesImpl.kt b/toggles-prefs/src/main/java/se/eelde/toggles/prefs/TogglesPreferencesImpl.kt index 7ac27f8b..345fc85e 100644 --- a/toggles-prefs/src/main/java/se/eelde/toggles/prefs/TogglesPreferencesImpl.kt +++ b/toggles-prefs/src/main/java/se/eelde/toggles/prefs/TogglesPreferencesImpl.kt @@ -8,7 +8,10 @@ import se.eelde.toggles.core.TogglesProviderContract.toggleUri import se.eelde.toggles.core.TogglesProviderContract.toggleValueUri @Suppress("LibraryEntitiesShouldNotBePublic") -public class TogglesPreferencesImpl(context: Context) : TogglesPreferences { +public class TogglesPreferencesImpl( + context: Context, + private val scope: String? = null +) : TogglesPreferences { private val context = context.applicationContext private val contentResolver: ContentResolver = this.context.contentResolver @@ -122,7 +125,7 @@ public class TogglesPreferencesImpl(context: Context) : TogglesPreferences { @Toggle.ToggleType toggleType: String, key: String ): Toggle? { - val cursor = contentResolver.query(toggleUri(key), null, null, null, null) + val cursor = contentResolver.query(toggleUri(key, scope), null, null, null, null) cursor.use { if (cursor == null) { return null diff --git a/toggles-prefs/src/test/java/se/eelde/toggles/ScopedTogglesPreferencesTest.kt b/toggles-prefs/src/test/java/se/eelde/toggles/ScopedTogglesPreferencesTest.kt new file mode 100644 index 00000000..b6f913c1 --- /dev/null +++ b/toggles-prefs/src/test/java/se/eelde/toggles/ScopedTogglesPreferencesTest.kt @@ -0,0 +1,250 @@ +package se.eelde.toggles + +import android.app.Application +import android.content.ContentProvider +import android.content.ContentUris +import android.content.ContentValues +import android.content.UriMatcher +import android.content.pm.ProviderInfo +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import android.os.Build.VERSION_CODES.O +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.android.controller.ContentProviderController +import org.robolectric.annotation.Config +import se.eelde.toggles.core.ColumnNames +import se.eelde.toggles.core.Toggle +import se.eelde.toggles.core.TogglesProviderContract +import se.eelde.toggles.prefs.TogglesPreferences +import se.eelde.toggles.prefs.TogglesPreferencesImpl + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [O]) +internal class ScopedTogglesPreferencesTest { + + private val key = "scopedKey" + private lateinit var contentProviderController: ContentProviderController + + @Before + fun setUp() { + val info = + ProviderInfo().apply { authority = TogglesProviderContract.toggleUri().authority!! } + contentProviderController = + Robolectric.buildContentProvider(ScopedMockContentProvider::class.java).create(info) + } + + @Test + fun `return different values for different scopes`() { + val provider = contentProviderController.get() + + // Set up different values for different scopes + provider.scopedToggles["user1"] = mutableMapOf( + key to Toggle { + id = 1 + type = Toggle.TYPE.STRING + this.key = key + value = "user1_value" + } + ) + provider.scopedToggles["user2"] = mutableMapOf( + key to Toggle { + id = 2 + type = Toggle.TYPE.STRING + this.key = key + value = "user2_value" + } + ) + + // Create preferences for each scope + val prefsUser1 = TogglesPreferencesImpl( + ApplicationProvider.getApplicationContext(), + scope = "user1" + ) + val prefsUser2 = TogglesPreferencesImpl( + ApplicationProvider.getApplicationContext(), + scope = "user2" + ) + + // Verify each scope gets its own value + assertEquals("user1_value", prefsUser1.getString(key, "default")) + assertEquals("user2_value", prefsUser2.getString(key, "default")) + } + + @Test + fun `return different boolean values for different scopes`() { + val provider = contentProviderController.get() + + // Set up different boolean values for different scopes + provider.scopedToggles["admin"] = mutableMapOf( + key to Toggle { + id = 1 + type = Toggle.TYPE.BOOLEAN + this.key = key + value = "true" + } + ) + provider.scopedToggles["guest"] = mutableMapOf( + key to Toggle { + id = 2 + type = Toggle.TYPE.BOOLEAN + this.key = key + value = "false" + } + ) + + val prefsAdmin = TogglesPreferencesImpl( + ApplicationProvider.getApplicationContext(), + scope = "admin" + ) + val prefsGuest = TogglesPreferencesImpl( + ApplicationProvider.getApplicationContext(), + scope = "guest" + ) + + assertEquals(true, prefsAdmin.getBoolean(key, false)) + assertEquals(false, prefsGuest.getBoolean(key, true)) + } + + @Test + fun `use default scope when no scope specified`() { + val provider = contentProviderController.get() + + // Set up value in default scope + provider.scopedToggles[null] = mutableMapOf( + key to Toggle { + id = 1 + type = Toggle.TYPE.INTEGER + this.key = key + value = "42" + } + ) + + val prefs = TogglesPreferencesImpl( + ApplicationProvider.getApplicationContext() + ) + + assertEquals(42, prefs.getInt(key, 0)) + } +} + +internal class ScopedMockContentProvider : ContentProvider() { + companion object { + private const val CURRENT_CONFIGURATION_ID = 1 + private const val CURRENT_CONFIGURATION_KEY = 2 + private const val CURRENT_CONFIGURATIONS = 3 + private const val PREDEFINED_CONFIGURATION_VALUES = 5 + + private val uriMatcher = UriMatcher(UriMatcher.NO_MATCH) + + init { + uriMatcher.addURI( + TogglesProviderContract.toggleUri().authority!!, + "currentConfiguration/#", + CURRENT_CONFIGURATION_ID + ) + uriMatcher.addURI( + TogglesProviderContract.toggleUri().authority!!, + "currentConfiguration/*", + CURRENT_CONFIGURATION_KEY + ) + uriMatcher.addURI( + TogglesProviderContract.toggleUri().authority!!, + "currentConfiguration", + CURRENT_CONFIGURATIONS + ) + uriMatcher.addURI( + TogglesProviderContract.toggleUri().authority!!, + "predefinedConfigurationValue", + PREDEFINED_CONFIGURATION_VALUES + ) + } + } + + // Map of scope name to toggles map + val scopedToggles: MutableMap> = mutableMapOf() + private val toggleValues: MutableList = mutableListOf() + + override fun onCreate(): Boolean { + return true + } + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String? + ): Cursor { + when (uriMatcher.match(uri)) { + CURRENT_CONFIGURATION_ID -> { + throw IllegalArgumentException("toggle exists") + } + CURRENT_CONFIGURATION_KEY -> { + val cursor = MatrixCursor( + arrayOf( + ColumnNames.Toggle.COL_ID, + ColumnNames.Toggle.COL_KEY, + ColumnNames.Toggle.COL_TYPE, + ColumnNames.Toggle.COL_VALUE + ) + ) + + // Extract scope from query parameter + val scope = uri.getQueryParameter("SCOPE") + + uri.lastPathSegment?.let { key -> + scopedToggles[scope]?.get(key)?.let { toggle -> + cursor.addRow(arrayOf(toggle.id, toggle.key, toggle.type, toggle.value)) + } + } + + return cursor + } + else -> { + throw UnsupportedOperationException("Not yet implemented $uri") + } + } + } + + override fun insert(uri: Uri, values: ContentValues?): Uri { + val insertId: Long + when (uriMatcher.match(uri)) { + CURRENT_CONFIGURATIONS -> { + val toggle = Toggle.fromContentValues(values!!) + val scope = uri.getQueryParameter("SCOPE") + val scopeToggles = scopedToggles.getOrPut(scope) { mutableMapOf() } + require(!scopeToggles.containsKey(toggle.key)) { "toggle exists" } + scopeToggles[toggle.key] = toggle + insertId = scopeToggles.size.toLong() + toggle.id = insertId + } + PREDEFINED_CONFIGURATION_VALUES -> { + toggleValues.add(values!!.getAsString(ColumnNames.ToggleValue.COL_VALUE)) + insertId = toggleValues.size.toLong() + } + else -> { + throw UnsupportedOperationException("Not yet implemented $uri") + } + } + return ContentUris.withAppendedId(uri, insertId) + } + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array? + ): Int = error("Error") + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = + error("Error") + + override fun getType(uri: Uri): String = error("Error") +} diff --git a/toggles-sample/src/main/java/se/eelde/toggles/example/MainActivity.kt b/toggles-sample/src/main/java/se/eelde/toggles/example/MainActivity.kt index a9fa42de..c586d8a9 100644 --- a/toggles-sample/src/main/java/se/eelde/toggles/example/MainActivity.kt +++ b/toggles-sample/src/main/java/se/eelde/toggles/example/MainActivity.kt @@ -33,6 +33,8 @@ import se.eelde.toggles.example.info.InfoView import se.eelde.toggles.example.routes.Flow import se.eelde.toggles.example.routes.Info import se.eelde.toggles.example.routes.Oss +import se.eelde.toggles.example.routes.ScopedToggles +import se.eelde.toggles.example.scoped.ScopedTogglesView import se.eelde.toggles.oss.OssView @AndroidEntryPoint @@ -74,6 +76,7 @@ fun Navigation( BottomNavigationBar( prefsClick = { backStack.add(Info) }, flowClick = { backStack.add(Flow) }, + scopedClick = { backStack.add(ScopedToggles) }, ossClick = { backStack.add(Oss) }, rootDestination = RootDestination.Info ) @@ -91,6 +94,7 @@ fun Navigation( BottomNavigationBar( prefsClick = { backStack.add(Info) }, flowClick = { backStack.add(Flow) }, + scopedClick = { backStack.add(ScopedToggles) }, ossClick = { backStack.add(Oss) }, rootDestination = RootDestination.Flow ) @@ -103,11 +107,30 @@ fun Navigation( ) } } + entry { + Scaffold(bottomBar = { + BottomNavigationBar( + prefsClick = { backStack.add(Info) }, + flowClick = { backStack.add(Flow) }, + scopedClick = { backStack.add(ScopedToggles) }, + ossClick = { backStack.add(Oss) }, + rootDestination = RootDestination.Scoped + ) + }) { paddingValues -> + ScopedTogglesView( + hiltViewModel(), + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + ) + } + } entry { Scaffold(bottomBar = { BottomNavigationBar( prefsClick = { backStack.add(Info) }, flowClick = { backStack.add(Flow) }, + scopedClick = { backStack.add(ScopedToggles) }, ossClick = { backStack.add(Oss) }, rootDestination = RootDestination.Oss ) @@ -120,13 +143,14 @@ fun Navigation( } enum class RootDestination { - Info, Flow, Oss + Info, Flow, Scoped, Oss } @Composable fun BottomNavigationBar( prefsClick: () -> Unit, flowClick: () -> Unit, + scopedClick: () -> Unit, ossClick: () -> Unit, rootDestination: RootDestination, modifier: Modifier = Modifier, @@ -156,6 +180,18 @@ fun BottomNavigationBar( label = { Text(text = stringResource(id = R.string.nav_menu_toggles_flow)) } ) + NavigationBarItem( + selected = rootDestination == RootDestination.Scoped, + onClick = { scopedClick() }, + icon = { + Icon( + imageVector = Icons.Filled.SettingsEthernet, + contentDescription = null + ) + }, + label = { Text(text = stringResource(id = R.string.nav_menu_scoped)) } + ) + NavigationBarItem( selected = rootDestination == RootDestination.Oss, onClick = { ossClick() }, diff --git a/toggles-sample/src/main/java/se/eelde/toggles/example/routes/Routes.kt b/toggles-sample/src/main/java/se/eelde/toggles/example/routes/Routes.kt index 44beec88..81c664cc 100644 --- a/toggles-sample/src/main/java/se/eelde/toggles/example/routes/Routes.kt +++ b/toggles-sample/src/main/java/se/eelde/toggles/example/routes/Routes.kt @@ -10,3 +10,6 @@ object Flow @Serializable object Info + +@Serializable +object ScopedToggles diff --git a/toggles-sample/src/main/java/se/eelde/toggles/example/scoped/ScopedTogglesView.kt b/toggles-sample/src/main/java/se/eelde/toggles/example/scoped/ScopedTogglesView.kt new file mode 100644 index 00000000..1c5719aa --- /dev/null +++ b/toggles-sample/src/main/java/se/eelde/toggles/example/scoped/ScopedTogglesView.kt @@ -0,0 +1,144 @@ +package se.eelde.toggles.example.scoped + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.getValue +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@Composable +fun ScopedTogglesView(viewModel: ScopedTogglesViewModel, modifier: Modifier = Modifier) { + val viewState by viewModel.viewState + + Column( + modifier = modifier + .padding(16.dp) + .verticalScroll(rememberScrollState()) + ) { + Text( + text = "Scoped Toggles Demo", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = "This demonstrates per-user feature flags using scope-specific toggle instances", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 8.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Admin Scope Card + ScopeCard( + scopeName = "Admin Scope", + featureEnabled = viewState.adminFeatureEnabled, + apiEndpoint = viewState.adminApiEndpoint + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Guest Scope Card + ScopeCard( + scopeName = "Guest Scope", + featureEnabled = viewState.guestFeatureEnabled, + apiEndpoint = viewState.guestApiEndpoint + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Default Scope Card + ScopeCard( + scopeName = "Default Scope", + featureEnabled = viewState.defaultFeatureEnabled, + apiEndpoint = viewState.defaultApiEndpoint + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "How to use:", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = "1. Open the Toggles app\n" + + "2. Create scopes: 'admin' and 'guest'\n" + + "3. Create toggles: 'advanced_mode' (boolean) and 'api_endpoint' (string)\n" + + "4. Set different values for each scope\n" + + "5. Watch the values update in real-time!", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 8.dp) + ) + } + } + } +} + +@Composable +fun ScopeCard( + scopeName: String, + featureEnabled: Boolean, + apiEndpoint: String, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = scopeName, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Feature toggle status + Text( + text = "Advanced Mode:", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = if (featureEnabled) "✅ Enabled" else "❌ Disabled", + style = MaterialTheme.typography.bodyLarge, + color = if (featureEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // API endpoint + Text( + text = "API Endpoint:", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = apiEndpoint, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} diff --git a/toggles-sample/src/main/java/se/eelde/toggles/example/scoped/ScopedTogglesViewModel.kt b/toggles-sample/src/main/java/se/eelde/toggles/example/scoped/ScopedTogglesViewModel.kt new file mode 100644 index 00000000..b6c6dfbd --- /dev/null +++ b/toggles-sample/src/main/java/se/eelde/toggles/example/scoped/ScopedTogglesViewModel.kt @@ -0,0 +1,81 @@ +package se.eelde.toggles.example.scoped + +import android.app.Application +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import se.eelde.toggles.flow.TogglesImpl +import javax.inject.Inject + +@HiltViewModel +class ScopedTogglesViewModel @Inject constructor( + application: Application +) : ViewModel() { + private val adminToggles = TogglesImpl(application, scope = "admin") + private val guestToggles = TogglesImpl(application, scope = "guest") + private val defaultToggles = TogglesImpl(application) + + internal val viewState: MutableState = mutableStateOf( + ScopedViewState( + adminFeatureEnabled = false, + guestFeatureEnabled = false, + defaultFeatureEnabled = false, + adminApiEndpoint = "Loading...", + guestApiEndpoint = "Loading...", + defaultApiEndpoint = "Loading..." + ) + ) + + init { + // Monitor admin scope + viewModelScope.launch { + adminToggles.toggle("advanced_mode", false).collect { enabled -> + viewState.value = viewState.value.copy(adminFeatureEnabled = enabled) + } + } + + viewModelScope.launch { + adminToggles.toggle("api_endpoint", "https://api.admin.example.com").collect { endpoint -> + viewState.value = viewState.value.copy(adminApiEndpoint = endpoint) + } + } + + // Monitor guest scope + viewModelScope.launch { + guestToggles.toggle("advanced_mode", false).collect { enabled -> + viewState.value = viewState.value.copy(guestFeatureEnabled = enabled) + } + } + + viewModelScope.launch { + guestToggles.toggle("api_endpoint", "https://api.guest.example.com").collect { endpoint -> + viewState.value = viewState.value.copy(guestApiEndpoint = endpoint) + } + } + + // Monitor default scope + viewModelScope.launch { + defaultToggles.toggle("advanced_mode", false).collect { enabled -> + viewState.value = viewState.value.copy(defaultFeatureEnabled = enabled) + } + } + + viewModelScope.launch { + defaultToggles.toggle("api_endpoint", "https://api.default.example.com").collect { endpoint -> + viewState.value = viewState.value.copy(defaultApiEndpoint = endpoint) + } + } + } +} + +data class ScopedViewState( + val adminFeatureEnabled: Boolean, + val guestFeatureEnabled: Boolean, + val defaultFeatureEnabled: Boolean, + val adminApiEndpoint: String, + val guestApiEndpoint: String, + val defaultApiEndpoint: String +) diff --git a/toggles-sample/src/main/res/values/strings.xml b/toggles-sample/src/main/res/values/strings.xml index 04a1f663..21e63812 100644 --- a/toggles-sample/src/main/res/values/strings.xml +++ b/toggles-sample/src/main/res/values/strings.xml @@ -8,5 +8,6 @@ Flow Prefs + Scoped oss