summaryrefslogtreecommitdiffstats
path: root/mobile/android/android-components/components/feature/containers
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:35:49 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:35:49 +0000
commitd8bbc7858622b6d9c278469aab701ca0b609cddf (patch)
treeeff41dc61d9f714852212739e6b3738b82a2af87 /mobile/android/android-components/components/feature/containers
parentReleasing progress-linux version 125.0.3-1~progress7.99u1. (diff)
downloadfirefox-d8bbc7858622b6d9c278469aab701ca0b609cddf.tar.xz
firefox-d8bbc7858622b6d9c278469aab701ca0b609cddf.zip
Merging upstream version 126.0.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'mobile/android/android-components/components/feature/containers')
-rw-r--r--mobile/android/android-components/components/feature/containers/README.md19
-rw-r--r--mobile/android/android-components/components/feature/containers/build.gradle80
-rw-r--r--mobile/android/android-components/components/feature/containers/proguard-rules.pro21
-rw-r--r--mobile/android/android-components/components/feature/containers/schemas/mozilla.components.feature.containers.db.ContainerDatabase/1.json52
-rw-r--r--mobile/android/android-components/components/feature/containers/src/androidTest/java/mozilla/components/feature/containers/ContainerStorageTest.kt107
-rw-r--r--mobile/android/android-components/components/feature/containers/src/androidTest/java/mozilla/components/feature/containers/db/ContainerDaoTest.kt92
-rw-r--r--mobile/android/android-components/components/feature/containers/src/main/AndroidManifest.xml4
-rw-r--r--mobile/android/android-components/components/feature/containers/src/main/java/mozilla/components/feature/containers/ContainerMiddleware.kt106
-rw-r--r--mobile/android/android-components/components/feature/containers/src/main/java/mozilla/components/feature/containers/ContainerStorage.kt72
-rw-r--r--mobile/android/android-components/components/feature/containers/src/main/java/mozilla/components/feature/containers/db/ContainerDao.kt36
-rw-r--r--mobile/android/android-components/components/feature/containers/src/main/java/mozilla/components/feature/containers/db/ContainerDatabase.kt63
-rw-r--r--mobile/android/android-components/components/feature/containers/src/main/java/mozilla/components/feature/containers/db/ContainerEntity.kt49
-rw-r--r--mobile/android/android-components/components/feature/containers/src/test/java/mozilla/components/feature/containers/ContainerMiddlewareTest.kt115
-rw-r--r--mobile/android/android-components/components/feature/containers/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker2
-rw-r--r--mobile/android/android-components/components/feature/containers/src/test/resources/robolectric.properties1
15 files changed, 819 insertions, 0 deletions
diff --git a/mobile/android/android-components/components/feature/containers/README.md b/mobile/android/android-components/components/feature/containers/README.md
new file mode 100644
index 0000000000..aea3596f2d
--- /dev/null
+++ b/mobile/android/android-components/components/feature/containers/README.md
@@ -0,0 +1,19 @@
+# [Android Components](../../../README.md) > Feature > Containers
+
+Feature component for working with contextual identities also known as containers.
+
+## Usage
+
+### Setting up the dependency
+
+Use Gradle to download the library from [maven.mozilla.org](https://maven.mozilla.org/) ([Setup repository](../../../README.md#maven-repository)):
+
+```Groovy
+implementation "org.mozilla.components:feature-containers:{latest-version}"
+```
+
+## License
+
+ This Source Code Form is subject to the terms of the Mozilla Public
+ License, v. 2.0. If a copy of the MPL was not distributed with this
+ file, You can obtain one at http://mozilla.org/MPL/2.0/
diff --git a/mobile/android/android-components/components/feature/containers/build.gradle b/mobile/android/android-components/components/feature/containers/build.gradle
new file mode 100644
index 0000000000..9ebb3289db
--- /dev/null
+++ b/mobile/android/android-components/components/feature/containers/build.gradle
@@ -0,0 +1,80 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+apply plugin: 'com.google.devtools.ksp'
+
+android {
+ defaultConfig {
+ minSdkVersion config.minSdkVersion
+ compileSdk config.compileSdkVersion
+ targetSdkVersion config.targetSdkVersion
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+
+ ksp {
+ arg("room.schemaLocation", "$projectDir/schemas".toString())
+ arg("room.generateKotlin", "true")
+ }
+
+ javaCompileOptions {
+ annotationProcessorOptions {
+ arguments += ["room.incremental": "true"]
+ }
+ }
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ }
+ }
+
+ packagingOptions {
+ resources {
+ excludes += ['META-INF/proguard/androidx-annotations.pro']
+ }
+ }
+
+ sourceSets {
+ androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
+ }
+
+ namespace 'mozilla.components.feature.containers'
+}
+
+dependencies {
+ implementation project(':browser-state')
+ implementation project(':support-ktx')
+ implementation project(':support-base')
+
+ implementation ComponentsDependencies.kotlin_coroutines
+
+ implementation ComponentsDependencies.androidx_paging
+ implementation ComponentsDependencies.androidx_lifecycle_livedata
+
+ implementation ComponentsDependencies.androidx_room_runtime
+ ksp ComponentsDependencies.androidx_room_compiler
+
+ androidTestImplementation project(':support-android-test')
+
+ androidTestImplementation ComponentsDependencies.androidx_room_testing
+ androidTestImplementation ComponentsDependencies.androidx_arch_core_testing
+ androidTestImplementation ComponentsDependencies.androidx_test_core
+ androidTestImplementation ComponentsDependencies.androidx_test_runner
+ androidTestImplementation ComponentsDependencies.androidx_test_rules
+ androidTestImplementation ComponentsDependencies.testing_coroutines
+
+ testImplementation project(':support-test')
+ testImplementation project(':support-test-libstate')
+
+ testImplementation ComponentsDependencies.androidx_test_junit
+ testImplementation ComponentsDependencies.testing_coroutines
+ testImplementation ComponentsDependencies.testing_robolectric
+}
+
+apply from: '../../../android-lint.gradle'
+apply from: '../../../publish.gradle'
+ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description)
diff --git a/mobile/android/android-components/components/feature/containers/proguard-rules.pro b/mobile/android/android-components/components/feature/containers/proguard-rules.pro
new file mode 100644
index 0000000000..f1b424510d
--- /dev/null
+++ b/mobile/android/android-components/components/feature/containers/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
diff --git a/mobile/android/android-components/components/feature/containers/schemas/mozilla.components.feature.containers.db.ContainerDatabase/1.json b/mobile/android/android-components/components/feature/containers/schemas/mozilla.components.feature.containers.db.ContainerDatabase/1.json
new file mode 100644
index 0000000000..4a8eb5e60b
--- /dev/null
+++ b/mobile/android/android-components/components/feature/containers/schemas/mozilla.components.feature.containers.db.ContainerDatabase/1.json
@@ -0,0 +1,52 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 1,
+ "identityHash": "77d1905ab2c154b7ed655e58bd578a84",
+ "entities": [
+ {
+ "tableName": "containers",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`context_id` TEXT NOT NULL, `name` TEXT NOT NULL, `color` TEXT NOT NULL, `icon` TEXT NOT NULL, PRIMARY KEY(`context_id`))",
+ "fields": [
+ {
+ "fieldPath": "contextId",
+ "columnName": "context_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "color",
+ "columnName": "color",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "icon",
+ "columnName": "icon",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "columnNames": [
+ "context_id"
+ ],
+ "autoGenerate": false
+ },
+ "indices": [],
+ "foreignKeys": []
+ }
+ ],
+ "views": [],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '77d1905ab2c154b7ed655e58bd578a84')"
+ ]
+ }
+}
diff --git a/mobile/android/android-components/components/feature/containers/src/androidTest/java/mozilla/components/feature/containers/ContainerStorageTest.kt b/mobile/android/android-components/components/feature/containers/src/androidTest/java/mozilla/components/feature/containers/ContainerStorageTest.kt
new file mode 100644
index 0000000000..199034309c
--- /dev/null
+++ b/mobile/android/android-components/components/feature/containers/src/androidTest/java/mozilla/components/feature/containers/ContainerStorageTest.kt
@@ -0,0 +1,107 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.feature.containers
+
+import android.content.Context
+import androidx.room.Room
+import androidx.test.core.app.ApplicationProvider
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+import mozilla.components.browser.state.state.Container
+import mozilla.components.browser.state.state.ContainerState.Color
+import mozilla.components.browser.state.state.ContainerState.Icon
+import mozilla.components.feature.containers.db.ContainerDatabase
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Before
+import org.junit.Test
+
+@ExperimentalCoroutinesApi
+@Suppress("LargeClass")
+class ContainerStorageTest {
+ private lateinit var context: Context
+ private lateinit var storage: ContainerStorage
+
+ @Before
+ fun setUp() {
+ context = ApplicationProvider.getApplicationContext()
+ val database = Room.inMemoryDatabaseBuilder(context, ContainerDatabase::class.java).build()
+
+ storage = ContainerStorage(context)
+ storage.database = lazy { database }
+ }
+
+ @Test
+ fun testAddingContainer() = runTest {
+ storage.addContainer("1", "Personal", Color.RED, Icon.FINGERPRINT)
+ storage.addContainer("2", "Shopping", Color.BLUE, Icon.CART)
+
+ val containers = getAllContainers()
+
+ assertEquals(2, containers.size)
+
+ assertEquals("1", containers[0].contextId)
+ assertEquals("Personal", containers[0].name)
+ assertEquals(Color.RED, containers[0].color)
+ assertEquals(Icon.FINGERPRINT, containers[0].icon)
+ assertEquals("2", containers[1].contextId)
+ assertEquals("Shopping", containers[1].name)
+ assertEquals(Color.BLUE, containers[1].color)
+ assertEquals(Icon.CART, containers[1].icon)
+ }
+
+ @Test
+ fun testRemovingContainers() = runTest {
+ storage.addContainer("1", "Personal", Color.RED, Icon.FINGERPRINT)
+ storage.addContainer("2", "Shopping", Color.BLUE, Icon.CART)
+
+ getAllContainers().let { containers ->
+ assertEquals(2, containers.size)
+
+ storage.removeContainer(containers[0])
+ }
+
+ getAllContainers().let { containers ->
+ assertEquals(1, containers.size)
+
+ assertEquals("2", containers[0].contextId)
+ assertEquals("Shopping", containers[0].name)
+ assertEquals(Color.BLUE, containers[0].color)
+ assertEquals(Icon.CART, containers[0].icon)
+ }
+ }
+
+ @Test
+ fun testGettingContainers() = runTest {
+ storage.addContainer("1", "Personal", Color.RED, Icon.FINGERPRINT)
+ storage.addContainer("2", "Shopping", Color.BLUE, Icon.CART)
+
+ val containers = storage.getContainers().first()
+
+ assertNotNull(containers)
+ assertEquals(2, containers.size)
+
+ with(containers[0]) {
+ assertEquals("1", contextId)
+ assertEquals("Personal", name)
+ assertEquals(Color.RED, color)
+ assertEquals(Icon.FINGERPRINT, icon)
+ }
+
+ with(containers[1]) {
+ assertEquals("2", contextId)
+ assertEquals("Shopping", name)
+ assertEquals(Color.BLUE, color)
+ assertEquals(Icon.CART, icon)
+ }
+ }
+
+ private suspend fun getAllContainers(): List<Container> {
+ return storage.containerDao.getContainersList().map { containerEntity ->
+ containerEntity.toContainer()
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/feature/containers/src/androidTest/java/mozilla/components/feature/containers/db/ContainerDaoTest.kt b/mobile/android/android-components/components/feature/containers/src/androidTest/java/mozilla/components/feature/containers/db/ContainerDaoTest.kt
new file mode 100644
index 0000000000..344bb5d956
--- /dev/null
+++ b/mobile/android/android-components/components/feature/containers/src/androidTest/java/mozilla/components/feature/containers/db/ContainerDaoTest.kt
@@ -0,0 +1,92 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.feature.containers.db
+
+import android.content.Context
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import androidx.room.Room
+import androidx.test.core.app.ApplicationProvider
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import mozilla.components.browser.state.state.ContainerState.Color
+import mozilla.components.browser.state.state.ContainerState.Icon
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import java.util.UUID
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+
+@ExperimentalCoroutinesApi
+class ContainerDaoTest {
+ private val context: Context
+ get() = ApplicationProvider.getApplicationContext()
+
+ private lateinit var database: ContainerDatabase
+ private lateinit var containerDao: ContainerDao
+ private lateinit var executor: ExecutorService
+
+ @get:Rule
+ var instantTaskExecutorRule = InstantTaskExecutorRule()
+
+ @Before
+ fun setUp() {
+ database = Room.inMemoryDatabaseBuilder(context, ContainerDatabase::class.java).build()
+ containerDao = database.containerDao()
+ executor = Executors.newSingleThreadExecutor()
+ }
+
+ @After
+ fun tearDown() {
+ database.close()
+ executor.shutdown()
+ }
+
+ @Test
+ fun testAddingContainer() = runTest {
+ val container =
+ ContainerEntity(
+ contextId = UUID.randomUUID().toString(),
+ name = "Personal",
+ color = Color.RED,
+ icon = Icon.FINGERPRINT,
+ )
+ containerDao.insertContainer(container)
+
+ val pagedList = containerDao.getContainersList()
+
+ assertEquals(1, pagedList.size)
+ assertEquals(container, pagedList[0])
+ }
+
+ @Test
+ fun testRemovingContainer() = runTest {
+ val container1 =
+ ContainerEntity(
+ contextId = UUID.randomUUID().toString(),
+ name = "Personal",
+ color = Color.RED,
+ icon = Icon.FINGERPRINT,
+ )
+ val container2 =
+ ContainerEntity(
+ contextId = UUID.randomUUID().toString(),
+ name = "Shopping",
+ color = Color.BLUE,
+ icon = Icon.CART,
+ )
+
+ containerDao.insertContainer(container1)
+ containerDao.insertContainer(container2)
+ containerDao.deleteContainer(container1)
+
+ val pagedList = containerDao.getContainersList()
+
+ assertEquals(1, pagedList.size)
+ assertEquals(container2, pagedList[0])
+ }
+}
diff --git a/mobile/android/android-components/components/feature/containers/src/main/AndroidManifest.xml b/mobile/android/android-components/components/feature/containers/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..e16cda1d34
--- /dev/null
+++ b/mobile/android/android-components/components/feature/containers/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<manifest />
diff --git a/mobile/android/android-components/components/feature/containers/src/main/java/mozilla/components/feature/containers/ContainerMiddleware.kt b/mobile/android/android-components/components/feature/containers/src/main/java/mozilla/components/feature/containers/ContainerMiddleware.kt
new file mode 100644
index 0000000000..772e16ee52
--- /dev/null
+++ b/mobile/android/android-components/components/feature/containers/src/main/java/mozilla/components/feature/containers/ContainerMiddleware.kt
@@ -0,0 +1,106 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.feature.containers
+
+import android.content.Context
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.launch
+import mozilla.components.browser.state.action.BrowserAction
+import mozilla.components.browser.state.action.ContainerAction
+import mozilla.components.browser.state.action.InitAction
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.Container
+import mozilla.components.browser.state.state.ContainerState
+import mozilla.components.lib.state.Middleware
+import mozilla.components.lib.state.MiddlewareContext
+import mozilla.components.lib.state.Store
+import java.util.UUID
+import kotlin.coroutines.CoroutineContext
+
+/**
+ * [Middleware] implementation for handling [ContainerAction] and syncing the containers in
+ * [BrowserState.containers] with the [ContainerStorage].
+ */
+class ContainerMiddleware(
+ applicationContext: Context,
+ coroutineContext: CoroutineContext = Dispatchers.IO,
+ private val containerStorage: Storage = ContainerStorage(applicationContext),
+) : Middleware<BrowserState, BrowserAction> {
+
+ private var scope = CoroutineScope(coroutineContext)
+
+ override fun invoke(
+ context: MiddlewareContext<BrowserState, BrowserAction>,
+ next: (BrowserAction) -> Unit,
+ action: BrowserAction,
+ ) {
+ when (action) {
+ is InitAction -> initializeContainers(context.store)
+ is ContainerAction.AddContainerAction -> addContainer(action)
+ is ContainerAction.RemoveContainerAction -> removeContainer(context.store, action)
+ else -> {
+ // no-op
+ }
+ }
+
+ next(action)
+ }
+
+ private fun initializeContainers(
+ store: Store<BrowserState, BrowserAction>,
+ ) = scope.launch {
+ containerStorage.getContainers().collect { containers ->
+ store.dispatch(ContainerAction.AddContainersAction(containers))
+ }
+ }
+
+ private fun addContainer(
+ action: ContainerAction.AddContainerAction,
+ ) = scope.launch {
+ containerStorage.addContainer(
+ contextId = action.container.contextId,
+ name = action.container.name,
+ color = action.container.color,
+ icon = action.container.icon,
+ )
+ }
+
+ private fun removeContainer(
+ store: Store<BrowserState, BrowserAction>,
+ action: ContainerAction.RemoveContainerAction,
+ ) = scope.launch {
+ store.state.containers[action.contextId]?.let {
+ containerStorage.removeContainer(it)
+ }
+ }
+
+ /**
+ * Interface for a storage to be passed to the middleware.
+ */
+ interface Storage {
+ /**
+ * Returns a [Flow] list of all the [Container] instances.
+ */
+ fun getContainers(): Flow<List<Container>>
+
+ /**
+ * Adds a new [Container].
+ */
+ suspend fun addContainer(
+ contextId: String = UUID.randomUUID().toString(),
+ name: String,
+ color: ContainerState.Color,
+ icon: ContainerState.Icon,
+ )
+
+ /**
+ * Removes the given [Container].
+ */
+ suspend fun removeContainer(container: Container)
+ }
+}
diff --git a/mobile/android/android-components/components/feature/containers/src/main/java/mozilla/components/feature/containers/ContainerStorage.kt b/mobile/android/android-components/components/feature/containers/src/main/java/mozilla/components/feature/containers/ContainerStorage.kt
new file mode 100644
index 0000000000..1219923b58
--- /dev/null
+++ b/mobile/android/android-components/components/feature/containers/src/main/java/mozilla/components/feature/containers/ContainerStorage.kt
@@ -0,0 +1,72 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.feature.containers
+
+import android.content.Context
+import androidx.annotation.VisibleForTesting
+import androidx.paging.DataSource
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import mozilla.components.browser.state.state.Container
+import mozilla.components.browser.state.state.ContainerState.Color
+import mozilla.components.browser.state.state.ContainerState.Icon
+import mozilla.components.feature.containers.db.ContainerDatabase
+import mozilla.components.feature.containers.db.ContainerEntity
+import mozilla.components.feature.containers.db.toContainerEntity
+
+/**
+ * A storage implementation for organizing containers (contextual identities).
+ */
+internal class ContainerStorage(context: Context) : ContainerMiddleware.Storage {
+
+ @VisibleForTesting
+ internal var database: Lazy<ContainerDatabase> =
+ lazy { ContainerDatabase.get(context) }
+ val containerDao by lazy { database.value.containerDao() }
+
+ /**
+ * Adds a new [Container].
+ */
+ override suspend fun addContainer(
+ contextId: String,
+ name: String,
+ color: Color,
+ icon: Icon,
+ ) {
+ containerDao.insertContainer(
+ ContainerEntity(
+ contextId = contextId,
+ name = name,
+ color = color,
+ icon = icon,
+ ),
+ )
+ }
+
+ /**
+ * Returns a [Flow] list of all the [Container] instances.
+ */
+ override fun getContainers(): Flow<List<Container>> {
+ return containerDao.getContainers().map { list ->
+ list.map { entity -> entity.toContainer() }
+ }
+ }
+
+ /**
+ * Returns all saved [Container] instances as a [DataSource.Factory].
+ */
+ fun getContainersPaged(): DataSource.Factory<Int, Container> = containerDao
+ .getContainersPaged()
+ .map { entity ->
+ entity.toContainer()
+ }
+
+ /**
+ * Removes the given [Container].
+ */
+ override suspend fun removeContainer(container: Container) {
+ containerDao.deleteContainer(container.toContainerEntity())
+ }
+}
diff --git a/mobile/android/android-components/components/feature/containers/src/main/java/mozilla/components/feature/containers/db/ContainerDao.kt b/mobile/android/android-components/components/feature/containers/src/main/java/mozilla/components/feature/containers/db/ContainerDao.kt
new file mode 100644
index 0000000000..40e9f5cd41
--- /dev/null
+++ b/mobile/android/android-components/components/feature/containers/src/main/java/mozilla/components/feature/containers/db/ContainerDao.kt
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.feature.containers.db
+
+import androidx.paging.DataSource
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.Query
+import androidx.room.Transaction
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Internal DAO for accessing [ContainerEntity] instances.
+ */
+@Dao
+internal interface ContainerDao {
+ @Insert
+ suspend fun insertContainer(container: ContainerEntity): Long
+
+ @Delete
+ suspend fun deleteContainer(identity: ContainerEntity)
+
+ @Transaction
+ @Query("SELECT * FROM containers")
+ fun getContainers(): Flow<List<ContainerEntity>>
+
+ @Query("SELECT * FROM containers")
+ suspend fun getContainersList(): List<ContainerEntity>
+
+ @Transaction
+ @Query("SELECT * FROM containers")
+ fun getContainersPaged(): DataSource.Factory<Int, ContainerEntity>
+}
diff --git a/mobile/android/android-components/components/feature/containers/src/main/java/mozilla/components/feature/containers/db/ContainerDatabase.kt b/mobile/android/android-components/components/feature/containers/src/main/java/mozilla/components/feature/containers/db/ContainerDatabase.kt
new file mode 100644
index 0000000000..e6a7f55d87
--- /dev/null
+++ b/mobile/android/android-components/components/feature/containers/src/main/java/mozilla/components/feature/containers/db/ContainerDatabase.kt
@@ -0,0 +1,63 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.feature.containers.db
+
+import android.content.Context
+import androidx.room.Database
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import androidx.room.TypeConverter
+import androidx.room.TypeConverters
+import mozilla.components.browser.state.state.ContainerState.Color
+import mozilla.components.browser.state.state.ContainerState.Icon
+
+/**
+ * Internal database for storing containers (contextual identities).
+ */
+@Database(entities = [ContainerEntity::class], version = 1)
+@TypeConverters(Converter::class)
+internal abstract class ContainerDatabase : RoomDatabase() {
+ abstract fun containerDao(): ContainerDao
+
+ companion object {
+ @Volatile
+ private var instance: ContainerDatabase? = null
+
+ @Synchronized
+ fun get(context: Context): ContainerDatabase {
+ instance?.let { return it }
+
+ return Room.databaseBuilder(
+ context,
+ ContainerDatabase::class.java,
+ "containers",
+ ).build().also {
+ instance = it
+ }
+ }
+ }
+}
+
+internal class Converter {
+ @TypeConverter
+ fun toColorString(color: Color): String {
+ return color.color
+ }
+
+ @TypeConverter
+ fun toColor(color: String): Color? {
+ return Color.values().find { it.color == color }
+ }
+
+ @TypeConverter
+ fun toIconString(icon: Icon): String {
+ return icon.icon
+ }
+
+ @TypeConverter
+ fun toIcon(icon: String): Icon? {
+ return Icon.values().find { it.icon == icon }
+ }
+}
diff --git a/mobile/android/android-components/components/feature/containers/src/main/java/mozilla/components/feature/containers/db/ContainerEntity.kt b/mobile/android/android-components/components/feature/containers/src/main/java/mozilla/components/feature/containers/db/ContainerEntity.kt
new file mode 100644
index 0000000000..2532789d43
--- /dev/null
+++ b/mobile/android/android-components/components/feature/containers/src/main/java/mozilla/components/feature/containers/db/ContainerEntity.kt
@@ -0,0 +1,49 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.feature.containers.db
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import mozilla.components.browser.state.state.Container
+import mozilla.components.browser.state.state.Container.Color
+import mozilla.components.browser.state.state.Container.Icon
+
+/**
+ * Internal entity representing a container (contextual identity).
+ */
+@Entity(tableName = "containers")
+internal data class ContainerEntity(
+ @PrimaryKey
+ @ColumnInfo(name = "context_id")
+ var contextId: String,
+
+ @ColumnInfo(name = "name")
+ var name: String,
+
+ @ColumnInfo(name = "color")
+ var color: Color,
+
+ @ColumnInfo(name = "icon")
+ var icon: Icon,
+) {
+ internal fun toContainer(): Container {
+ return Container(
+ contextId,
+ name,
+ color,
+ icon,
+ )
+ }
+}
+
+internal fun Container.toContainerEntity(): ContainerEntity {
+ return ContainerEntity(
+ contextId,
+ name,
+ color,
+ icon,
+ )
+}
diff --git a/mobile/android/android-components/components/feature/containers/src/test/java/mozilla/components/feature/containers/ContainerMiddlewareTest.kt b/mobile/android/android-components/components/feature/containers/src/test/java/mozilla/components/feature/containers/ContainerMiddlewareTest.kt
new file mode 100644
index 0000000000..2cef4eacbc
--- /dev/null
+++ b/mobile/android/android-components/components/feature/containers/src/test/java/mozilla/components/feature/containers/ContainerMiddlewareTest.kt
@@ -0,0 +1,115 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.feature.containers
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.flow
+import mozilla.components.browser.state.action.ContainerAction
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.ContainerState
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import mozilla.components.support.test.whenever
+import org.junit.Assert.assertEquals
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.verify
+
+@ExperimentalCoroutinesApi
+@RunWith(AndroidJUnit4::class)
+class ContainerMiddlewareTest {
+ @get:Rule
+ val mainCoroutineRule = MainCoroutineRule()
+
+ // Test container
+ private val container = ContainerState(
+ contextId = "contextId",
+ name = "Personal",
+ color = ContainerState.Color.GREEN,
+ icon = ContainerState.Icon.CART,
+ )
+
+ @Test
+ fun `container storage stores the provided container on add container action`() =
+ runTestOnMain {
+ val storage = mockStorage()
+ val middleware = ContainerMiddleware(testContext, coroutineContext, containerStorage = storage)
+ val store = BrowserStore(
+ initialState = BrowserState(),
+ middleware = listOf(middleware),
+ )
+
+ store.waitUntilIdle() // wait to consume InitAction
+ store.waitUntilIdle() // wait to consume AddContainersAction
+
+ store.dispatch(ContainerAction.AddContainerAction(container)).joinBlocking()
+
+ verify(storage).addContainer(
+ container.contextId,
+ container.name,
+ container.color,
+ container.icon,
+ )
+ }
+
+ @Test
+ fun `fetch the containers from the container storage and load into browser state on initialize container state action`() =
+ runTestOnMain {
+ val storage = mockStorage(listOf(container))
+ val middleware = ContainerMiddleware(testContext, coroutineContext, containerStorage = storage)
+ val store = BrowserStore(
+ initialState = BrowserState(),
+ middleware = listOf(middleware),
+ )
+
+ store.waitUntilIdle() // wait to consume InitAction
+ store.waitUntilIdle() // wait to consume AddContainersAction
+
+ verify(storage).getContainers()
+ assertEquals(container, store.state.containers["contextId"])
+ }
+
+ @Test
+ fun `container storage removes the provided container on remove container action`() =
+ runTestOnMain {
+ val storage = mockStorage()
+ val middleware = ContainerMiddleware(testContext, coroutineContext, containerStorage = storage)
+ val store = BrowserStore(
+ initialState = BrowserState(
+ containers = mapOf(
+ container.contextId to container,
+ ),
+ ),
+ middleware = listOf(middleware),
+ )
+
+ store.waitUntilIdle() // wait to consume InitAction
+ store.waitUntilIdle() // wait to consume AddContainersAction
+
+ store.dispatch(ContainerAction.RemoveContainerAction(container.contextId))
+ .joinBlocking()
+
+ verify(storage).removeContainer(container)
+ }
+
+ private fun mockStorage(
+ containers: List<ContainerState> = emptyList(),
+ ): ContainerStorage {
+ val storage: ContainerStorage = mock()
+ whenever(storage.getContainers()).thenReturn(
+ flow {
+ emit(containers)
+ },
+ )
+ return storage
+ }
+}
diff --git a/mobile/android/android-components/components/feature/containers/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/feature/containers/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
new file mode 100644
index 0000000000..cf1c399ea8
--- /dev/null
+++ b/mobile/android/android-components/components/feature/containers/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
@@ -0,0 +1,2 @@
+mock-maker-inline
+// This allows mocking final classes (classes are final by default in Kotlin)
diff --git a/mobile/android/android-components/components/feature/containers/src/test/resources/robolectric.properties b/mobile/android/android-components/components/feature/containers/src/test/resources/robolectric.properties
new file mode 100644
index 0000000000..932b01b9eb
--- /dev/null
+++ b/mobile/android/android-components/components/feature/containers/src/test/resources/robolectric.properties
@@ -0,0 +1 @@
+sdk=28